Files
projet_gestion_commande/frontend-admin/src/screens/delivery/DashboardScreen.tsx
T
2026-03-05 17:01:51 +01:00

1707 lines
65 KiB
TypeScript

import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
RefObject,
} from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
AppState,
Dimensions,
Linking,
Modal,
StatusBar,
ScrollView,
} from "react-native";
import Geolocation from "@react-native-community/geolocation";
import { Ionicons } from "@expo/vector-icons";
import * as Location from "expo-location";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getMyStatus,
updateMyStatus,
getMyDeliveries,
getMyQueue,
getDeliveryDetails,
startDelivery,
updateDeliveryStatus,
updateMyLocation,
} from "../../api/api_delivery";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo } from "../../api/tomtom";
import type { DeliveryStatus, DeliveryItem, QueueInfo } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
import Button from "../../components/ui/Button";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
import TomTomMap, {
TomTomMapRef,
TomTomMarker,
} from "../../components/TomTomMap";
import DetailsModal from "../../components/ui/Modal";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const MAP_HEIGHT = 260;
const LOCATION_INTERVAL_MS = 15000;
const STATUS_LABELS: Record<string, string> = {
available: "Disponible",
busy: "Occupé",
offline: "Hors ligne",
};
// --------------------------------------------------
// Types
// --------------------------------------------------
interface EnrichedDelivery extends DeliveryItem {
clientName?: string;
clientPhone?: string;
clientUsername?: string;
clientNom?: string;
clientPrenom?: string;
items?: Array<{ produit: string; quantite: number; prix: number }>;
}
export default function DashboardScreen() {
const { colors } = useTheme();
const [status, setStatus] = useState<DeliveryStatus | null>(null);
const [deliveries, setDeliveries] = useState<EnrichedDelivery[]>([]);
const [queue, setQueue] = useState<QueueInfo | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// Geo
const [locationEnabled, setLocationEnabled] = useState(false);
const [lastCoords, setLastCoords] = useState<{
lat: number;
lng: number;
} | null>(null);
const lastCoordsRef = useRef<{ lat: number; lng: number } | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const locationInterval = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const appState = useRef(AppState.currentState);
// TomTom map refs
const mapRef = useRef<TomTomMapRef>(null);
const fullscreenMapRef = useRef<TomTomMapRef>(null);
const [mapFullscreen, setMapFullscreen] = useState(false);
const [routeLoading, setRouteLoading] = useState(false);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const pendingRouteAddress = useRef<string | null>(null);
const { alert, showError, showSuccess, hideAlert } = useAlert();
const [detailsDelivery, setDetailsDelivery] = useState<EnrichedDelivery | null>(null);
const STATUS_COLORS: Record<string, string> = useMemo(
() => ({
available: colors.success,
busy: colors.warning,
offline: colors.textMuted,
}),
[colors],
);
// Marker du livreur (position courante)
const driverMarkers: TomTomMarker[] = useMemo(() => {
if (!lastCoords) return [];
return [
{
id: "driver",
latitude: lastCoords.lat,
longitude: lastCoords.lng,
color: colors.success,
label: "Ma position",
description: lastUpdate
? lastUpdate.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})
: undefined,
},
];
}, [lastCoords, lastUpdate, colors.success]);
// --------------------------------------------------
// TomTom Route calculation
// Geocode + calcul dans DashboardScreen pour récupérer routeInfo
// --------------------------------------------------
const calcRoute = useCallback(
async (address: string, targetRef?: RefObject<TomTomMapRef | null>) => {
const coords = lastCoordsRef.current;
if (!coords) {
pendingRouteAddress.current = address;
return;
}
pendingRouteAddress.current = address;
const ref = targetRef ?? mapRef;
if (!ref.current) return;
setRouteLoading(true);
try {
const origin = { latitude: coords.lat, longitude: coords.lng };
const dest = await geocodeAddress(address);
if (!dest) {
setRouteLoading(false);
return;
}
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
ref.current?.calcRoute(origin, dest);
} else {
setRouteInfo(null);
ref.current?.calcRoute(origin, dest);
}
} catch {
/* silent */
} finally {
setRouteLoading(false);
}
},
[],
);
// --------------------------------------------------
// Data
// --------------------------------------------------
const loadData = useCallback(async () => {
try {
const [statusRes, deliveriesRes, queueRes] = await Promise.all([
getMyStatus(),
getMyDeliveries(),
getMyQueue(),
]);
if (statusRes.success && statusRes.status)
setStatus(statusRes.status);
if (queueRes.success && queueRes.queue_info)
setQueue(queueRes.queue_info);
const rawDeliveries =
deliveriesRes.success && deliveriesRes.deliveries
? deliveriesRes.deliveries
: [];
const queueCommands: DeliveryItem[] = [];
if (queueRes.success && queueRes.queue_info?.commands) {
for (const cmd of queueRes.queue_info.commands) {
const cmdId = cmd.id || cmd.command_id;
if (cmdId && !rawDeliveries.find((d) => d.id === cmdId)) {
queueCommands.push({
id: cmdId,
status: cmd.status || "assigned",
adresse:
cmd.adresse ||
cmd.delivery_address ||
"Adresse inconnue",
total_prix: cmd.total_prix || cmd.total_price || 0,
created_at: cmd.created_at || "",
updated_at: cmd.updated_at || "",
});
}
}
}
const allDeliveries = [...rawDeliveries, ...queueCommands].filter(
(d) => d.status !== "approved",
);
const enriched: EnrichedDelivery[] = await Promise.all(
allDeliveries
.filter((d) => d && d.id)
.map(async (d): Promise<EnrichedDelivery> => {
try {
const detailRes = await getDeliveryDetails(d.id);
if (detailRes.success && detailRes.delivery) {
const detail = detailRes.delivery;
const client = detail.client_info;
return {
...d,
adresse:
detail.delivery?.adresse || d.adresse,
clientName: client?.nom
? `${client.prenom || ""} ${client.nom}`.trim()
: client?.username || undefined,
clientPhone: client?.telephone || undefined,
clientUsername: client?.username || undefined,
clientNom: client?.nom || undefined,
clientPrenom: client?.prenom || undefined,
items:
detail.delivery?.items ||
(d as any).items ||
[],
};
}
} catch {
/* ignore */
}
return { ...d };
}),
);
setDeliveries(enriched);
const activeDelivery =
enriched.find(
(d) =>
d.status === "in_progress" || d.status === "en_route",
) || enriched.find((d) => d.status === "assigned");
if (activeDelivery && activeDelivery.adresse) {
calcRoute(activeDelivery.adresse);
}
} catch {
/* ignore */
}
setLoading(false);
}, [calcRoute]);
useEffect(() => {
loadData();
}, [loadData]);
// Quand GPS devient disponible, rejouer la route en attente
useEffect(() => {
if (lastCoords && pendingRouteAddress.current) {
calcRoute(pendingRouteAddress.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastCoords]);
// Quand le fullscreen s'ouvre, rejouer la route sur la carte fullscreen
useEffect(() => {
if (!mapFullscreen) return;
const addr = pendingRouteAddress.current;
const coords = lastCoordsRef.current;
if (!addr || !coords) return;
setTimeout(() => {
calcRoute(addr, fullscreenMapRef);
}, 1200);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapFullscreen]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
// --------------------------------------------------
// Location tracking
// --------------------------------------------------
const sendingRef = useRef(false);
const getLocationWithFallback = useCallback(
(): Promise<Location.LocationObject> =>
new Promise((resolve, reject) => {
Geolocation.getCurrentPosition(
(position) =>
resolve({
coords: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude ?? 0,
accuracy: position.coords.accuracy ?? 0,
altitudeAccuracy: position.coords.altitudeAccuracy ?? 0,
heading: position.coords.heading ?? 0,
speed: position.coords.speed ?? 0,
},
timestamp: position.timestamp,
} as Location.LocationObject),
(error) => reject(new Error(error.message)),
// enableHighAccuracy: false → provider réseau/cell (1-3s)
// au lieu du GPS pur qui peut prendre 30-60s (cold start)
{ enableHighAccuracy: false, timeout: 15000, maximumAge: 10000 },
);
}),
[],
);
const sendCurrentLocation = useCallback(async () => {
if (sendingRef.current) return;
sendingRef.current = true;
try {
const loc = await getLocationWithFallback();
if (!loc?.coords) {
return;
}
const coords = loc.coords;
const { latitude, longitude } = coords;
const wasNull = !lastCoordsRef.current;
lastCoordsRef.current = { lat: latitude, lng: longitude };
setLastCoords({ lat: latitude, lng: longitude });
setLastUpdate(new Date());
await updateMyLocation(latitude, longitude);
// Si c'est la première position GPS et qu'une adresse était en attente, rejouer
if (wasNull && pendingRouteAddress.current) {
const addr = pendingRouteAddress.current;
setTimeout(() => {
calcRoute(addr);
}, 1000);
}
} catch {
/* silent */
} finally {
sendingRef.current = false;
}
}, [getLocationWithFallback]);
const startLocationTracking = useCallback(async () => {
const { status: fgStatus } =
await Location.requestForegroundPermissionsAsync();
if (fgStatus !== "granted") {
showError(
"Permission requise",
"La géolocalisation est nécessaire pour le suivi des livraisons.",
);
return;
}
setLocationEnabled(true);
await sendCurrentLocation();
if (locationInterval.current) clearInterval(locationInterval.current);
locationInterval.current = setInterval(
sendCurrentLocation,
LOCATION_INTERVAL_MS,
);
}, [sendCurrentLocation]);
const stopLocationTracking = useCallback(() => {
if (locationInterval.current) {
clearInterval(locationInterval.current);
locationInterval.current = null;
}
setLocationEnabled(false);
}, []);
useEffect(() => {
startLocationTracking();
return () => stopLocationTracking();
}, [startLocationTracking, stopLocationTracking]);
useEffect(() => {
const sub = AppState.addEventListener("change", (nextState) => {
if (
appState.current.match(/inactive|background/) &&
nextState === "active"
) {
if (!locationInterval.current) {
sendCurrentLocation();
locationInterval.current = setInterval(
sendCurrentLocation,
LOCATION_INTERVAL_MS,
);
setLocationEnabled(true);
}
} else if (nextState.match(/inactive|background/)) {
if (locationInterval.current) {
clearInterval(locationInterval.current);
locationInterval.current = null;
}
}
appState.current = nextState;
});
return () => sub.remove();
}, [sendCurrentLocation]);
// --------------------------------------------------
// Actions
// --------------------------------------------------
const handleStatusChange = async (
newStatus: "available" | "busy" | "offline",
) => {
const res = await updateMyStatus(newStatus);
if (res.success) {
setStatus((prev) =>
prev ? { ...prev, status: newStatus } : { status: newStatus },
);
} else {
showError("Erreur", res.error || "Impossible de changer le statut");
}
};
const handleStartDelivery = async (deliveryId: number) => {
const lat = lastCoords?.lat || 0;
const lng = lastCoords?.lng || 0;
const res = await startDelivery(deliveryId, lat, lng);
if (res.success) {
showSuccess("Succès", "Livraison démarrée");
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const handleArrivedDelivery = async (deliveryId: number) => {
const lat = lastCoords?.lat || 0;
const lng = lastCoords?.lng || 0;
const res = await updateDeliveryStatus(deliveryId, "arrived", lat, lng);
if (res.success) {
showSuccess("Succès", "Statut mis à jour : arrivé à destination");
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const handleCompleteDelivery = async (deliveryId: number) => {
const lat = lastCoords?.lat || 0;
const lng = lastCoords?.lng || 0;
const res = await updateDeliveryStatus(deliveryId, "livre", lat, lng);
if (res.success) {
showSuccess("Succès", "Livraison terminée");
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const openNavigation = (address: string) => {
const encoded = encodeURIComponent(address);
const wazeApp = `waze://?q=${encoded}&navigate=yes`;
const wazeWeb = `https://waze.com/ul?q=${encoded}&navigate=yes`;
Linking.openURL(wazeApp).catch(() => {
Linking.openURL(wazeWeb);
});
};
// --------------------------------------------------
// Render delivery card
// --------------------------------------------------
const renderDelivery = ({ item }: { item: EnrichedDelivery }) => {
const isActive =
item.status === "in_progress" ||
item.status === "en_route" ||
item.status === "arrived" ||
item.status === "assigned";
return (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.cardHeader}>
<View style={{ flex: 1 }}>
<Text style={styles.deliveryId}>
Commande #{item.id}
</Text>
{item.clientName && (
<Text style={styles.clientName}>
{item.clientName}
</Text>
)}
</View>
<Badge
label={
item.status === "completed" ||
item.status === "livre"
? "Terminée"
: item.status === "arrived"
? "Arrivé"
: item.status === "in_progress" ||
item.status === "en_route"
? "En cours"
: "En attente"
}
color={
item.status === "completed" ||
item.status === "livre"
? colors.success
: item.status === "arrived"
? colors.accent
: item.status === "in_progress" ||
item.status === "en_route"
? colors.warning
: colors.info
}
/>
</View>
<TouchableOpacity
style={styles.addressRow}
onPress={() => openNavigation(item.adresse)}
activeOpacity={0.7}
>
<Ionicons
name="location-outline"
size={16}
color={colors.accent}
/>
<Text style={styles.address} numberOfLines={2}>
{item.adresse}
</Text>
<Ionicons
name="navigate-outline"
size={18}
color={colors.accent}
/>
</TouchableOpacity>
{isActive && lastCoords && (
<TouchableOpacity
style={styles.calcRouteBtn}
onPress={() => calcRoute(item.adresse)}
activeOpacity={0.7}
>
<Ionicons
name="map-outline"
size={16}
color={colors.white}
/>
<Text style={styles.calcRouteBtnText}>
{routeLoading
? "Calcul..."
: "Calculer l'itinéraire"}
</Text>
</TouchableOpacity>
)}
{item.items && item.items.length > 0 && (
<View style={styles.itemsSection}>
<View style={styles.itemsHeader}>
<Ionicons
name="bag-outline"
size={16}
color={colors.success}
/>
<Text style={styles.itemsTitle}>
Produits ({item.items.length})
</Text>
</View>
{item.items.map((prod, idx) => (
<View key={idx} style={styles.itemRow}>
<View style={{ flex: 1 }}>
<Text style={styles.itemName}>
{prod.produit}
</Text>
<Text style={styles.itemQty}>
Quantité: {prod.quantite}
</Text>
</View>
<Text style={styles.itemPrice}>
{(prod.prix ?? 0).toFixed(2)}
</Text>
</View>
))}
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total</Text>
<Text style={styles.totalValue}>
{item.total_prix ?? 0}
</Text>
</View>
</View>
)}
{(!item.items || item.items.length === 0) && (
<Text style={styles.priceOnly}>
{item.total_prix ?? 0}
</Text>
)}
{item.clientPhone && (
<TouchableOpacity
style={styles.phoneRow}
onPress={() =>
Linking.openURL(`tel:${item.clientPhone}`)
}
>
<Ionicons
name="call-outline"
size={16}
color={colors.info}
/>
<Text style={styles.phoneText}>{item.clientPhone}</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.detailsBtn}
onPress={() => setDetailsDelivery(item)}
activeOpacity={0.7}
>
<Ionicons name="list-outline" size={16} color={colors.accent} />
<Text style={styles.detailsBtnText}>Détails</Text>
</TouchableOpacity>
{(item.status === "pending" || item.status === "assigned") && (
<Button
title="Démarrer la livraison"
onPress={() => handleStartDelivery(item.id)}
style={{
marginTop: spacing.s,
backgroundColor: colors.success,
}}
/>
)}
{(item.status === "in_progress" ||
item.status === "en_route") && (
<Button
title="J'arrive"
onPress={() => handleArrivedDelivery(item.id)}
style={{
marginTop: spacing.s,
backgroundColor: colors.warning,
}}
/>
)}
{item.status === "arrived" && (
<Button
title="Terminer la livraison"
onPress={() => handleCompleteDelivery(item.id)}
style={{
marginTop: spacing.s,
backgroundColor: colors.accent,
}}
/>
)}
</Card>
);
};
// --------------------------------------------------
// Header avec TomTomMap
// --------------------------------------------------
const renderHeader = () => (
<View>
{/* Carte TomTom — toujours montée pour que le ref soit disponible */}
<View style={styles.mapContainer}>
<TomTomMap
ref={mapRef}
style={styles.map}
markers={driverMarkers}
initialCenter={
lastCoords
? {
latitude: lastCoords.lat,
longitude: lastCoords.lng,
}
: { latitude: 48.8566, longitude: 2.3522 }
}
initialZoom={14}
/>
{/* Overlay GPS */}
{lastCoords ? (
<View style={styles.gpsOverlay}>
<View
style={[
styles.gpsDot,
{
backgroundColor: locationEnabled
? colors.success
: colors.danger,
},
]}
/>
<Text style={styles.gpsOverlayText}>
{lastCoords.lat.toFixed(4)},{" "}
{lastCoords.lng.toFixed(4)}
</Text>
{lastUpdate && (
<Text style={styles.gpsOverlayTime}>
{lastUpdate.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
)}
</View>
) : (
<View style={styles.gpsOverlay}>
<Ionicons
name="location-outline"
size={16}
color={colors.textMuted}
/>
<Text style={styles.gpsOverlayText}>
Récupération GPS...
</Text>
</View>
)}
{/* Overlay infos trajet */}
{routeInfo && !routeLoading && (
<View style={styles.routeInfoOverlay}>
<View style={styles.routeInfoChip}>
<Ionicons
name="time-outline"
size={13}
color={colors.white}
/>
<Text style={styles.routeInfoOverlayText}>
{routeInfo.duration}
</Text>
</View>
<View style={styles.routeInfoChip}>
<Ionicons
name="navigate-outline"
size={13}
color={colors.white}
/>
<Text style={styles.routeInfoOverlayText}>
{routeInfo.distance}
</Text>
</View>
</View>
)}
{/* Bouton plein écran */}
<TouchableOpacity
style={styles.expandBtn}
onPress={() => setMapFullscreen(true)}
activeOpacity={0.7}
>
<Ionicons
name="expand-outline"
size={20}
color={colors.white}
/>
</TouchableOpacity>
{/* Indicateur calcul de route */}
{routeLoading && (
<View style={styles.routeLoadingOverlay}>
<Text style={styles.routeLoadingText}>
Calcul de l'itinéraire...
</Text>
</View>
)}
</View>
{/* Queue info */}
{queue && queue.queue_size > 0 && (
<View style={styles.queueBar}>
<Ionicons
name="layers-outline"
size={18}
color={colors.accent}
/>
<Text style={styles.queueText}>
{queue.queue_size} commande
{queue.queue_size > 1 ? "s" : ""} dans la queue
</Text>
</View>
)}
<Text style={styles.sectionTitle}>
Mes livraisons ({deliveries.length})
</Text>
</View>
);
// --------------------------------------------------
// Styles
// --------------------------------------------------
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
statusBar: {
padding: spacing.l,
backgroundColor: colors.bgSecondary,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
statusLabel: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
marginBottom: spacing.s,
},
statusBtns: { flexDirection: "row", gap: spacing.s },
statusBtn: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.xs,
paddingHorizontal: spacing.m,
borderRadius: 20,
borderWidth: 1,
borderColor: colors.border,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: spacing.xs,
},
statusText: { color: colors.textMuted, fontSize: fontSize.sm },
// Map
mapContainer: {
borderRadius: borderRadius.md,
overflow: "hidden",
marginBottom: spacing.m,
position: "relative",
height: MAP_HEIGHT,
},
map: { flex: 1 },
gpsOverlay: {
position: "absolute",
bottom: spacing.s,
left: spacing.s,
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "rgba(0,0,0,0.7)",
paddingVertical: 4,
paddingHorizontal: spacing.s,
borderRadius: borderRadius.sm,
},
gpsDot: { width: 8, height: 8, borderRadius: 4 },
gpsOverlayText: {
color: colors.textWhite,
fontSize: 10,
fontFamily: "monospace",
},
gpsOverlayTime: { color: colors.textMuted, fontSize: 10 },
noMapBox: {
alignItems: "center",
paddingVertical: spacing.xl,
marginBottom: spacing.m,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
},
noMapText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.s,
},
routeLoadingOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.4)",
justifyContent: "center",
alignItems: "center",
},
routeLoadingText: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "600",
},
expandBtn: {
position: "absolute",
top: spacing.s,
right: spacing.s,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
alignItems: "center",
},
routeInfoOverlay: {
position: "absolute",
top: spacing.s,
left: spacing.s,
flexDirection: "row",
gap: spacing.xs,
},
routeInfoChip: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "rgba(0,0,0,0.7)",
paddingVertical: 4,
paddingHorizontal: spacing.s,
borderRadius: 12,
},
routeInfoOverlayText: {
color: colors.white,
fontSize: fontSize.xs,
fontWeight: "700",
},
// Instructions
instructionBar: {
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
padding: spacing.m,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.border,
},
currentInstruction: {
flexDirection: "row",
alignItems: "center",
gap: spacing.m,
},
instructionIconBox: {
width: 42,
height: 42,
borderRadius: 12,
backgroundColor: colors.accent,
justifyContent: "center",
alignItems: "center",
},
instructionText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
},
instructionStreet: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 2,
},
instructionDist: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "700",
},
nextInstruction: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
},
nextLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "600",
},
nextText: {
color: colors.textSecondary,
fontSize: fontSize.xs,
flex: 1,
},
routeSummary: {
flexDirection: "row",
alignItems: "center",
gap: spacing.m,
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
},
showStepsBtn: {
flexDirection: "row",
alignItems: "center",
gap: 4,
marginLeft: "auto",
backgroundColor: colors.bgInput,
paddingVertical: 4,
paddingHorizontal: spacing.s,
borderRadius: 12,
},
showStepsBtnText: {
color: colors.textSecondary,
fontSize: fontSize.xs,
fontWeight: "600",
},
// All instructions
allInstructionsBox: {
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
padding: spacing.m,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.border,
},
allInstructionsHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.m,
},
allInstructionsTitle: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
stepRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.m,
paddingVertical: spacing.s,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
},
stepRowActive: {
backgroundColor: colors.accent + "10",
borderRadius: borderRadius.sm,
paddingHorizontal: spacing.s,
},
stepRowPassed: { opacity: 0.5 },
stepIcon: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: colors.bgInput,
justifyContent: "center",
alignItems: "center",
},
stepText: { color: colors.textWhite, fontSize: fontSize.sm },
stepStreet: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 1,
},
stepDist: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "600",
},
// Calc route button
calcRouteBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
backgroundColor: colors.accent,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
marginTop: spacing.s,
},
calcRouteBtnText: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "600",
},
// Queue
queueBar: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
backgroundColor: colors.accent + "15",
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
marginBottom: spacing.m,
},
queueText: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "600",
},
sectionTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
marginBottom: spacing.m,
},
// Card
cardHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.s,
},
deliveryId: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
clientName: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
addressRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
backgroundColor: colors.bgInput,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
},
address: {
flex: 1,
color: colors.textSecondary,
fontSize: fontSize.sm,
},
itemsSection: {
backgroundColor: colors.bgInput,
borderRadius: borderRadius.sm,
padding: spacing.m,
marginTop: spacing.s,
},
itemsHeader: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
},
itemsTitle: {
color: colors.success,
fontSize: fontSize.sm,
fontWeight: "700",
},
itemRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
},
itemName: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "600",
},
itemQty: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 1,
},
itemPrice: {
color: colors.success,
fontSize: fontSize.sm,
fontWeight: "600",
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
},
totalLabel: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: "600",
},
totalValue: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
priceOnly: {
color: colors.success,
fontSize: fontSize.sm,
fontWeight: "600",
marginTop: spacing.xs,
},
phoneRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginTop: spacing.s,
},
phoneText: { color: colors.info, fontSize: fontSize.sm },
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
// Fullscreen map modal
fullscreenContainer: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
fullscreenMap: { ...StyleSheet.absoluteFillObject },
fullscreenTopBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingTop: 50,
paddingHorizontal: spacing.l,
paddingBottom: spacing.m,
backgroundColor: "rgba(0,0,0,0.5)",
},
closeFullscreenBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.15)",
justifyContent: "center",
alignItems: "center",
},
fullscreenTitle: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
fullscreenInstructionBar: {
position: "absolute",
top: 110,
left: spacing.m,
right: spacing.m,
flexDirection: "row",
alignItems: "center",
gap: spacing.m,
backgroundColor: "rgba(0,0,0,0.8)",
padding: spacing.m,
borderRadius: borderRadius.md,
},
fullscreenBottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "rgba(0,0,0,0.7)",
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
paddingBottom: 40,
gap: spacing.s,
},
fullscreenInfoRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
fullscreenInfoText: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "600",
},
fullscreenCoords: {
color: colors.textMuted,
fontSize: fontSize.sm,
fontFamily: "monospace",
},
// Bouton détails
detailsBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
borderWidth: 1,
borderColor: colors.accent,
paddingVertical: spacing.s,
borderRadius: borderRadius.sm,
marginTop: spacing.m,
},
detailsBtnText: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "600",
},
// Modal détails
detailSection: { marginBottom: spacing.m },
detailSectionTitle: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "700",
marginBottom: spacing.s,
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
},
detailLabel: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
detailValue: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "600",
},
detailDivider: {
height: 1,
backgroundColor: colors.border,
marginBottom: spacing.m,
},
detailProductRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.s,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
},
detailProductName: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "600",
},
detailProductQty: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 2,
},
detailProductPrice: {
color: colors.success,
fontSize: fontSize.sm,
fontWeight: "700",
},
detailEmpty: {
color: colors.textMuted,
fontSize: fontSize.sm,
textAlign: "center",
paddingVertical: spacing.m,
},
detailTotalRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.m,
paddingTop: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.border,
},
detailTotalLabel: {
color: colors.textSecondary,
fontSize: fontSize.md,
fontWeight: "700",
},
detailTotalValue: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
fullscreenStatusBadge: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
gap: 6,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.m,
borderRadius: 20,
marginTop: spacing.xs,
},
fullscreenStatusText: {
fontSize: fontSize.sm,
fontWeight: "600",
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<View style={styles.container}>
{/* ── Modal plein écran avec TomTomMap ── */}
<Modal
visible={mapFullscreen}
animationType="fade"
onRequestClose={() => setMapFullscreen(false)}
statusBarTranslucent
>
<StatusBar hidden={mapFullscreen} />
<View style={styles.fullscreenContainer}>
<TomTomMap
ref={fullscreenMapRef}
style={styles.fullscreenMap}
markers={driverMarkers}
initialCenter={
lastCoords
? {
latitude: lastCoords.lat,
longitude: lastCoords.lng,
}
: { latitude: 48.8566, longitude: 2.3522 }
}
initialZoom={15}
/>
{/* Top bar */}
<View style={styles.fullscreenTopBar}>
<TouchableOpacity
style={styles.closeFullscreenBtn}
onPress={() => setMapFullscreen(false)}
>
<Ionicons
name="close"
size={24}
color={colors.white}
/>
</TouchableOpacity>
<Text style={styles.fullscreenTitle}>
{pendingRouteAddress.current
? "Itinéraire en cours"
: "GPS en direct"}
</Text>
<View style={{ width: 40 }} />
</View>
{/* Bottom info */}
<View style={styles.fullscreenBottomBar}>
{/* Adresse de destination */}
{pendingRouteAddress.current && (
<View style={styles.fullscreenInfoRow}>
<Ionicons
name="location"
size={16}
color={colors.danger}
/>
<Text
style={[
styles.fullscreenInfoText,
{ flex: 1 },
]}
numberOfLines={2}
>
{pendingRouteAddress.current}
</Text>
</View>
)}
{/* Infos trajet */}
{routeInfo && (
<View style={styles.fullscreenInfoRow}>
<View style={styles.routeInfoChip}>
<Ionicons
name="time-outline"
size={14}
color={colors.white}
/>
<Text style={styles.routeInfoOverlayText}>
{routeInfo.duration}
</Text>
</View>
<View
style={[
styles.routeInfoChip,
{ marginLeft: spacing.s },
]}
>
<Ionicons
name="navigate-outline"
size={14}
color={colors.white}
/>
<Text style={styles.routeInfoOverlayText}>
{routeInfo.distance}
</Text>
</View>
</View>
)}
{/* GPS status */}
<View style={styles.fullscreenInfoRow}>
<View
style={[
styles.gpsDot,
{
backgroundColor: locationEnabled
? colors.success
: colors.danger,
},
]}
/>
<Text style={styles.fullscreenInfoText}>
GPS {locationEnabled ? "actif" : "inactif"}
</Text>
{lastCoords && (
<Text
style={[
styles.fullscreenCoords,
{ marginLeft: 8 },
]}
>
{lastCoords.lat.toFixed(4)},{" "}
{lastCoords.lng.toFixed(4)}
</Text>
)}
</View>
{/* Bouton recalculer */}
{pendingRouteAddress.current && lastCoords && (
<TouchableOpacity
style={styles.calcRouteBtn}
onPress={() =>
calcRoute(
pendingRouteAddress.current!,
fullscreenMapRef,
)
}
activeOpacity={0.7}
>
<Ionicons
name="navigate"
size={16}
color={colors.white}
/>
<Text style={styles.calcRouteBtnText}>
Recalculer l'itinéraire
</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
{/* ── Modal détails commande ── */}
<DetailsModal
visible={detailsDelivery !== null}
onClose={() => setDetailsDelivery(null)}
title={`Commande #${detailsDelivery?.id}`}
icon="receipt-outline"
iconColor={colors.accent}
>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Infos client */}
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>
<Ionicons name="person-outline" size={14} color={colors.accent} />
{" "}Client
</Text>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Pseudo</Text>
<Text style={styles.detailValue}>
{detailsDelivery?.clientUsername || "—"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Prénom</Text>
<Text style={styles.detailValue}>
{detailsDelivery?.clientPrenom || "—"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Nom</Text>
<Text style={styles.detailValue}>
{detailsDelivery?.clientNom || "—"}
</Text>
</View>
</View>
{/* Séparateur */}
<View style={styles.detailDivider} />
{/* Produits */}
<Text style={styles.detailSectionTitle}>
<Ionicons name="bag-outline" size={14} color={colors.success} />
{" "}Produits
</Text>
{detailsDelivery?.items && detailsDelivery.items.length > 0 ? (
detailsDelivery.items.map((prod, idx) => (
<View key={idx} style={styles.detailProductRow}>
<View style={{ flex: 1 }}>
<Text style={styles.detailProductName}>
{prod.produit}
</Text>
<Text style={styles.detailProductQty}>
Quantité : {prod.quantite}
</Text>
</View>
<Text style={styles.detailProductPrice}>
{(prod.prix ?? 0).toFixed(2)}
</Text>
</View>
))
) : (
<Text style={styles.detailEmpty}>Aucun produit</Text>
)}
{/* Total */}
<View style={styles.detailTotalRow}>
<Text style={styles.detailTotalLabel}>Total</Text>
<Text style={styles.detailTotalValue}>
{detailsDelivery?.total_prix ?? 0}
</Text>
</View>
</ScrollView>
</DetailsModal>
{/* ── Barre de statut ── */}
<View style={styles.statusBar}>
<Text style={styles.statusLabel}>Mon statut:</Text>
<View style={styles.statusBtns}>
{(["available", "busy", "offline"] as const).map((s) => (
<TouchableOpacity
key={s}
onPress={() => handleStatusChange(s)}
style={[
styles.statusBtn,
status?.status === s && {
backgroundColor: STATUS_COLORS[s] + "33",
borderColor: STATUS_COLORS[s],
},
]}
>
<View
style={[
styles.dot,
{ backgroundColor: STATUS_COLORS[s] },
]}
/>
<Text
style={[
styles.statusText,
status?.status === s && {
color: STATUS_COLORS[s],
},
]}
>
{STATUS_LABELS[s]}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* ── Liste des livraisons ── */}
<FlatList
data={deliveries}
keyExtractor={(item, index) => (item.id ?? index).toString()}
renderItem={renderDelivery}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.success}
/>
}
contentContainerStyle={{
padding: spacing.l,
paddingBottom: 100,
}}
ListEmptyComponent={
<Text style={styles.empty}>
Aucune livraison en attente
</Text>
}
ListHeaderComponent={renderHeader()}
/>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</View>
);
}