chore: v1.0.0
Omnex Plateform App - EAS Build / build (push) Failing after 8m4s

This commit is contained in:
Xor290
2026-08-04 18:47:47 +02:00
parent 736ec86747
commit 877291e684
69 changed files with 35272 additions and 0 deletions
@@ -0,0 +1,16 @@
import React from "react";
import Badge from "./ui/Badge";
import { STATUS_LABELS, getStatusColors } from "../utils/constants";
import { useTheme } from "../context/ThemeContext";
interface StatusBadgeProps {
status: string;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const { colors } = useTheme();
const label = STATUS_LABELS[status] || status;
const statusColors = getStatusColors(colors);
const color = statusColors[status] || colors.textMuted;
return <Badge label={label} color={color} />;
}
@@ -0,0 +1,369 @@
import React, {
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { StyleSheet } from "react-native";
import { WebView } from "react-native-webview";
import { geocodeAddress, calculateRoute } from "../api/tomtom";
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
export interface TomTomMarker {
id: string;
latitude: number;
longitude: number;
color: string;
label?: string;
description?: string;
isSelected?: boolean;
}
export interface TomTomMapRef {
fitAllMarkers: () => void;
fitToCoordinates: (
coords: { latitude: number; longitude: number }[],
) => void;
calcRoute: (
origin: { latitude: number; longitude: number },
destination: { latitude: number; longitude: number },
) => void;
calcRouteFromAddress: (
origin: { latitude: number; longitude: number },
address: string,
) => void;
clearRoute: () => void;
}
interface TomTomMapProps {
style?: any;
markers?: TomTomMarker[];
initialCenter?: { latitude: number; longitude: number };
initialZoom?: number;
onMarkerPress?: (markerId: string) => void;
}
const TomTomMap = forwardRef<TomTomMapRef, TomTomMapProps>(
(
{
style,
markers = [],
initialCenter,
initialZoom = 14,
onMarkerPress,
},
ref,
) => {
const webViewRef = useRef<WebView>(null);
const isReady = useRef(false);
const pendingMessages = useRef<string[]>([]);
const sendMessage = useCallback((msg: object) => {
const json = JSON.stringify(msg);
if (isReady.current) {
webViewRef.current?.injectJavaScript(
`(function(){ try { handleMessage(${json}); } catch(e) {} })(); true;`,
);
} else {
pendingMessages.current.push(json);
}
}, []);
useImperativeHandle(
ref,
() => ({
fitAllMarkers: () => sendMessage({ type: "fitAll" }),
fitToCoordinates: (coords) =>
sendMessage({ type: "fitCoords", coords }),
clearRoute: () => sendMessage({ type: "clearRoute" }),
calcRoute: (origin, dest) => {
calculateRoute(origin, dest).then((result) => {
if (result) {
sendMessage({
type: "drawRoute",
coordinates: result.route.coordinates,
destination: dest,
});
} else {
// Fallback: ligne droite
sendMessage({
type: "drawRoute",
coordinates: [origin, dest],
destination: dest,
});
}
}).catch(() => {
sendMessage({
type: "drawRoute",
coordinates: [origin, dest],
destination: dest,
});
});
},
calcRouteFromAddress: async (origin, address) => {
try {
const dest = await geocodeAddress(address);
if (!dest) return;
const result = await calculateRoute(origin, dest);
if (result) {
sendMessage({
type: "drawRoute",
coordinates: result.route.coordinates,
destination: dest,
});
} else {
sendMessage({
type: "drawRoute",
coordinates: [origin, dest],
destination: dest,
});
}
} catch {
/* silent */
}
},
}),
[sendMessage],
);
useEffect(() => {
sendMessage({ type: "updateMarkers", markers });
}, [markers, sendMessage]);
const onMessage = useCallback(
(event: any) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === "ready") {
isReady.current = true;
// Rejouer les messages en attente
const pending = pendingMessages.current.slice();
pendingMessages.current = [];
for (const msg of pending) {
webViewRef.current?.injectJavaScript(
`(function(){ try { handleMessage(${msg}); } catch(e) {} })(); true;`,
);
}
// Fit sur tous les markers après un court délai
setTimeout(() => {
webViewRef.current?.injectJavaScript(
`(function(){ try { handleMessage(${JSON.stringify({ type: "fitAll" })}); } catch(e) {} })(); true;`,
);
}, 500);
} else if (data.type === "markerPress" && onMarkerPress) {
onMarkerPress(data.id);
}
} catch {
/* ignore */
}
},
[onMarkerPress],
);
const center =
initialCenter ||
(markers.length > 0
? {
latitude: markers[0].latitude,
longitude: markers[0].longitude,
}
: { latitude: 48.8566, longitude: 2.3522 });
const html = buildHtml(center, initialZoom, TOMTOM_API_KEY);
return (
<WebView
ref={webViewRef}
style={[styles.map, style]}
source={{ html, baseUrl: "https://api.tomtom.com" }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
scrollEnabled={false}
bounces={false}
originWhitelist={["*"]}
mixedContentMode="always"
allowFileAccessFromFileURLs
allowUniversalAccessFromFileURLs
androidLayerType="hardware"
/>
);
},
);
function buildHtml(
center: { latitude: number; longitude: number },
zoom: number,
apiKey: string,
) {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" href="https://api.tomtom.com/maps-sdk-for-web/cdn/6.x/6.25.0/maps/maps.css">
<script src="https://api.tomtom.com/maps-sdk-for-web/cdn/6.x/6.25.0/maps/maps-web.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #map { width: 100%; height: 100%; overflow: hidden; }
.marker-dot {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 2px solid rgba(255,255,255,0.8);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
}
.marker-dot.selected {
width: 36px; height: 36px;
border: 3px solid #7c3aed;
box-shadow: 0 0 12px rgba(124,58,237,0.6);
}
.dest-marker {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
var map = tt.map({
key: '${apiKey}',
container: 'map',
center: [${center.longitude}, ${center.latitude}],
zoom: ${zoom},
stylesVisibility: { trafficFlow: false, trafficIncidents: false }
});
var markerObjects = {};
var destMarker = null;
var ROUTE_SOURCE = 'tt-route-src';
var ROUTE_LAYER = 'tt-route-lyr';
// File d'attente pour les messages reçus avant que le style soit chargé
var pendingRouteData = null;
var styleLoaded = false;
function clearRoute() {
try { if (map.getLayer(ROUTE_LAYER)) map.removeLayer(ROUTE_LAYER); } catch(e) {}
try { if (map.getSource(ROUTE_SOURCE)) map.removeSource(ROUTE_SOURCE); } catch(e) {}
if (destMarker) { destMarker.remove(); destMarker = null; }
}
function drawRoute(coordinates, destination) {
clearRoute();
if (!destination) return;
// Marker destination (pin rouge)
var pin = document.createElement('div');
pin.className = 'dest-marker';
pin.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="#ef4444" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>';
destMarker = new tt.Marker({ element: pin })
.setLngLat([destination.longitude, destination.latitude])
.addTo(map);
if (!coordinates || coordinates.length < 2) {
map.flyTo({ center: [destination.longitude, destination.latitude], zoom: 15 });
return;
}
// Convertir les coords { latitude, longitude } en [lng, lat] pour GeoJSON
var lngLat = coordinates.map(function(c) {
return [c.longitude, c.latitude];
});
try {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: lngLat }
}
});
map.addLayer({
id: ROUTE_LAYER,
type: 'line',
source: ROUTE_SOURCE,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#4285F4', 'line-width': 6, 'line-opacity': 0.9 }
});
var bounds = new tt.LngLatBounds();
lngLat.forEach(function(c) { bounds.extend(c); });
map.fitBounds(bounds, { padding: 60, maxZoom: 16, duration: 800 });
} catch(e) {}
}
function handleMessage(data) {
if (data.type === 'updateMarkers') {
Object.values(markerObjects).forEach(function(m) { m.remove(); });
markerObjects = {};
(data.markers || []).forEach(function(m) {
var el = document.createElement('div');
el.className = 'marker-dot' + (m.isSelected ? ' selected' : '');
el.style.backgroundColor = m.color || '#7c3aed';
el.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="8"/></svg>';
el.addEventListener('click', function() {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'markerPress', id: m.id }));
});
var popup = new tt.Popup({ offset: 20, closeButton: false })
.setHTML('<div style="padding:4px 8px;font-size:12px;"><b>' + (m.label || m.id) + '</b>' + (m.description ? '<br>' + m.description : '') + '</div>');
var marker = new tt.Marker({ element: el })
.setLngLat([m.longitude, m.latitude])
.setPopup(popup)
.addTo(map);
markerObjects[m.id] = marker;
});
}
if (data.type === 'drawRoute') {
if (!styleLoaded) {
pendingRouteData = data;
} else {
drawRoute(data.coordinates, data.destination);
}
}
if (data.type === 'clearRoute') {
pendingRouteData = null;
clearRoute();
}
if (data.type === 'fitAll') {
var all = Object.values(markerObjects);
if (all.length > 0) {
var bounds = new tt.LngLatBounds();
all.forEach(function(m) { bounds.extend(m.getLngLat()); });
if (destMarker) bounds.extend(destMarker.getLngLat());
map.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 800 });
}
}
if (data.type === 'fitCoords') {
if (data.coords && data.coords.length > 0) {
var bounds = new tt.LngLatBounds();
data.coords.forEach(function(c) { bounds.extend([c.longitude, c.latitude]); });
map.fitBounds(bounds, { padding: 80, maxZoom: 15, duration: 800 });
}
}
}
map.on('load', function() {
styleLoaded = true;
if (pendingRouteData) {
drawRoute(pendingRouteData.coordinates, pendingRouteData.destination);
pendingRouteData = null;
}
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'ready' }));
});
</script>
</body>
</html>`;
}
const styles = StyleSheet.create({
map: { flex: 1 },
});
export default TomTomMap;
@@ -0,0 +1,266 @@
import React, { useEffect, useRef, useMemo } from "react";
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
type AlertType = "success" | "error" | "confirm";
interface AlertModalProps {
visible: boolean;
type?: AlertType;
title: string;
message: string;
onClose: () => void;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
}
export default function AlertModal({
visible,
type = "error",
title,
message,
onClose,
onConfirm,
confirmText = "Confirmer",
cancelText = "Annuler",
}: AlertModalProps) {
const { colors } = useTheme();
const scale = useRef(new Animated.Value(0.85)).current;
const opacity = useRef(new Animated.Value(0)).current;
const CONFIG: Record<
AlertType,
{
icon: keyof typeof Ionicons.glyphMap;
color: string;
barColor: string;
}
> = useMemo(
() => ({
success: {
icon: "checkmark-circle",
color: colors.success,
barColor: colors.success,
},
error: {
icon: "alert-circle",
color: colors.danger,
barColor: colors.danger,
},
confirm: {
icon: "help-circle",
color: colors.warning,
barColor: colors.warning,
},
}),
[colors],
);
const cfg = CONFIG[type];
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 8,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
scale.setValue(0.85);
opacity.setValue(0);
}
}, [visible]);
const styles = useMemo(
() =>
StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
content: {
backgroundColor: colors.bgSecondary,
borderRadius: 20,
width: "100%",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 30,
elevation: 20,
},
accentBar: {
height: 3,
width: "100%",
},
body: {
alignItems: "center",
paddingHorizontal: spacing.xl,
paddingTop: spacing.xl,
paddingBottom: spacing.xl,
},
iconCircle: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.l,
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
textAlign: "center",
marginBottom: spacing.s,
},
message: {
color: colors.textSecondary,
fontSize: fontSize.md,
textAlign: "center",
lineHeight: 20,
marginBottom: spacing.xl,
},
buttons: {
flexDirection: "row",
gap: spacing.m,
width: "100%",
justifyContent: "center",
},
btn: {
flex: 1,
paddingVertical: spacing.m,
borderRadius: 12,
alignItems: "center",
},
btnCancel: {
backgroundColor: "rgba(255,255,255,0.08)",
},
btnCancelText: {
color: colors.textSecondary,
fontSize: 15,
fontWeight: "600",
},
btnConfirm: {},
btnConfirmText: {
color: colors.textWhite,
fontSize: 15,
fontWeight: "700",
},
btnOk: {
flex: 0,
paddingHorizontal: spacing.xxl,
},
}),
[colors],
);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.content,
{ transform: [{ scale }], opacity },
]}
>
<View
style={[
styles.accentBar,
{ backgroundColor: cfg.barColor },
]}
/>
<View style={styles.body}>
<View
style={[
styles.iconCircle,
{ backgroundColor: cfg.color + "20" },
]}
>
<Ionicons
name={cfg.icon}
size={32}
color={cfg.color}
/>
</View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
<View style={styles.buttons}>
{type === "confirm" ? (
<>
<TouchableOpacity
style={[styles.btn, styles.btnCancel]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.btnCancelText}>
{cancelText}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.btn,
styles.btnConfirm,
{ backgroundColor: cfg.color },
]}
onPress={() => {
onConfirm?.();
onClose();
}}
activeOpacity={0.7}
>
<Text style={styles.btnConfirmText}>
{confirmText}
</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
style={[
styles.btn,
styles.btnOk,
{ backgroundColor: cfg.color },
]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.btnConfirmText}>
OK
</Text>
</TouchableOpacity>
)}
</View>
</View>
</Animated.View>
</View>
</Modal>
);
}
@@ -0,0 +1,31 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { spacing, borderRadius, fontSize, fontWeight } from '../../theme';
interface BadgeProps {
label: string;
color: string;
textColor?: string;
}
export default function Badge({ label, color, textColor = '#fff' }: BadgeProps) {
return (
<View style={[styles.badge, { backgroundColor: color + '22', borderColor: color }]}>
<Text style={[styles.text, { color }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: spacing.m,
paddingVertical: spacing.xs,
borderRadius: borderRadius.xl,
borderWidth: 1,
alignSelf: 'flex-start',
},
text: {
fontSize: fontSize.xs,
fontWeight: fontWeight.semibold,
},
});
@@ -0,0 +1,111 @@
import React from "react";
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
type ViewStyle,
type TextStyle,
} from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ButtonProps {
title: string;
onPress: () => void;
variant?:
| "primary"
| "secondary"
| "danger"
| "success"
| "warning"
| "outline"
| "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
fullWidth?: boolean;
}
export default function Button({
title,
onPress,
variant = "primary",
size = "md",
loading = false,
disabled = false,
style,
textStyle,
fullWidth = false,
}: ButtonProps) {
const { colors } = useTheme();
const bgColor = {
primary: colors.accent,
secondary: colors.bgCard,
danger: colors.danger,
success: colors.success,
warning: colors.warning,
outline: "transparent",
ghost: "transparent",
}[variant];
const txtColor = variant === "success" ? colors.black : colors.textWhite;
const borderColor = variant === "outline" ? colors.border : "transparent";
const paddingV = { sm: spacing.s, md: spacing.m, lg: spacing.l }[size];
const paddingH = { sm: spacing.m, md: spacing.xl, lg: spacing.xxl }[size];
const fSize = { sm: fontSize.sm, md: fontSize.md, lg: fontSize.lg }[size];
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
style={[
styles.base,
{
backgroundColor: bgColor,
borderColor,
paddingVertical: paddingV,
paddingHorizontal: paddingH,
opacity: disabled ? 0.5 : 1,
},
fullWidth && styles.fullWidth,
style,
]}
>
{loading ? (
<ActivityIndicator color={txtColor} size="small" />
) : (
<Text
style={[
styles.text,
{ color: txtColor, fontSize: fSize },
textStyle,
]}
>
{title}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: borderRadius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
flexDirection: "row",
},
fullWidth: {
width: "100%",
},
text: {
fontWeight: fontWeight.semibold,
},
});
@@ -0,0 +1,29 @@
import React, { useMemo } from "react";
import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
import { spacing, borderRadius, shadows } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CardProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
export default function Card({ children, style }: CardProps) {
const { colors } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
card: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
},
}),
[colors],
);
return <View style={[styles.card, shadows.md, style]}>{children}</View>;
}
@@ -0,0 +1,42 @@
import React, { useMemo } from "react";
import { View, ActivityIndicator, Text, StyleSheet } from "react-native";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface LoadingSpinnerProps {
message?: string;
size?: "small" | "large";
}
export default function LoadingSpinner({
message,
size = "large",
}: LoadingSpinnerProps) {
const { colors } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.bgPrimary,
padding: spacing.xl,
},
text: {
color: colors.textSecondary,
fontSize: fontSize.md,
marginTop: spacing.l,
},
}),
[colors],
);
return (
<View style={styles.container}>
<ActivityIndicator size={size} color={colors.accent} />
{message && <Text style={styles.text}>{message}</Text>}
</View>
);
}
@@ -0,0 +1,198 @@
import React, { type ReactNode, useEffect, useRef, useMemo } from "react";
import {
Modal as RNModal,
View,
ScrollView,
KeyboardAvoidingView,
TouchableOpacity,
Text,
StyleSheet,
Animated,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ModalProps {
visible: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
}
export default function Modal({
visible,
onClose,
title,
children,
icon,
iconColor,
}: ModalProps) {
const { colors } = useTheme();
const scale = useRef(new Animated.Value(0.85)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 8,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
scale.setValue(0.85);
opacity.setValue(0);
}
}, [visible]);
const styles = useMemo(
() =>
StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
content: {
backgroundColor: colors.bgSecondary,
borderRadius: 20,
width: "100%",
maxHeight: "80%",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
overflow: "hidden",
shadowColor: colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.15,
shadowRadius: 30,
elevation: 20,
},
accentBar: {
height: 3,
backgroundColor: colors.accent,
width: "100%",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing.xl,
paddingTop: spacing.l,
paddingBottom: spacing.m,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
flex: 1,
},
iconCircle: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.accent + "20",
justifyContent: "center",
alignItems: "center",
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
flex: 1,
},
closeBtn: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(255,255,255,0.06)",
justifyContent: "center",
alignItems: "center",
},
body: {
paddingHorizontal: spacing.xl,
},
}),
[colors],
);
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.content,
{ transform: [{ scale }], opacity },
]}
>
<View style={styles.accentBar} />
<View style={styles.header}>
<View style={styles.titleRow}>
{icon && (
<View
style={[
styles.iconCircle,
iconColor
? {
backgroundColor:
iconColor + "20",
}
: null,
]}
>
<Ionicons
name={icon}
size={20}
color={iconColor || colors.accent}
/>
</View>
)}
{title && <Text style={styles.title}>{title}</Text>}
</View>
<TouchableOpacity
onPress={onClose}
style={styles.closeBtn}
activeOpacity={0.7}
>
<Ionicons
name="close"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<ScrollView
style={styles.body}
contentContainerStyle={{ paddingBottom: spacing.xl }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
</Animated.View>
</View>
</KeyboardAvoidingView>
</RNModal>
);
}
@@ -0,0 +1,90 @@
import React, { useMemo } from "react";
import {
View,
TextInput as RNTextInput,
Text,
StyleSheet,
type TextInputProps,
} from "react-native";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CustomTextInputProps extends TextInputProps {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export default function TextInput({
label,
error,
icon,
style,
...props
}: CustomTextInputProps) {
const { colors } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
container: {
marginBottom: spacing.l,
},
label: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginBottom: spacing.s,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgInput,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
},
inputError: {
borderColor: colors.danger,
},
icon: {
paddingLeft: spacing.m,
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
},
inputWithIcon: {
paddingLeft: spacing.s,
},
errorText: {
color: colors.danger,
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
}),
[colors],
);
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={[styles.inputWrapper, error && styles.inputError]}>
{icon && <View style={styles.icon}>{icon}</View>}
<RNTextInput
style={[
styles.input,
icon ? styles.inputWithIcon : undefined,
style,
]}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accent}
{...props}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
@@ -0,0 +1,108 @@
import React, { useEffect, useRef, useMemo } from "react";
import { Animated, Text, StyleSheet } from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ToastProps {
message: string;
type: "success" | "error" | "warning" | "info";
visible: boolean;
onHide: () => void;
duration?: number;
}
export default function Toast({
message,
type,
visible,
onHide,
duration = 3000,
}: ToastProps) {
const { colors } = useTheme();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-50)).current;
const TYPE_COLORS = useMemo(
() => ({
success: colors.success,
error: colors.danger,
warning: colors.warning,
info: colors.info,
}),
[colors],
);
const styles = useMemo(
() =>
StyleSheet.create({
container: {
position: "absolute",
top: 60,
left: spacing.l,
right: spacing.l,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
borderRadius: borderRadius.sm,
zIndex: 9999,
},
text: {
color: colors.black,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
textAlign: "center",
},
}),
[colors],
);
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => {
Animated.parallel([
Animated.timing(opacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: -50,
duration: 300,
useNativeDriver: true,
}),
]).start(() => onHide());
}, duration);
return () => clearTimeout(timer);
}
}, [visible]);
if (!visible) return null;
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: TYPE_COLORS[type],
opacity,
transform: [{ translateY }],
},
]}
>
<Text style={styles.text}>{message}</Text>
</Animated.View>
);
}