This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user