chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 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} />;
}
+300
View File
@@ -0,0 +1,300 @@
import React, {
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { StyleSheet } from "react-native";
import { WebView } from "react-native-webview";
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
export interface TomTomMarker {
id: string;
latitude: number;
longitude: number;
color: string;
label?: string;
description?: string;
isSelected?: boolean;
}
export interface TomTomRoute {
coordinates: { latitude: number; longitude: number }[];
color?: string;
}
export interface TomTomDestination {
latitude: number;
longitude: number;
color?: string;
}
export interface TomTomMapRef {
fitAllMarkers: () => void;
fitToCoordinates: (
coords: { latitude: number; longitude: number }[],
) => void;
}
interface TomTomMapProps {
style?: any;
markers?: TomTomMarker[];
route?: TomTomRoute | null;
destination?: TomTomDestination | null;
initialCenter?: { latitude: number; longitude: number };
initialZoom?: number;
onMarkerPress?: (markerId: string) => void;
}
const TomTomMap = forwardRef<TomTomMapRef, TomTomMapProps>(
(
{
style,
markers = [],
route,
destination,
initialCenter,
initialZoom = 12,
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(
`handleMessage(${json}); true;`,
);
} else {
pendingMessages.current.push(json);
}
}, []);
useImperativeHandle(
ref,
() => ({
fitAllMarkers: () => sendMessage({ type: "fitAll" }),
fitToCoordinates: (coords) =>
sendMessage({ type: "fitCoords", coords }),
}),
[sendMessage],
);
useEffect(() => {
sendMessage({ type: "updateMarkers", markers });
}, [markers, sendMessage]);
useEffect(() => {
sendMessage({
type: "updateRoute",
route: route || null,
destination: destination || null,
});
}, [route, destination, sendMessage]);
const onMessage = useCallback(
(event: any) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === "ready") {
isReady.current = true;
// flush pending
for (const msg of pendingMessages.current) {
webViewRef.current?.injectJavaScript(
`handleMessage(${msg}); true;`,
);
}
pendingMessages.current = [];
// Auto fit markers after ready + flush
setTimeout(() => {
sendMessage({ type: "fitAll" });
}, 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);
return (
<WebView
ref={webViewRef}
style={[styles.map, style]}
source={{ html }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
scrollEnabled={false}
bounces={false}
originWhitelist={["*"]}
/>
);
},
);
function buildHtml(
center: { latitude: number; longitude: number },
zoom: number,
) {
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; }
html, body, #map { width: 100%; height: 100%; }
.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);
font-size: 12px; color: white; 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: 24px; height: 24px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 2px solid white;
font-size: 10px; color: white;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
}
</style>
</head>
<body>
<div id="map"></div>
<script>
var map = tt.map({
key: '${TOMTOM_API_KEY}',
container: 'map',
center: [${center.longitude}, ${center.latitude}],
zoom: ${zoom},
stylesVisibility: { trafficFlow: false, trafficIncidents: false }
});
var markers = {};
var routeLayer = null;
var destMarker = null;
function clearMarkers() {
Object.values(markers).forEach(function(m) { m.remove(); });
markers = {};
}
function clearRoute() {
if (routeLayer && map.getSource('route')) {
map.removeLayer('route-line');
map.removeSource('route');
routeLayer = null;
}
if (destMarker) { destMarker.remove(); destMarker = null; }
}
function handleMessage(data) {
if (data.type === 'updateMarkers') {
clearMarkers();
(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"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></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);
markers[m.id] = marker;
});
}
if (data.type === 'updateRoute') {
clearRoute();
if (data.destination) {
var el = document.createElement('div');
el.className = 'dest-marker';
el.style.backgroundColor = data.destination.color || '#ef4444';
el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="white"><path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z"/></svg>';
destMarker = new tt.Marker({ element: el })
.setLngLat([data.destination.longitude, data.destination.latitude])
.addTo(map);
}
if (data.route && data.route.coordinates && data.route.coordinates.length > 1) {
var coords = data.route.coordinates.map(function(c) { return [c.longitude, c.latitude]; });
map.addSource('route', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }
});
map.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
paint: {
'line-color': data.route.color || '#4285F4',
'line-width': 4,
'line-opacity': 0.8
}
});
routeLayer = true;
}
}
if (data.type === 'fitAll') {
var allM = Object.values(markers);
if (allM.length > 0) {
var bounds = new tt.LngLatBounds();
allM.forEach(function(m) { bounds.extend(m.getLngLat()); });
map.fitBounds(bounds, { padding: 60, maxZoom: 15 });
}
}
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 });
}
}
}
map.on('load', function() {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'ready' }));
});
</script>
</body>
</html>`;
}
const styles = StyleSheet.create({
map: { flex: 1 },
});
export default TomTomMap;
@@ -0,0 +1,265 @@
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%",
},
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,
},
});
+109
View File
@@ -0,0 +1,109 @@
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"
| "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,
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,
},
});
+29
View File
@@ -0,0 +1,29 @@
import React, { useMemo } from "react";
import { View, StyleSheet, type ViewStyle } from "react-native";
import { spacing, borderRadius, shadows } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CardProps {
children: React.ReactNode;
style?: 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>
);
}
+184
View File
@@ -0,0 +1,184 @@
import React, { type ReactNode, useEffect, useRef, useMemo } from "react";
import {
Modal as RNModal,
View,
TouchableOpacity,
Text,
StyleSheet,
Animated,
} 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,
paddingBottom: spacing.xl,
},
}),
[colors],
);
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<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>
<View style={styles.body}>{children}</View>
</Animated.View>
</View>
</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>
);
}
+108
View File
@@ -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>
);
}