Exemple complet : pointer
On suit un geste de bout en bout : l'employé maintient le bouton Service pour commencer sa journée. Cette page traverse toutes les couches de l'app.
Vue d'ensemble
1. L'écran : src/app/(tabs)/index.tsx
export default function Clock() {
const { t } = useTranslation();
const clock = useToday();
const teamName = useTeamName();
// ...
<ClockButton
kind="shift"
label={t('clock.shift')}
running={clock.status === 'on'}
timer={formatTimer(clock.workedMinutes)}
accessibilityLabel={
clock.status === 'on' ? t('clock.clockOut') : clock.status === 'break' ? t('clock.endBreak') : t('clock.clockIn')
}
disabled={clock.sending || !clock.data}
onPress={clock.toggleShift}
/>
{clock.status !== 'off' && (
<ClockButton kind="break" ... onPress={clock.toggleBreak} />
)}
}L'écran ne sait pas comment on pointe. Il récupère tout de useToday() et passe la bonne fonction au bouton.
disabled={clock.sending || !clock.data}empêche un second pointage pendant que le premier part, et tout pointage tant qu'on ne connaît pas encore l'état du jour.- Le bouton Pause n'apparaît que lorsqu'on est en service ou en pause.
- Le nom lu par VoiceOver change selon l'état : « Pointer l'arrivée », « Pointer le départ » ou « Finir la pause ».
2. Le bouton : src/components/clock/clock-button.tsx
Le sujet dit « un clic pour pointer ». Sur téléphone, un simple appui se déclenche trop facilement dans la poche. Le bouton demande donc un appui maintenu d'une seconde.
const HOLD_MS = 1000;
const progress = useRef(new Animated.Value(0)).current;
const startHold = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Animated.timing(progress, { toValue: 1, duration: HOLD_MS, easing: Easing.linear, useNativeDriver: false })
.start(({ finished }) => {
if (!finished) return;
progress.setValue(0);
confirm();
});
};
const cancelHold = () => {
progress.stopAnimation();
Animated.timing(progress, { toValue: 0, duration: 150, useNativeDriver: false }).start();
};
const confirm = () => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onPress();
};| Moment | Ce qui se passe |
|---|---|
Le doigt se pose (onPressIn) | Petite vibration, l'anneau commence à se remplir |
| Pendant 1 seconde | progress passe de 0 à 1 |
Le doigt part avant la fin (onPressOut) | L'animation s'arrête (finished vaut false), l'anneau se vide en 150 ms, rien n'est envoyé |
| La seconde est écoulée | Vibration « succès », l'anneau se remet à 0, onPress() est appelé |
L'anneau
L'anneau est un cercle SVG dont on ne dessine qu'une partie du contour :
<AnimatedCircle
r={RING_RADIUS}
stroke={ringColor}
strokeDasharray={`${RING_LENGTH} ${RING_LENGTH}`}
strokeDashoffset={progress.interpolate({ inputRange: [0, 1], outputRange: [RING_LENGTH, 0] })}
transform={`rotate(-90 ...)`}
/>strokeDasharraydit : un trait de la longueur du cercle, puis un vide de la même longueur.strokeDashoffsetdécale ce trait. ÀRING_LENGTH, il est entièrement caché. À0, il fait tout le tour.interpolateconvertitprogress(0 à 1) en décalage (longueur à 0).rotate(-90)fait partir l'anneau du haut, comme une horloge.
Les couleurs
| État | Bouton | Anneau |
|---|---|---|
| À l'arrêt | Blanc, bordure verte foncée, icône lecture | Vert foncé |
| Travail en cours | Rouge, icône stop blanche | Rouge |
| Pause en cours | Sable, icône stop | Sable foncé |
Accessibilité
Avec VoiceOver ou TalkBack, maintenir un bouton n'est pas pratique. Le bouton déclare une action activate : un double tap lance directement onPress(), sans attendre.
accessibilityActions={[{ name: 'activate' }]}
onAccessibilityAction={(event) => event.nativeEvent.actionName === 'activate' && !disabled && onPress()}3. Le hook : src/hooks/use-today.ts
const run = (action: (userId: number) => Promise<void>) => async () => {
setSending(true);
try {
await action(user.id);
await query.reload();
} finally {
setSending(false);
}
};
return {
// ...
toggleShift: run(status === 'break' ? toggleBreak : toggleClock),
toggleBreak: run(toggleBreak),
};run fabrique une fonction qui envoie l'action puis recharge les données. Le finally remet sending à false même en cas d'erreur, pour que le bouton ne reste pas bloqué.
Le bouton Service pendant une pause
Pendant une pause, le bouton Service sert à reprendre le travail. toggleShift appelle alors toggleBreak, qui ferme la pause. Pour partir, on reprend puis on pointe le départ. C'est pour ça que son nom accessible devient « Finir la pause » dans ce cas.
4. L'appel : src/api/working-times.ts
export async function toggleClock(userId: number) {
await api(`/clocks/${userId}`, { method: 'POST' });
}C'est tout. Il n'y a pas de paramètre « arrivée » ou « départ » : l'API décide selon l'état actuel. S'il n'y a pas de période ouverte, elle en crée une. S'il y en a une, elle la ferme. Elle utilise sa propre heure, pas celle du téléphone (ce point compte pour le mode hors ligne).
5. Le client : src/api/client.ts
api() ajoute les cookies et le jeton CSRF, et renouvelle la session si besoin. Voir Parler à l'API.
6. Le rechargement et les calculs
Après l'envoi, query.reload() relance :
const [periods, lastClockOut] = await Promise.all([
fetchWorkingTimes(user.id, addDays(today, -1), addDays(today, 1)),
fetchLastClockOut(user.id),
]);On charge d'hier à demain pour attraper une journée de nuit commencée la veille et pas encore finie.
Puis le hook calcule tout ce que l'écran affiche :
const shift = periods.find((p) => p.end === null && !isForgotten(p, now)) ?? null;
const currentBreak = shift?.breaks.find((b) => b.end === null) ?? null;
const todayShifts = periods.filter((p) => p === shift || toDateKey(p.start) === todayKey);
const workedMinutes = todayShifts.reduce((total, p) => total + workedMinutesOf(p, now), 0);
const status: ClockStatus = currentBreak ? 'break' : shift ? 'on' : 'off';| Valeur | Règle |
|---|---|
shift | La période sans fin, sauf si elle dure depuis plus de 14 h (c'est un oubli, pas une journée en cours) |
currentBreak | La pause sans fin dans cette période |
todayShifts | Les périodes commencées aujourd'hui, plus celle en cours |
workedMinutes | Somme de (fin ou maintenant) moins début moins pauses |
status | Pause si une pause est ouverte, sinon en service si une période est ouverte, sinon hors service |
7. Le chrono qui tourne
useToday utilise useNow(), qui renvoie une nouvelle Date chaque seconde. Comme workedMinutes est calculé avec now, il augmente tout seul et React redessine le chrono. Aucune requête n'est faite pour ça : le calcul est local.
Ce qu'il faut retenir
| Couche | Fichier | Responsabilité |
|---|---|---|
| Écran | (tabs)/index.tsx | Afficher, brancher le bouton |
| Composant | clock/clock-button.tsx | Le geste, l'animation, les vibrations |
| Hook | hooks/use-today.ts | L'état du jour, les actions |
| API | api/working-times.ts | La requête et la conversion des dates |
| Client | api/client.ts | Cookies, CSRF, renouvellement |
| Calculs | utils/history.ts | Oublis, minutes travaillées |
| Temps | hooks/use-now.ts | L'heure qui avance |