Skip to content

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 ​

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.

tsx
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();
};
MomentCe qui se passe
Le doigt se pose (onPressIn)Petite vibration, l'anneau commence à se remplir
Pendant 1 secondeprogress 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éeVibration « 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 :

tsx
<AnimatedCircle
  r={RING_RADIUS}
  stroke={ringColor}
  strokeDasharray={`${RING_LENGTH} ${RING_LENGTH}`}
  strokeDashoffset={progress.interpolate({ inputRange: [0, 1], outputRange: [RING_LENGTH, 0] })}
  transform={`rotate(-90 ...)`}
/>
  • strokeDasharray dit : un trait de la longueur du cercle, puis un vide de la même longueur.
  • strokeDashoffset décale ce trait. À RING_LENGTH, il est entièrement caché. À 0, il fait tout le tour.
  • interpolate convertit progress (0 à 1) en décalage (longueur à 0).
  • rotate(-90) fait partir l'anneau du haut, comme une horloge.

Les couleurs ​

ÉtatBoutonAnneau
À l'arrêtBlanc, bordure verte foncée, icône lectureVert foncé
Travail en coursRouge, icône stop blancheRouge
Pause en coursSable, icône stopSable 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.

tsx
accessibilityActions={[{ name: 'activate' }]}
onAccessibilityAction={(event) => event.nativeEvent.actionName === 'activate' && !disabled && onPress()}

3. Le hook : src/hooks/use-today.ts ​

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 ​

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 :

ts
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 :

ts
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';
ValeurRègle
shiftLa période sans fin, sauf si elle dure depuis plus de 14 h (c'est un oubli, pas une journée en cours)
currentBreakLa pause sans fin dans cette période
todayShiftsLes périodes commencées aujourd'hui, plus celle en cours
workedMinutesSomme de (fin ou maintenant) moins début moins pauses
statusPause 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 ​

CoucheFichierResponsabilité
Écran(tabs)/index.tsxAfficher, brancher le bouton
Composantclock/clock-button.tsxLe geste, l'animation, les vibrations
Hookhooks/use-today.tsL'état du jour, les actions
APIapi/working-times.tsLa requête et la conversion des dates
Clientapi/client.tsCookies, CSRF, renouvellement
Calculsutils/history.tsOublis, minutes travaillées
Tempshooks/use-now.tsL'heure qui avance