diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..829d900
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+# Valeurs de développement local UNIQUEMENT
+# NE JAMAIS committer .env avec de vraies valeurs
+# En production, tous les secrets transitent par expo-secure-store (Android Keystore)
+
+# URL de l'instance n8n — doit utiliser HTTPS en production
+N8N_BASE_URL=https://n8n.votre-domaine.com
+
+# Clé API n8n (Settings > API Keys dans l'interface n8n)
+# Scope minimal recommandé : lecture workflows + exécutions uniquement
+N8N_API_KEY=votre-cle-api-n8n
+
+# Token custom injecté par HAProxy (header X-App-Token)
+# Tourner régulièrement selon la procédure dans docs/HAPROXY.md
+N8N_APP_TOKEN=votre-token-haproxy
diff --git a/.gitignore b/.gitignore
index d914c32..6dfeeb0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,9 +30,14 @@ yarn-error.*
.DS_Store
*.pem
-# local env files
+# local env files — NE JAMAIS committer les vraies valeurs
+.env
+.env.local
.env*.local
+# EAS credentials — keystore critique, sauvegarder hors git
+credentials.json
+
# typescript
*.tsbuildinfo
diff --git a/App.js b/App.js
deleted file mode 100644
index 09f879b..0000000
--- a/App.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { StatusBar } from 'expo-status-bar';
-import { StyleSheet, Text, View } from 'react-native';
-
-export default function App() {
- return (
-
- Open up App.js to start working on your app!
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#fff',
- alignItems: 'center',
- justifyContent: 'center',
- },
-});
diff --git a/app.json b/app.json
index ccf0246..93da178 100644
--- a/app.json
+++ b/app.json
@@ -1,29 +1,41 @@
{
"expo": {
- "name": "n8n",
- "slug": "n8n",
+ "name": "n8n Pilot",
+ "slug": "n8n-pilot",
+ "scheme": "n8npilot",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
- "userInterfaceStyle": "light",
+ "userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
- "backgroundColor": "#ffffff"
+ "backgroundColor": "#121212"
},
"ios": {
- "supportsTablet": true
+ "supportsTablet": false
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
- "backgroundColor": "#ffffff"
+ "backgroundColor": "#121212"
},
- "edgeToEdgeEnabled": true
+ "edgeToEdgeEnabled": true,
+ "package": "com.n8npilot.app"
},
"web": {
"favicon": "./assets/favicon.png"
- }
+ },
+ "plugins": [
+ "expo-router",
+ "expo-secure-store",
+ [
+ "expo-local-authentication",
+ {
+ "faceIDPermission": "Autoriser n8n Pilot à utiliser Face ID"
+ }
+ ]
+ ]
}
}
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..40ec781
--- /dev/null
+++ b/app/(tabs)/_layout.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { Tabs } from 'expo-router';
+import { useTheme } from 'react-native-paper';
+import { MaterialCommunityIcons } from '@expo/vector-icons';
+
+/**
+ * Props pour les icônes de tab — typage strict MaterialCommunityIcons.
+ */
+interface TabIconProps {
+ name: React.ComponentProps['name'];
+ color: string;
+ size: number;
+}
+
+/** Composant d'icône de tab — évite la répétition du rendu inline */
+const TabIcon: React.FC = ({ name, color, size }) => (
+
+);
+
+/**
+ * Layout de navigation par onglets (4 tabs principaux).
+ * Les logs d'exécution sont accessibles via le Stack navigator (pas de tab dédié)
+ * pour garder la nav propre et hiérarchiquement correcte.
+ */
+export default function TabsLayout() {
+ const theme = useTheme();
+
+ return (
+
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+
+ );
+}
diff --git a/app/(tabs)/executions.tsx b/app/(tabs)/executions.tsx
new file mode 100644
index 0000000..ecafab5
--- /dev/null
+++ b/app/(tabs)/executions.tsx
@@ -0,0 +1,3 @@
+import ExecutionsScreen from '../../src/screens/ExecutionsScreen';
+
+export default ExecutionsScreen;
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
new file mode 100644
index 0000000..cfac0af
--- /dev/null
+++ b/app/(tabs)/index.tsx
@@ -0,0 +1,3 @@
+import DashboardScreen from '../../src/screens/DashboardScreen';
+
+export default DashboardScreen;
diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..12bc1b6
--- /dev/null
+++ b/app/(tabs)/settings.tsx
@@ -0,0 +1,3 @@
+import SettingsScreen from '../../src/screens/SettingsScreen';
+
+export default SettingsScreen;
diff --git a/app/(tabs)/workflows.tsx b/app/(tabs)/workflows.tsx
new file mode 100644
index 0000000..dfac2db
--- /dev/null
+++ b/app/(tabs)/workflows.tsx
@@ -0,0 +1,3 @@
+import WorkflowsScreen from '../../src/screens/WorkflowsScreen';
+
+export default WorkflowsScreen;
diff --git a/app/_layout.tsx b/app/_layout.tsx
new file mode 100644
index 0000000..de72729
--- /dev/null
+++ b/app/_layout.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useState } from 'react';
+import { View } from 'react-native';
+import { Stack, useRouter, useSegments } from 'expo-router';
+import { PaperProvider, MD3DarkTheme, ActivityIndicator, Snackbar } from 'react-native-paper';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { useAppStore } from '../src/store/appStore';
+import { registerToastCallback } from '../src/utils/errorHandler';
+
+/**
+ * Thème Material Design 3 sombre personnalisé pour n8n Pilot.
+ * L'orange (#FF6D3E) est la couleur d'accent principale — cohérente avec l'identité n8n.
+ */
+const APP_THEME = {
+ ...MD3DarkTheme,
+ colors: {
+ ...MD3DarkTheme.colors,
+ primary: '#FF6D3E',
+ primaryContainer: '#5C1C00',
+ secondary: '#FF9E7D',
+ background: '#121212',
+ surface: '#1E1E1E',
+ surfaceVariant: '#2C2C2C',
+ },
+};
+
+/**
+ * Layout racine de l'application.
+ * Responsabilités :
+ * 1. Fournit les providers globaux (GestureHandler, SafeArea, Paper)
+ * 2. Charge la configuration au démarrage depuis le secure store
+ * 3. Redirige vers /setup si aucune configuration n'existe
+ * 4. Enregistre le callback de toast pour l'errorHandler global
+ * 5. Affiche le toast d'erreur global en overlay
+ */
+export default function RootLayout() {
+ const router = useRouter();
+ const segments = useSegments();
+ const { config, loadConfig } = useAppStore();
+ const [isReady, setIsReady] = useState(false);
+ const [toastMessage, setToastMessage] = useState('');
+ const [toastVisible, setToastVisible] = useState(false);
+
+ /**
+ * Enregistre le callback de toast dans l'errorHandler au premier montage.
+ * Permet à toute la couche API de remonter des erreurs vers l'UI sans couplage direct.
+ */
+ useEffect(() => {
+ registerToastCallback((message) => {
+ setToastMessage(message);
+ setToastVisible(true);
+ });
+ }, []);
+
+ /** Charge la configuration depuis le secure store puis marque l'app comme prête */
+ useEffect(() => {
+ loadConfig().finally(() => setIsReady(true));
+ }, []);
+
+ /**
+ * Gère la redirection initiale selon l'état de configuration.
+ * - Pas configuré et hors /setup → redirige vers /setup
+ * - Configuré et dans /setup → redirige vers les tabs
+ */
+ useEffect(() => {
+ if (!isReady) return;
+ const inSetup = segments[0] === 'setup';
+ if (!config.isConfigured && !inSetup) {
+ router.replace('/setup');
+ } else if (config.isConfigured && inSetup) {
+ router.replace('/(tabs)');
+ }
+ }, [isReady, config.isConfigured, segments]);
+
+ if (!isReady) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* Toast global pour toutes les erreurs remontées par l'errorHandler */}
+ setToastVisible(false)}
+ duration={4000}
+ style={{ backgroundColor: '#4A0000' }}
+ >
+ {toastMessage}
+
+
+
+
+ );
+}
diff --git a/app/execution/[id].tsx b/app/execution/[id].tsx
new file mode 100644
index 0000000..ed6f1e4
--- /dev/null
+++ b/app/execution/[id].tsx
@@ -0,0 +1,3 @@
+import LogsScreen from '../../src/screens/LogsScreen';
+
+export default LogsScreen;
diff --git a/app/setup.tsx b/app/setup.tsx
new file mode 100644
index 0000000..32b6f90
--- /dev/null
+++ b/app/setup.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { View, StyleSheet } from 'react-native';
+import { Text, useTheme } from 'react-native-paper';
+import SettingsScreen from '../src/screens/SettingsScreen';
+
+/**
+ * Écran d'installation initiale — affiché au premier lancement.
+ * Partage le composant SettingsScreen avec un en-tête de bienvenue.
+ * Après sauvegarde, le root layout redirige automatiquement vers les tabs.
+ */
+export default function SetupScreen() {
+ const theme = useTheme();
+
+ return (
+
+
+
+ n8n Pilot
+
+
+ Configurez votre instance n8n pour commencer
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ paddingHorizontal: 24,
+ paddingTop: 24,
+ paddingBottom: 8,
+ alignItems: 'center',
+ gap: 4,
+ },
+ title: {
+ fontWeight: '700',
+ },
+});
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..d872de3
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,7 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ plugins: ['react-native-reanimated/plugin'],
+ };
+};
diff --git a/docs/BACKUP.md b/docs/BACKUP.md
new file mode 100644
index 0000000..9761213
--- /dev/null
+++ b/docs/BACKUP.md
@@ -0,0 +1,89 @@
+# Sauvegarde de n8n Pilot
+
+## Ce qu'il faut sauvegarder
+
+### 1. Credentials de signature EAS (`credentials.json`)
+
+**Où** : généré par EAS lors du premier build dans `~/.eas/` ou téléchargeable depuis le dashboard Expo.
+
+**Pourquoi** : sans le keystore de signature, il est **impossible** de mettre à jour l'APK sur un appareil
+qui a déjà la version signée avec ce keystore. Android refuse les mises à jour avec un keystore différent.
+
+**Comment sauvegarder** :
+
+```bash
+# Télécharger les credentials depuis EAS
+eas credentials
+
+# Choisir : Android > Keystore > Download
+# Sauvegarder le fichier .jks et les mots de passe associés
+```
+
+> **Criticalité : MAXIMALE** — perte du keystore = obligation de désinstaller l'app sur tous les appareils
+> avant de pouvoir installer une nouvelle version.
+
+---
+
+### 2. Fichier `.env` de développement
+
+**Où** : racine du projet (jamais dans git)
+
+**Pourquoi** : contient l'URL de l'instance, la clé API et le token HAProxy pour l'environnement de dev.
+Sans ce fichier, les développeurs doivent reconfigurer manuellement l'environnement.
+
+**Comment sauvegarder** : gestionnaire de mots de passe (Bitwarden, 1Password, Vault) avec les champs :
+- `N8N_BASE_URL`
+- `N8N_API_KEY`
+- `N8N_APP_TOKEN`
+
+---
+
+### 3. Configuration EAS (`eas.json`)
+
+**Où** : racine du projet, **committé dans git**
+
+**Pourquoi** : définit les profils de build (development, preview, production).
+
+**Statut** : déjà sauvegardé via git — aucune action supplémentaire requise.
+
+---
+
+### 4. Données utilisateur sur l'appareil
+
+Les credentials (URL, clé API, token) sont chiffrés dans l'**Android Keystore** de l'appareil.
+Ils ne sont **pas** sauvegardés dans les sauvegardes Android standard (par conception de sécurité).
+
+**Conséquence** : après réinitialisation usine ou changement d'appareil, l'utilisateur doit
+reconfigurer l'app depuis l'écran Paramètres.
+
+---
+
+## Fréquence recommandée
+
+| Élément | Fréquence | Stockage |
+|---------|-----------|----------|
+| Keystore EAS | À chaque rotation + après génération | Coffre chiffré hors-ligne |
+| `.env` dev | À chaque rotation des tokens | Gestionnaire de mots de passe |
+| Code source | En continu via git push | Gitea self-hosted |
+
+---
+
+## Procédure de restauration
+
+### Restaurer le keystore
+
+```bash
+# Uploader le keystore sauvegardé vers EAS
+eas credentials
+# Choisir : Android > Keystore > Upload existing keystore
+```
+
+### Restaurer l'environnement de développement
+
+```bash
+git clone https://homegit.gyozamancave.fr/billisdead/n8n-mobile.git
+cd n8n-mobile
+cp .env.example .env
+# Remplir .env depuis le gestionnaire de mots de passe
+npm install --legacy-peer-deps
+```
diff --git a/docs/HAPROXY.md b/docs/HAPROXY.md
new file mode 100644
index 0000000..d8bfab7
--- /dev/null
+++ b/docs/HAPROXY.md
@@ -0,0 +1,195 @@
+# Configuration HAProxy pour n8n Pilot
+
+## Architecture
+
+```
+Android App → HTTPS → HAProxy (443) → HTTP → n8n (5678)
+ ↓
+ Validation X-App-Token
+ Rate limiting
+ TLS termination
+```
+
+HAProxy agit comme reverse proxy devant n8n. Il :
+1. Termine TLS (certificat Let's Encrypt ou autosigné)
+2. Valide le header `X-App-Token` (token secret partagé)
+3. Applique du rate limiting pour protéger l'API
+4. Forward les requêtes vers n8n en HTTP interne
+
+---
+
+## Configuration HAProxy complète
+
+```haproxy
+#---------------------------------------------------------------------
+# global : paramètres du processus HAProxy
+#---------------------------------------------------------------------
+global
+ log /dev/log local0
+ log /dev/log local1 notice
+ maxconn 2000
+ daemon
+
+#---------------------------------------------------------------------
+# defaults : valeurs par défaut pour tous les frontends/backends
+#---------------------------------------------------------------------
+defaults
+ log global
+ mode http
+ option httplog
+ option dontlognull
+ timeout connect 5s
+ timeout client 30s
+ timeout server 30s
+ # Timeout plus long pour les webhooks n8n qui peuvent prendre du temps
+ timeout tunnel 3600s
+
+#---------------------------------------------------------------------
+# frontend : point d'entrée HTTPS sur le port 443
+#---------------------------------------------------------------------
+frontend n8n_pilot_frontend
+ bind *:443 ssl crt /etc/ssl/n8n/fullchain.pem alpn h2,http/1.1
+
+ # Sécurité TLS : désactiver les versions obsolètes
+ ssl-min-ver TLSv1.2
+
+ # Headers de sécurité injectés sur toutes les réponses
+ http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
+ http-response set-header X-Content-Type-Options "nosniff"
+ http-response set-header X-Frame-Options "DENY"
+
+ # ACL : vérification du X-App-Token
+ # La valeur doit correspondre exactement au token configuré dans l'app
+ acl valid_app_token req.hdr(X-App-Token) -m str "VOTRE_TOKEN_ICI"
+
+ # Refuser les requêtes sans token valide avec 403
+ http-request deny deny_status 403 if !valid_app_token
+
+ # Rate limiting : max 60 requêtes par minute par IP
+ # Protège contre les scripts de scan et les abus
+ stick-table type ip size 100k expire 60s store http_req_rate(60s)
+ http-request track-sc0 src
+ http-request deny deny_status 429 if { sc_http_req_rate(0) gt 60 }
+
+ # Redirection HTTP → HTTPS (optionnel si port 80 ouvert)
+ # redirect scheme https if !{ ssl_fc }
+
+ default_backend n8n_backend
+
+#---------------------------------------------------------------------
+# backend : instance n8n locale
+#---------------------------------------------------------------------
+backend n8n_backend
+ balance roundrobin
+
+ # Supprimer le X-App-Token avant de forwarder à n8n (ne doit pas atteindre n8n)
+ http-request del-header X-App-Token
+
+ # Health check : n8n répond sur /healthz
+ option httpchk GET /healthz
+ http-check expect status 200
+
+ server n8n_local 127.0.0.1:5678 check inter 10s fall 3 rise 2
+```
+
+---
+
+## Rotation du X-App-Token
+
+Le token doit être tourné régulièrement (recommandé : tous les 90 jours minimum).
+
+### Procédure de rotation sans downtime
+
+1. **Générer un nouveau token** :
+ ```bash
+ openssl rand -hex 32
+ # Exemple : a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
+ ```
+
+2. **Phase de transition — accepter les deux tokens** :
+ ```haproxy
+ # Accepter l'ancien ET le nouveau pendant la transition
+ acl valid_app_token req.hdr(X-App-Token) -m str "ANCIEN_TOKEN"
+ acl valid_app_token_new req.hdr(X-App-Token) -m str "NOUVEAU_TOKEN"
+ http-request deny deny_status 403 if !valid_app_token !valid_app_token_new
+ ```
+
+3. **Mettre à jour l'app** sur tous les appareils (Paramètres > Token HAProxy > Sauvegarder)
+
+4. **Retirer l'ancien token** de HAProxy une fois tous les appareils mis à jour :
+ ```haproxy
+ acl valid_app_token req.hdr(X-App-Token) -m str "NOUVEAU_TOKEN"
+ http-request deny deny_status 403 if !valid_app_token
+ ```
+
+5. **Recharger HAProxy** sans coupure :
+ ```bash
+ haproxy -f /etc/haproxy/haproxy.cfg -c # Valider la config
+ systemctl reload haproxy # Rechargement gracieux
+ ```
+
+---
+
+## Troubleshooting
+
+### Erreur 403 Forbidden
+
+**Cause** : `X-App-Token` absent ou incorrect.
+
+**Diagnostic** :
+```bash
+# Tester manuellement avec curl
+curl -v -H "X-App-Token: VOTRE_TOKEN" https://n8n.votre-domaine.com/api/v1/workflows
+```
+
+**Solutions** :
+- Vérifier que le token dans l'app (Paramètres) correspond exactement à la config HAProxy
+- Contrôler les espaces ou caractères invisibles dans le token
+- Vérifier les logs HAProxy : `journalctl -u haproxy -f`
+
+---
+
+### Erreur 429 Too Many Requests
+
+**Cause** : rate limit dépassé (> 60 req/min).
+
+**Solutions** :
+- Augmenter l'intervalle de polling dans l'app (Paramètres > Intervalle)
+- Ajuster la limite dans HAProxy si l'usage légitime est plus élevé
+- Vérifier qu'aucun processus tiers ne spam l'endpoint
+
+---
+
+### Timeout / connexion refusée
+
+**Cause** : n8n inaccessible sur le port 5678, ou HAProxy mal configuré.
+
+**Diagnostic** :
+```bash
+# Vérifier que n8n tourne
+curl http://127.0.0.1:5678/healthz
+
+# Vérifier le statut HAProxy
+systemctl status haproxy
+haproxy -f /etc/haproxy/haproxy.cfg -c
+
+# Logs HAProxy en temps réel
+journalctl -u haproxy -f
+```
+
+---
+
+### Certificat TLS expiré
+
+```bash
+# Renouveler avec Certbot
+certbot renew --quiet
+# Recharger HAProxy pour prendre en compte le nouveau certificat
+systemctl reload haproxy
+```
+
+Automatiser le renouvellement :
+```bash
+# Ajouter dans crontab (root)
+0 3 * * 1 certbot renew --quiet && systemctl reload haproxy
+```
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
new file mode 100644
index 0000000..93f9f32
--- /dev/null
+++ b/docs/INSTALL.md
@@ -0,0 +1,136 @@
+# Installation de n8n Pilot
+
+## Prérequis
+
+| Outil | Version minimale | Rôle |
+|-------|-----------------|------|
+| Node.js | 20 LTS | Runtime JS |
+| npm | 10+ | Gestionnaire de paquets |
+| EAS CLI | 3.0+ | Build cloud Expo |
+| Android | 8.0 (API 26) | Cible minimum |
+| Compte Expo | gratuit | Build EAS |
+
+```bash
+# Vérifier les prérequis
+node --version # >= 20
+npm --version # >= 10
+eas --version # >= 3.0 (après install)
+```
+
+---
+
+## 1. Cloner le dépôt
+
+```bash
+git clone https://homegit.gyozamancave.fr/billisdead/n8n-mobile.git
+cd n8n-mobile
+```
+
+---
+
+## 2. Installer les dépendances
+
+```bash
+npm install --legacy-peer-deps
+```
+
+---
+
+## 3. Configurer l'environnement de développement
+
+Copiez le fichier exemple et remplissez vos valeurs **localement uniquement** :
+
+```bash
+cp .env.example .env
+# Éditez .env avec votre URL, clé API et token HAProxy
+```
+
+> ⚠️ Le fichier `.env` est dans `.gitignore` — ne jamais le committer.
+> En production, les secrets transitent **uniquement** par `expo-secure-store`.
+
+---
+
+## 4. Lancer en développement
+
+```bash
+# Démarrer le serveur Metro
+npm start
+
+# Sur appareil Android (USB ou WiFi) ou émulateur
+npm run android
+```
+
+Pour le développement, l'app vous demandera de saisir l'URL et la clé API
+directement dans l'écran Paramètres — elles seront stockées dans le Keystore Android.
+
+---
+
+## 5. Installer EAS CLI
+
+```bash
+npm install -g eas-cli
+eas login # Connectez-vous avec votre compte Expo
+```
+
+---
+
+## 6. Configurer le projet EAS
+
+```bash
+# Initialise l'ID de projet Expo (première fois uniquement)
+eas init
+
+# Vérifie la configuration
+eas build:configure
+```
+
+---
+
+## 7. Builder l'APK (profil preview)
+
+```bash
+# Build Android APK — exécuté dans le cloud Expo
+eas build --platform android --profile preview
+```
+
+Le build prend environ 10–15 minutes. EAS envoie un email avec le lien de téléchargement.
+
+Pour un build local (nécessite Android Studio + JDK 17) :
+
+```bash
+eas build --platform android --profile preview --local
+```
+
+---
+
+## 8. Sideloader l'APK sur Android
+
+### Via ADB
+
+```bash
+# Connecter l'appareil en USB avec le débogage USB activé
+adb devices
+
+# Installer l'APK
+adb install chemin/vers/n8n-pilot.apk
+```
+
+### Manuellement
+
+1. Transférez l'APK sur l'appareil (câble, Google Drive, etc.)
+2. Ouvrez l'APK depuis le gestionnaire de fichiers
+3. Autorisez l'installation depuis des sources inconnues si demandé
+4. Suivez l'assistant d'installation
+
+---
+
+## 9. Premier lancement
+
+Au premier démarrage, l'écran de configuration apparaît automatiquement :
+
+1. Saisissez l'URL HTTPS de votre instance n8n
+2. Collez votre clé API n8n (Settings > API Keys dans n8n)
+3. Saisissez le token HAProxy si applicable
+4. Appuyez sur **Sauvegarder**
+
+Les credentials sont chiffrés dans l'Android Keystore — ils ne quittent jamais l'appareil.
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
new file mode 100644
index 0000000..cc10ff2
--- /dev/null
+++ b/docs/SECURITY.md
@@ -0,0 +1,147 @@
+# Sécurité — n8n Pilot
+
+## Modèle de menaces
+
+### Acteurs et vecteurs
+
+| Menace | Impact | Probabilité | Mitigation |
+|--------|--------|-------------|-----------|
+| Vol de l'appareil Android | Accès aux credentials n8n | Moyen | Android Keystore chiffré, biométrie optionnelle |
+| Interception réseau (MITM) | Exposition de la clé API | Faible (TLS) | TLS obligatoire, refus HTTP |
+| Fuite du token HAProxy | Accès non autorisé à n8n | Moyen | Rotation régulière, token hors bundle |
+| Compromission du build APK | Backdoor dans l'app | Faible | Keystore signé, builds EAS reproductibles |
+| Brute force API | Épuisement quotas n8n | Faible | Rate limiting HAProxy (429) |
+| Logs applicatifs exposés | Fuite de secrets | Faible | Jamais de log de secrets, même en dev |
+
+---
+
+## Principes appliqués
+
+### Least Privilege (moindre privilège)
+
+La clé API n8n doit être créée avec le **scope minimal** nécessaire :
+
+```
+Recommandé :
+✓ workflows:list — lister les workflows
+✓ workflows:read — lire les détails
+✓ workflows:update — activer/désactiver (toggle)
+✓ executions:list — voir l'historique
+✓ executions:read — lire les logs
+✓ executions:delete — supprimer des exécutions
+✓ workflows:run — déclencher manuellement
+
+Non recommandé :
+✗ credentials:* — inutile pour cette app
+✗ users:* — inutile pour cette app
+✗ admin:* — jamais
+```
+
+**Comment créer la clé** dans n8n :
+1. Settings > n8n API
+2. Create API Key
+3. Nommer la clé "n8n-pilot-mobile"
+4. Définir les scopes minimaux ci-dessus
+5. Copier la clé — elle n'est affichée qu'une seule fois
+
+---
+
+### Defense in Depth (défense en profondeur)
+
+L'accès à l'API n8n passe par **deux barrières indépendantes** :
+
+```
+Requête → [TLS] → [X-App-Token HAProxy] → [X-N8N-API-KEY n8n] → Données
+```
+
+Si un token est compromis :
+- Compromission du `X-App-Token` seul → HAProxy bloque, n8n jamais atteint
+- Compromission de la clé API seul → sans le `X-App-Token`, HAProxy bloque à 403
+- Les deux compromis → rotation immédiate des deux (procédures dans HAPROXY.md et BACKUP.md)
+
+---
+
+### Zero Trust (réseau local inclus)
+
+n8n ne doit **pas** être accessible directement sur le réseau, même en local.
+Toutes les requêtes passent par HAProxy qui valide le token.
+
+```
+# Bloquer l'accès direct au port n8n (iptables)
+iptables -A INPUT -p tcp --dport 5678 -s 127.0.0.1 -j ACCEPT
+iptables -A INPUT -p tcp --dport 5678 -j DROP
+```
+
+---
+
+## Gestion des secrets — cycle de vie
+
+### Stockage
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Appareil Android │
+│ ┌─────────────────────────────────────────────┐ │
+│ │ Android Keystore (expo-secure-store) │ │
+│ │ n8n_base_url → https://n8n.example.com │ │
+│ │ n8n_api_key → clé API (chiffrée) │ │
+│ │ n8n_app_token → token HAProxy (chiffré) │ │
+│ └─────────────────────────────────────────────┘ │
+│ │
+│ État React (Zustand) — en mémoire uniquement : │
+│ config.baseUrl → copie non sensible │
+│ config.isConfigured → booléen │
+│ JAMAIS : apiKey, appToken en mémoire JS │
+└─────────────────────────────────────────────────────┘
+```
+
+### Transit
+
+- Les secrets sont lus depuis le Keystore **à chaque requête** HTTP dans `client.ts`
+- Ils sont injectés dans les headers HTTP et **ne sont pas loggués**
+- Ils ne sont **jamais** :
+ - Sérialisés dans AsyncStorage
+ - Inclus dans des logs (même en dev)
+ - Embarqués dans le bundle JS (`app.json`, `package.json`, code source)
+ - Envoyés à des services tiers (analytics, crash reporters)
+
+### Rotation
+
+| Secret | Fréquence recommandée | Procédure |
+|--------|-----------------------|-----------|
+| Clé API n8n | Tous les 180 jours, ou en cas de compromission | Créer nouvelle clé dans n8n, mettre à jour dans l'app |
+| X-App-Token | Tous les 90 jours | Voir docs/HAPROXY.md |
+| Keystore EAS | Jamais (sauf compromission) | Voir docs/BACKUP.md |
+
+---
+
+## Checklist avant mise en production
+
+### Application
+
+- [ ] `app.json` : `userInterfaceStyle` = `"dark"` (pas d'info sensible en clair)
+- [ ] `.env` non commité dans git (`git status` ne montre pas `.env`)
+- [ ] Aucun `console.log` de valeurs sensibles dans le code
+- [ ] `__DEV__` gate sur tous les logs dans `errorHandler.ts`
+- [ ] Expo SDK à jour — vérifier les CVE : `npx expo-doctor`
+- [ ] Dépendances à jour : `npm audit`
+
+### Infrastructure
+
+- [ ] HAProxy : TLS 1.2 minimum, TLS 1.0/1.1 désactivés
+- [ ] Certificat TLS valide et non expiré
+- [ ] n8n inaccessible directement sur le port 5678 depuis l'extérieur
+- [ ] Rate limiting HAProxy activé (429 configuré)
+- [ ] Logs HAProxy ne contiennent pas les valeurs des headers secrets
+
+### n8n
+
+- [ ] Clé API avec scope minimal (voir § Least Privilege ci-dessus)
+- [ ] n8n mis à jour vers la dernière version stable
+- [ ] Sauvegardes n8n actives (workflows, credentials n8n)
+
+### Appareil
+
+- [ ] Verrouillage d'écran activé (PIN, pattern ou biométrie)
+- [ ] Biométrie configurée dans l'app si disponible
+- [ ] Appareil Android 8+ (API 26+) pour garantir la robustesse du Keystore
diff --git a/docs/UPDATE.md b/docs/UPDATE.md
new file mode 100644
index 0000000..5e07689
--- /dev/null
+++ b/docs/UPDATE.md
@@ -0,0 +1,78 @@
+# Mise à jour de n8n Pilot
+
+## Processus de mise à jour
+
+### 1. Récupérer les changements
+
+```bash
+git pull origin main
+npm install --legacy-peer-deps
+```
+
+### 2. Bumper la version
+
+Éditez `app.json` et incrémentez `version` :
+
+```json
+{
+ "expo": {
+ "version": "1.1.0",
+ "android": {
+ "versionCode": 2
+ }
+ }
+}
+```
+
+> **Règle** : `versionCode` doit être strictement supérieur à la version précédente.
+> `version` suit [semver](https://semver.org/) : MAJEUR.MINEUR.CORRECTIF
+
+### 3. Rebuilder l'APK
+
+```bash
+eas build --platform android --profile preview
+```
+
+### 4. Distribuer la mise à jour
+
+#### Option A — Sideload manuel
+
+Répétez la procédure de sideload (voir INSTALL.md § 8) avec le nouvel APK.
+L'Android Package Manager gère la mise à jour en place si le `versionCode` est supérieur.
+
+#### Option B — OTA (Over The Air) via Expo Updates
+
+Pour les mises à jour mineures (JS uniquement, pas de changements natifs) :
+
+```bash
+eas update --branch production --message "Fix: correction du polling"
+```
+
+L'app télécharge la mise à jour au prochain démarrage si elle est connectée.
+
+> **Important** : les changements dans `app.json`, l'ajout de plugins natifs
+> ou la modification des permissions nécessitent un rebuild APK complet.
+
+---
+
+## Vérifications avant release
+
+- [ ] Tests sur appareil physique (pas seulement émulateur)
+- [ ] Vérifier que la connexion à l8n fonctionne avec les nouvelles données
+- [ ] Valider le polling sur WiFi et données mobiles
+- [ ] Vérifier que les secrets restent dans le Keystore après mise à jour
+- [ ] Contrôler les logs Android (`adb logcat`) pour des erreurs inattendues
+
+---
+
+## Rollback
+
+En cas de régression critique :
+
+```bash
+# Revenir au commit précédent
+git revert HEAD
+
+# Rebuilder et redéployer la version précédente
+eas build --platform android --profile preview
+```
diff --git a/eas.json b/eas.json
new file mode 100644
index 0000000..256dff7
--- /dev/null
+++ b/eas.json
@@ -0,0 +1,29 @@
+{
+ "cli": {
+ "version": ">= 3.0.0"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ }
+ },
+ "preview": {
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk",
+ "gradleCommand": ":app:assembleRelease"
+ }
+ },
+ "production": {
+ "android": {
+ "buildType": "apk"
+ }
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/index.js b/index.js
deleted file mode 100644
index 1d6e981..0000000
--- a/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { registerRootComponent } from 'expo';
-
-import App from './App';
-
-// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
-// It also ensures that whether you load the app in Expo Go or in a native build,
-// the environment is set up appropriately
-registerRootComponent(App);
diff --git a/package-lock.json b/package-lock.json
index 50a7e2b..3384888 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,33 @@
{
- "name": "n8n-app",
+ "name": "n8n",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "n8n-app",
+ "name": "n8n",
"version": "1.0.0",
"dependencies": {
+ "axios": "^1.16.1",
+ "date-fns": "^4.2.1",
"expo": "~54.0.33",
+ "expo-local-authentication": "~17.0.8",
+ "expo-router": "~6.0.23",
+ "expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
- "react-native": "0.81.5"
+ "react-native": "0.81.5",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-paper": "^5.15.2",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "zustand": "^5.0.13"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.15",
+ "@types/react-native": "^0.72.8",
+ "typescript": "^6.0.3"
}
},
"node_modules/@0no-co/graphql.web": {
@@ -1515,6 +1531,40 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@callstack/react-theme-provider": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.9.tgz",
+ "integrity": "sha512-tTQ0uDSCL0ypeMa8T/E9wAZRGKWj8kXP7+6RYgPTfOPs9N07C9xM8P02GJ3feETap4Ux5S69D9nteq9mEj86NA==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^3.2.0",
+ "hoist-non-react-statics": "^3.3.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.3.0"
+ }
+ },
+ "node_modules/@callstack/react-theme-provider/node_modules/deepmerge": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
+ "integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@egjs/hammerjs": {
+ "version": "2.0.17",
+ "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
+ "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hammerjs": "^2.0.36"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -2700,6 +2750,463 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
+ "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -2960,6 +3467,155 @@
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
+ "node_modules/@react-native/virtualized-lists": {
+ "version": "0.72.8",
+ "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
+ "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "invariant": "^2.2.4",
+ "nullthrows": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
+ "node_modules/@react-navigation/bottom-tabs": {
+ "version": "7.16.1",
+ "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.1.tgz",
+ "integrity": "sha512-wjFATJmbq0K8B96Ax0JcK2+Eu7syfYvQ5qUd/tgcv8JuCYLwKKqojJMAl31qdjpKqFG09pQ6TSdEDHOek60CAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/elements": "^2.9.18",
+ "color": "^4.2.3",
+ "sf-symbols-typescript": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^7.2.4",
+ "react": ">= 18.2.0",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 4.0.0",
+ "react-native-screens": ">= 4.0.0"
+ }
+ },
+ "node_modules/@react-navigation/core": {
+ "version": "7.17.4",
+ "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz",
+ "integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/routers": "^7.5.5",
+ "escape-string-regexp": "^4.0.0",
+ "fast-deep-equal": "^3.1.3",
+ "nanoid": "^3.3.11",
+ "query-string": "^7.1.3",
+ "react-is": "^19.1.0",
+ "use-latest-callback": "^0.2.4",
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "react": ">= 18.2.0"
+ }
+ },
+ "node_modules/@react-navigation/core/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-navigation/core/node_modules/react-is": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
+ "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
+ "license": "MIT"
+ },
+ "node_modules/@react-navigation/elements": {
+ "version": "2.9.18",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.18.tgz",
+ "integrity": "sha512-mKEvDr6CkCVYZSb8W9WubNseihL+1c8M7ktZJCTCbMk8rQgdQfkdRNwpSUQKspdGpUHCb9cyzvaiuzl1NtjVgw==",
+ "license": "MIT",
+ "dependencies": {
+ "color": "^4.2.3",
+ "use-latest-callback": "^0.2.4",
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@react-native-masked-view/masked-view": ">= 0.2.0",
+ "@react-navigation/native": "^7.2.4",
+ "react": ">= 18.2.0",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@react-native-masked-view/masked-view": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-navigation/native": {
+ "version": "7.2.4",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz",
+ "integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/core": "^7.17.4",
+ "escape-string-regexp": "^4.0.0",
+ "fast-deep-equal": "^3.1.3",
+ "nanoid": "^3.3.11",
+ "use-latest-callback": "^0.2.4"
+ },
+ "peerDependencies": {
+ "react": ">= 18.2.0",
+ "react-native": "*"
+ }
+ },
+ "node_modules/@react-navigation/native-stack": {
+ "version": "7.15.1",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.15.1.tgz",
+ "integrity": "sha512-kNrJggwoB/onC0MpZIuZ6qaqeAziFchz+W9txBzhd6qbWmB1OkPVUnu6fWgc6BQc7MeMf59djVmqgX+6kJU1Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/elements": "^2.9.18",
+ "color": "^4.2.3",
+ "sf-symbols-typescript": "^2.1.0",
+ "warn-once": "^0.1.1"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^7.2.4",
+ "react": ">= 18.2.0",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 4.0.0",
+ "react-native-screens": ">= 4.0.0"
+ }
+ },
+ "node_modules/@react-navigation/native/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-navigation/routers": {
+ "version": "7.5.5",
+ "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz",
+ "integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@@ -3034,6 +3690,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hammerjs": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
+ "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
+ "license": "MIT"
+ },
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -3067,6 +3729,27 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
+ "node_modules/@types/react": {
+ "version": "19.2.15",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
+ "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-native": {
+ "version": "0.72.8",
+ "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
+ "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@react-native/virtualized-lists": "^0.72.4",
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -3260,6 +3943,18 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
@@ -3272,6 +3967,49 @@
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
+ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/axios/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/axios/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3737,6 +4475,19 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -3878,6 +4629,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -3901,6 +4658,19 @@
"node": ">=0.8"
}
},
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3916,6 +4686,46 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/color/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -4048,6 +4858,23 @@
"node": ">= 8"
}
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.2.1.tgz",
+ "integrity": "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4065,6 +4892,15 @@
}
}
},
+ "node_modules/decode-uri-component": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
+ "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -4104,6 +4940,15 @@
"node": ">=8"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4132,6 +4977,12 @@
"node": ">=8"
}
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -4159,6 +5010,20 @@
"url": "https://dotenvx.com"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4204,6 +5069,15 @@
"stackframe": "^1.3.4"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -4213,6 +5087,33 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4320,6 +5221,32 @@
}
}
},
+ "node_modules/expo-constants": {
+ "version": "18.0.13",
+ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
+ "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/config": "~12.0.13",
+ "@expo/env": "~2.0.8"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/expo-local-authentication": {
+ "version": "17.0.8",
+ "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.8.tgz",
+ "integrity": "sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==",
+ "license": "MIT",
+ "dependencies": {
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-modules-autolinking": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
@@ -4419,6 +5346,163 @@
"react-native": "*"
}
},
+ "node_modules/expo-router": {
+ "version": "6.0.23",
+ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
+ "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/metro-runtime": "^6.1.2",
+ "@expo/schema-utils": "^0.1.8",
+ "@radix-ui/react-slot": "1.2.0",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "@react-navigation/bottom-tabs": "^7.4.0",
+ "@react-navigation/native": "^7.1.8",
+ "@react-navigation/native-stack": "^7.3.16",
+ "client-only": "^0.0.1",
+ "debug": "^4.3.4",
+ "escape-string-regexp": "^4.0.0",
+ "expo-server": "^1.0.5",
+ "fast-deep-equal": "^3.1.3",
+ "invariant": "^2.2.4",
+ "nanoid": "^3.3.8",
+ "query-string": "^7.1.3",
+ "react-fast-compare": "^3.2.2",
+ "react-native-is-edge-to-edge": "^1.1.6",
+ "semver": "~7.6.3",
+ "server-only": "^0.0.1",
+ "sf-symbols-typescript": "^2.1.0",
+ "shallowequal": "^1.1.0",
+ "use-latest-callback": "^0.2.1",
+ "vaul": "^1.1.2"
+ },
+ "peerDependencies": {
+ "@expo/metro-runtime": "^6.1.2",
+ "@react-navigation/drawer": "^7.5.0",
+ "@testing-library/react-native": ">= 12.0.0",
+ "expo": "*",
+ "expo-constants": "^18.0.13",
+ "expo-linking": "^8.0.11",
+ "react": "*",
+ "react-dom": "*",
+ "react-native": "*",
+ "react-native-gesture-handler": "*",
+ "react-native-reanimated": "*",
+ "react-native-safe-area-context": ">= 5.4.0",
+ "react-native-screens": "*",
+ "react-native-web": "*",
+ "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@react-navigation/drawer": {
+ "optional": true
+ },
+ "@testing-library/react-native": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native-gesture-handler": {
+ "optional": true
+ },
+ "react-native-reanimated": {
+ "optional": true
+ },
+ "react-native-web": {
+ "optional": true
+ },
+ "react-server-dom-webpack": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/expo-router/node_modules/@expo/metro-runtime": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
+ "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==",
+ "license": "MIT",
+ "dependencies": {
+ "anser": "^1.4.9",
+ "pretty-format": "^29.7.0",
+ "stacktrace-parser": "^0.1.10",
+ "whatwg-fetch": "^3.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-dom": "*",
+ "react-native": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/expo-router/node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+ "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/expo-router/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/expo-router/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/expo-secure-store": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
+ "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-server": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
@@ -4748,20 +5832,6 @@
"react-native": "*"
}
},
- "node_modules/expo/node_modules/expo-constants": {
- "version": "18.0.13",
- "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
- "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
- "license": "MIT",
- "dependencies": {
- "@expo/config": "~12.0.13",
- "@expo/env": "~2.0.8"
- },
- "peerDependencies": {
- "expo": "*",
- "react-native": "*"
- }
- },
"node_modules/expo/node_modules/expo-file-system": {
"version": "19.0.22",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
@@ -4886,6 +5956,12 @@
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"license": "Apache-2.0"
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4913,6 +5989,15 @@
"node": ">=8"
}
},
+ "node_modules/filter-obj": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
+ "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -4965,12 +6050,48 @@
"integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==",
"license": "MIT"
},
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fontfaceobserver": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz",
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
"license": "BSD-2-Clause"
},
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/freeport-async": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
@@ -5036,6 +6157,39 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -5045,6 +6199,19 @@
"node": ">=8.0.0"
}
},
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/getenv": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
@@ -5071,6 +6238,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5086,6 +6265,33 @@
"node": ">=4"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -5113,6 +6319,21 @@
"hermes-estree": "0.32.0"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/hosted-git-info": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
@@ -5258,6 +6479,12 @@
"loose-envify": "^1.0.0"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
@@ -6198,6 +7425,15 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -7188,6 +8424,15 @@
"node": ">= 6"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7205,6 +8450,24 @@
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
+ "node_modules/query-string": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
+ "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
+ "license": "MIT",
+ "dependencies": {
+ "decode-uri-component": "^0.2.2",
+ "filter-obj": "^1.1.0",
+ "split-on-first": "^1.0.0",
+ "strict-uri-encode": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -7257,6 +8520,24 @@
"ws": "^7"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-freeze": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
+ "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0"
+ }
+ },
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -7320,6 +8601,21 @@
}
}
},
+ "node_modules/react-native-gesture-handler": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
+ "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@egjs/hammerjs": "^2.0.17",
+ "hoist-non-react-statics": "^3.3.0",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
@@ -7330,6 +8626,76 @@
"react-native": "*"
}
},
+ "node_modules/react-native-paper": {
+ "version": "5.15.2",
+ "resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.2.tgz",
+ "integrity": "sha512-e0HoKG+G85e5ckM28qElaqiDD+y0uHLVCUmOWe5094OuB9NDX4kKTY3twCVb2PPgA6lB0pQylu/O4Bvyg/gIYA==",
+ "license": "MIT",
+ "workspaces": [
+ "example",
+ "docs"
+ ],
+ "dependencies": {
+ "@callstack/react-theme-provider": "^3.0.9",
+ "color": "^3.1.2",
+ "use-latest-callback": "^0.2.3"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-safe-area-context": "*"
+ }
+ },
+ "node_modules/react-native-paper/node_modules/color": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^1.9.3",
+ "color-string": "^1.6.0"
+ }
+ },
+ "node_modules/react-native-reanimated": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
+ "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-is-edge-to-edge": "^1.2.1",
+ "semver": "^7.7.2"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "0.78 - 0.82",
+ "react-native-worklets": "0.5 - 0.8"
+ }
+ },
+ "node_modules/react-native-safe-area-context": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
+ "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-screens": {
+ "version": "4.16.0",
+ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
+ "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-freeze": "^1.0.0",
+ "react-native-is-edge-to-edge": "^1.2.1",
+ "warn-once": "^0.1.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -7429,6 +8795,75 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -7800,12 +9235,33 @@
"node": ">= 0.8"
}
},
+ "node_modules/server-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sf-symbols-typescript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz",
+ "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7856,6 +9312,15 @@
"plist": "^3.0.5"
}
},
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -7917,6 +9382,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split-on-first": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -7980,6 +9454,15 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/strict-uri-encode": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
+ "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -8343,6 +9826,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
@@ -8361,6 +9850,20 @@
"node": ">=8"
}
},
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
@@ -8455,6 +9958,67 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-latest-callback": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz",
+ "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -8492,6 +10056,19 @@
"node": ">= 0.8"
}
},
+ "node_modules/vaul": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
+ "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/vlq": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz",
@@ -8507,6 +10084,12 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/warn-once": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz",
+ "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
+ "license": "MIT"
+ },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -8768,6 +10351,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.13",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
+ "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index b03e151..eeb893a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "n8n",
+ "name": "n8n-pilot",
"version": "1.0.0",
- "main": "index.js",
+ "main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
@@ -9,10 +9,26 @@
"web": "expo start --web"
},
"dependencies": {
+ "axios": "^1.16.1",
+ "date-fns": "^4.2.1",
"expo": "~54.0.33",
+ "expo-local-authentication": "~17.0.8",
+ "expo-router": "~6.0.23",
+ "expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
- "react-native": "0.81.5"
+ "react-native": "0.81.5",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-paper": "^5.15.2",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "zustand": "^5.0.13"
},
- "private": true
+ "private": true,
+ "devDependencies": {
+ "@types/react": "^19.2.15",
+ "@types/react-native": "^0.72.8",
+ "typescript": "^6.0.3"
+ }
}
diff --git a/src/api/client.ts b/src/api/client.ts
new file mode 100644
index 0000000..f6798d4
--- /dev/null
+++ b/src/api/client.ts
@@ -0,0 +1,77 @@
+import axios, {
+ AxiosInstance,
+ InternalAxiosRequestConfig,
+ AxiosResponse,
+ AxiosError,
+} from 'axios';
+import * as SecureStore from 'expo-secure-store';
+import { handleError } from '../utils/errorHandler';
+
+/**
+ * Clés de stockage sécurisé Android Keystore via expo-secure-store.
+ * Ces constantes sont les seuls points de référence aux secrets dans toute l'app.
+ */
+export const SECURE_STORE_KEYS = {
+ BASE_URL: 'n8n_base_url',
+ API_KEY: 'n8n_api_key',
+ APP_TOKEN: 'n8n_app_token',
+} as const;
+
+/**
+ * Crée et configure l'instance Axios centrale de l'application.
+ *
+ * Architecture de sécurité :
+ * - Les credentials (API key, app token) ne sont JAMAIS embarqués dans le bundle.
+ * - Ils sont récupérés dynamiquement depuis le secure store à chaque requête.
+ * - La base URL est également stockée dans le secure store, pas en dur.
+ */
+const createApiClient = (): AxiosInstance => {
+ const client = axios.create({
+ timeout: 15_000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ /**
+ * Intercepteur de requête sortante.
+ * Lit baseURL, X-N8N-API-KEY et X-App-Token depuis le secure store
+ * et les injecte dans chaque requête avant envoi.
+ * Aucune valeur sensible ne transite par les variables d'environnement du bundle.
+ */
+ client.interceptors.request.use(
+ async (config: InternalAxiosRequestConfig): Promise => {
+ const [baseUrl, apiKey, appToken] = await Promise.all([
+ SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL),
+ SecureStore.getItemAsync(SECURE_STORE_KEYS.API_KEY),
+ SecureStore.getItemAsync(SECURE_STORE_KEYS.APP_TOKEN),
+ ]);
+
+ if (baseUrl) config.baseURL = baseUrl;
+ if (apiKey) config.headers.set('X-N8N-API-KEY', apiKey);
+ if (appToken) config.headers.set('X-App-Token', appToken);
+
+ return config;
+ },
+ (error: AxiosError) => Promise.reject(error)
+ );
+
+ /**
+ * Intercepteur de réponse.
+ * Les erreurs HTTP sont transmises à errorHandler qui affiche un toast.
+ * L'erreur est re-rejetée pour que les appelants puissent aussi réagir si besoin.
+ * Aucun secret n'est loggué ici.
+ */
+ client.interceptors.response.use(
+ (response: AxiosResponse) => response,
+ (error: AxiosError) => {
+ handleError(error);
+ return Promise.reject(error);
+ }
+ );
+
+ return client;
+};
+
+/** Instance Axios unique partagée dans toute l'application */
+export const apiClient = createApiClient();
diff --git a/src/api/executions.ts b/src/api/executions.ts
new file mode 100644
index 0000000..22433c2
--- /dev/null
+++ b/src/api/executions.ts
@@ -0,0 +1,107 @@
+import { apiClient } from './client';
+import { ApiListResponse } from './workflows';
+
+/** Ensemble des statuts possibles pour une exécution n8n */
+export type ExecutionStatus = 'success' | 'error' | 'waiting' | 'running' | 'canceled';
+
+/** Modes de déclenchement d'une exécution */
+export type ExecutionMode = 'manual' | 'trigger' | 'webhook' | 'internal' | 'retry';
+
+/**
+ * Données d'exécution d'un nœud individuel.
+ * Disponibles uniquement dans la réponse détaillée (GET /executions/:id).
+ */
+export interface NodeExecutionData {
+ startTime: number;
+ executionTime: number;
+ source: unknown[];
+ data?: Record;
+ error?: {
+ message: string;
+ name?: string;
+ stack?: string;
+ };
+}
+
+/** Données complètes d'un résultat d'exécution */
+export interface ExecutionResultData {
+ resultData: {
+ /** Map nœud → tableau des runs de ce nœud */
+ runData: Record;
+ lastNodeExecuted?: string;
+ /** Erreur globale de l'exécution (distinct des erreurs par nœud) */
+ error?: {
+ message: string;
+ name: string;
+ stack?: string;
+ };
+ };
+}
+
+/**
+ * Représente une exécution telle que retournée par l'API n8n v1.
+ * Le champ `data` n'est disponible que dans la réponse de GET /executions/:id.
+ */
+export interface Execution {
+ id: string;
+ finished: boolean;
+ mode: ExecutionMode;
+ /** ID de l'exécution originale si c'est un retry */
+ retryOf?: string;
+ retrySuccessId?: string;
+ startedAt: string;
+ stoppedAt?: string;
+ workflowId: string;
+ workflowName?: string;
+ status: ExecutionStatus;
+ /** Présent uniquement via GET /executions/:id */
+ data?: ExecutionResultData;
+}
+
+/** Paramètres de filtrage acceptés par l'endpoint GET /executions */
+export interface FetchExecutionsParams {
+ /** Nombre max de résultats à retourner */
+ limit?: number;
+ /** Filtre par statut d'exécution */
+ status?: ExecutionStatus;
+ /** Filtre par identifiant de workflow */
+ workflowId?: string;
+ /** Curseur opaque de pagination (valeur nextCursor de la réponse précédente) */
+ cursor?: string;
+}
+
+/**
+ * Récupère l'historique des exécutions avec filtres optionnels.
+ *
+ * @param params - Filtres : limit, status, workflowId, cursor
+ * @returns Réponse paginée avec les exécutions et le curseur suivant
+ */
+export const fetchExecutions = async (
+ params: FetchExecutionsParams = {}
+): Promise> => {
+ const response = await apiClient.get>('/api/v1/executions', {
+ params,
+ });
+ return response.data;
+};
+
+/**
+ * Récupère les logs complets d'une exécution spécifique.
+ * Inclut les données de chaque nœud (champ `data`), absent dans la liste.
+ *
+ * @param id - Identifiant de l'exécution
+ * @returns Exécution complète avec données de nœuds
+ */
+export const fetchExecutionById = async (id: string): Promise => {
+ const response = await apiClient.get(`/api/v1/executions/${id}`);
+ return response.data;
+};
+
+/**
+ * Supprime une exécution de l'historique n8n.
+ *
+ * @param id - Identifiant de l'exécution à supprimer
+ */
+export const deleteExecution = async (id: string): Promise => {
+ await apiClient.delete(`/api/v1/executions/${id}`);
+};
diff --git a/src/api/workflows.ts b/src/api/workflows.ts
new file mode 100644
index 0000000..ce394a6
--- /dev/null
+++ b/src/api/workflows.ts
@@ -0,0 +1,112 @@
+import { apiClient } from './client';
+
+/** Représente un nœud dans un workflow n8n */
+export interface WorkflowNode {
+ id: string;
+ name: string;
+ type: string;
+ position: [number, number];
+ parameters: Record;
+ typeVersion?: number;
+}
+
+/** Tag associé à un workflow */
+export interface WorkflowTag {
+ id: string;
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/**
+ * Représente un workflow complet tel que retourné par l'API n8n v1.
+ * Les champs `nodes` et `connections` sont présents dans les réponses détaillées.
+ */
+export interface Workflow {
+ id: string;
+ name: string;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+ nodes: WorkflowNode[];
+ connections: Record;
+ settings?: Record;
+ tags?: WorkflowTag[];
+}
+
+/**
+ * Réponse paginée standard de l'API n8n.
+ * nextCursor est null si on est sur la dernière page.
+ */
+export interface ApiListResponse {
+ data: T[];
+ nextCursor: string | null;
+}
+
+/**
+ * Récupère la liste complète des workflows de l'instance n8n.
+ *
+ * @returns Tableau de tous les workflows (actifs et inactifs)
+ * @throws AxiosError — géré par l'intercepteur, remonte via errorHandler
+ */
+export const fetchWorkflows = async (): Promise => {
+ const response = await apiClient.get>('/api/v1/workflows');
+ return response.data.data;
+};
+
+/**
+ * Récupère le détail complet d'un workflow, incluant ses nœuds et connexions.
+ *
+ * @param id - Identifiant du workflow
+ * @returns Workflow avec tous ses détails
+ */
+export const fetchWorkflowById = async (id: string): Promise => {
+ const response = await apiClient.get(`/api/v1/workflows/${id}`);
+ return response.data;
+};
+
+/**
+ * Active un workflow : il répondra désormais à ses déclencheurs.
+ *
+ * @param id - Identifiant du workflow à activer
+ * @returns Workflow mis à jour avec active = true
+ */
+export const activateWorkflow = async (id: string): Promise => {
+ const response = await apiClient.post(`/api/v1/workflows/${id}/activate`);
+ return response.data;
+};
+
+/**
+ * Désactive un workflow : ses déclencheurs sont mis en veille.
+ *
+ * @param id - Identifiant du workflow à désactiver
+ * @returns Workflow mis à jour avec active = false
+ */
+export const deactivateWorkflow = async (id: string): Promise => {
+ const response = await apiClient.post(`/api/v1/workflows/${id}/deactivate`);
+ return response.data;
+};
+
+/** Paramètres optionnels pour le déclenchement manuel d'un workflow */
+export interface RunWorkflowParams {
+ /** Données injectées dans le nœud de démarrage manuel */
+ workflowData?: Record;
+}
+
+/**
+ * Déclenche manuellement l'exécution d'un workflow.
+ *
+ * @param id - Identifiant du workflow à déclencher
+ * @param params - Données optionnelles d'entrée
+ * @returns Identifiant de l'exécution créée
+ */
+export const runWorkflow = async (
+ id: string,
+ params?: RunWorkflowParams
+): Promise<{ executionId: string }> => {
+ const response = await apiClient.post<{ executionId: string }>(
+ `/api/v1/workflows/${id}/run`,
+ params ?? {}
+ );
+ return response.data;
+};
diff --git a/src/components/ExecutionCard.tsx b/src/components/ExecutionCard.tsx
new file mode 100644
index 0000000..a04156b
--- /dev/null
+++ b/src/components/ExecutionCard.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { View, StyleSheet } from 'react-native';
+import { Card, Text, IconButton, useTheme } from 'react-native-paper';
+import { Execution } from '../api/executions';
+import StatusBadge from './StatusBadge';
+import { formatShortDate, formatDuration, formatMode } from '../utils/formatters';
+
+interface ExecutionCardProps {
+ execution: Execution;
+ /**
+ * Navigue vers les logs complets de cette exécution.
+ * Déclenche router.push('/execution/:id') dans ExecutionsScreen.
+ */
+ onViewLogs: (id: string) => void;
+ /**
+ * Supprime l'exécution via le hook useExecutions.
+ */
+ onDelete: (id: string) => void;
+}
+
+/**
+ * Carte représentant une exécution dans la liste historique.
+ * Affiche : nom du workflow, badge de statut, mode de déclenchement, durée et date.
+ * Les actions (logs, suppression) sont déléguées aux callbacks.
+ */
+const ExecutionCard: React.FC = ({ execution, onViewLogs, onDelete }) => {
+ const theme = useTheme();
+
+ return (
+
+
+ {/* En-tête : nom du workflow et badge de statut */}
+
+
+ {execution.workflowName ?? `Workflow ${execution.workflowId}`}
+
+
+
+
+ {/* Métadonnées : mode de déclenchement, durée, date */}
+
+
+ {formatMode(execution.mode)}
+
+
+ {formatDuration(execution.startedAt, execution.stoppedAt)}
+
+
+ {formatShortDate(execution.startedAt)}
+
+
+
+
+ {/* Actions : consulter les logs et supprimer */}
+
+ onViewLogs(execution.id)}
+ accessibilityLabel="Voir les logs de cette exécution"
+ />
+ onDelete(execution.id)}
+ accessibilityLabel="Supprimer cette exécution"
+ />
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ card: {
+ marginHorizontal: 16,
+ marginVertical: 5,
+ elevation: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 6,
+ },
+ workflowName: {
+ flex: 1,
+ marginRight: 8,
+ fontWeight: '500',
+ },
+ meta: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ actions: {
+ justifyContent: 'flex-end',
+ paddingRight: 4,
+ paddingBottom: 2,
+ },
+});
+
+export default ExecutionCard;
diff --git a/src/components/SkeletonLoader.tsx b/src/components/SkeletonLoader.tsx
new file mode 100644
index 0000000..48ad413
--- /dev/null
+++ b/src/components/SkeletonLoader.tsx
@@ -0,0 +1,91 @@
+import React, { useEffect, useRef } from 'react';
+import { View, Animated, StyleSheet, ViewStyle } from 'react-native';
+
+interface SkeletonItemProps {
+ width?: number | `${number}%`;
+ height?: number;
+ borderRadius?: number;
+ style?: ViewStyle;
+}
+
+/**
+ * Bloc de squelette animé avec effet de pulsation (shimmer).
+ * Composant interne utilisé par SkeletonLoader pour construire les cartes placeholder.
+ */
+const SkeletonItem: React.FC = ({
+ width = '100%',
+ height = 16,
+ borderRadius = 4,
+ style,
+}) => {
+ const opacity = useRef(new Animated.Value(0.3)).current;
+
+ useEffect(() => {
+ /** Boucle d'animation : alterne entre 30% et 70% d'opacité */
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(opacity, {
+ toValue: 0.7,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ Animated.timing(opacity, {
+ toValue: 0.3,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+ animation.start();
+ return () => animation.stop();
+ }, [opacity]);
+
+ return (
+
+ );
+};
+
+interface SkeletonLoaderProps {
+ /** Nombre de cartes placeholder à afficher pendant le chargement initial */
+ count?: number;
+}
+
+/**
+ * Affiche des cartes placeholder animées pendant le chargement des données.
+ * Simule la structure d'une WorkflowCard/ExecutionCard pour éviter le layout shift
+ * et indiquer à l'utilisateur que du contenu arrive.
+ */
+const SkeletonLoader: React.FC = ({ count = 5 }) => {
+ return (
+ <>
+ {Array.from({ length: count }).map((_, index) => (
+
+
+
+
+
+ ))}
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: '#1E1E1E',
+ marginHorizontal: 16,
+ marginVertical: 6,
+ padding: 16,
+ borderRadius: 12,
+ },
+ skeleton: {
+ backgroundColor: '#2C2C2C',
+ },
+});
+
+export default SkeletonLoader;
diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx
new file mode 100644
index 0000000..f9f446c
--- /dev/null
+++ b/src/components/StatusBadge.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { View, StyleSheet } from 'react-native';
+import { Text } from 'react-native-paper';
+import { ExecutionStatus } from '../api/executions';
+import { formatStatus } from '../utils/formatters';
+
+/**
+ * Couleurs de fond des badges — optimisées pour le thème sombre MD3.
+ * Les teintes foncées gardent le contraste sans agresser l'œil.
+ */
+const STATUS_BG_COLORS: Record = {
+ success: '#1B5E20',
+ error: '#7F0000',
+ waiting: '#E65100',
+ running: '#0D47A1',
+ canceled: '#424242',
+};
+
+/** Couleurs de texte assorties à chaque fond */
+const STATUS_TEXT_COLORS: Record = {
+ success: '#A5D6A7',
+ error: '#EF9A9A',
+ waiting: '#FFCC80',
+ running: '#90CAF9',
+ canceled: '#BDBDBD',
+};
+
+interface StatusBadgeProps {
+ status: ExecutionStatus;
+ /**
+ * Taille du badge :
+ * - small : listes denses (ExecutionCard)
+ * - default : vue détail (LogsScreen)
+ */
+ size?: 'small' | 'default';
+}
+
+/**
+ * Badge coloré représentant le statut d'une exécution n8n.
+ * Utilise les couleurs MD3 dark pour rester lisible sur les surfaces sombres.
+ */
+const StatusBadge: React.FC = ({ status, size = 'default' }) => {
+ const backgroundColor = STATUS_BG_COLORS[status] ?? '#424242';
+ const textColor = STATUS_TEXT_COLORS[status] ?? '#BDBDBD';
+ const fontSize = size === 'small' ? 10 : 12;
+
+ return (
+
+
+ {formatStatus(status)}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ badge: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 12,
+ alignSelf: 'flex-start',
+ },
+ text: {
+ fontWeight: '600',
+ letterSpacing: 0.3,
+ },
+});
+
+export default StatusBadge;
diff --git a/src/components/WorkflowCard.tsx b/src/components/WorkflowCard.tsx
new file mode 100644
index 0000000..b0998e3
--- /dev/null
+++ b/src/components/WorkflowCard.tsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { View, StyleSheet } from 'react-native';
+import { Card, Text, Switch, IconButton, useTheme } from 'react-native-paper';
+import { Workflow } from '../api/workflows';
+import { formatRelativeDate } from '../utils/formatters';
+
+interface WorkflowCardProps {
+ workflow: Workflow;
+ /**
+ * Appelé quand l'utilisateur bascule le switch actif/inactif.
+ * Le hook useWorkflows gère l'appel API et la mise à jour d'état.
+ */
+ onToggle: (workflow: Workflow) => void;
+ /**
+ * Appelé quand l'utilisateur appuie sur le bouton lecture.
+ * Déclenche une exécution manuelle du workflow.
+ */
+ onRun: (id: string) => void;
+}
+
+/**
+ * Carte représentant un workflow dans la liste WorkflowsScreen.
+ * Affiche : nom, statut actif (switch), date de modification, tags et bouton run.
+ * Le toggle et le run déclenchent des callbacks — la carte n'appelle jamais l'API directement.
+ */
+const WorkflowCard: React.FC = ({ workflow, onToggle, onRun }) => {
+ const theme = useTheme();
+
+ return (
+
+
+ {/* En-tête : nom du workflow et switch actif/inactif */}
+
+
+ {workflow.name}
+
+ onToggle(workflow)}
+ color={theme.colors.primary}
+ />
+
+
+ {/* Date de dernière modification */}
+
+ Modifié {formatRelativeDate(workflow.updatedAt)}
+
+
+ {/* Tags optionnels */}
+ {workflow.tags && workflow.tags.length > 0 && (
+
+ {workflow.tags.map((tag) => (
+
+
+ {tag.name}
+
+
+ ))}
+
+ )}
+
+
+ {/* Bouton de déclenchement manuel */}
+
+ onRun(workflow.id)}
+ accessibilityLabel="Déclencher manuellement ce workflow"
+ />
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ card: {
+ marginHorizontal: 16,
+ marginVertical: 6,
+ elevation: 2,
+ },
+ content: {
+ paddingBottom: 4,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ name: {
+ flex: 1,
+ marginRight: 8,
+ fontWeight: '600',
+ },
+ tags: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginTop: 8,
+ gap: 6,
+ },
+ tag: {
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 8,
+ },
+ actions: {
+ justifyContent: 'flex-end',
+ paddingRight: 8,
+ paddingBottom: 4,
+ },
+});
+
+export default WorkflowCard;
diff --git a/src/hooks/useExecutions.ts b/src/hooks/useExecutions.ts
new file mode 100644
index 0000000..58f9320
--- /dev/null
+++ b/src/hooks/useExecutions.ts
@@ -0,0 +1,137 @@
+import { useState, useCallback } from 'react';
+import { useAppStore } from '../store/appStore';
+import {
+ fetchExecutions,
+ fetchExecutionById,
+ deleteExecution,
+ Execution,
+ ExecutionStatus,
+ FetchExecutionsParams,
+} from '../api/executions';
+import { usePolling } from './usePolling';
+
+/** Options de filtrage passées au hook à l'initialisation */
+interface UseExecutionsOptions {
+ /** Nombre max d'exécutions à charger */
+ limit?: number;
+ /** Filtre de statut initial */
+ statusFilter?: ExecutionStatus;
+ /** Restreindre aux exécutions d'un workflow spécifique */
+ workflowId?: string;
+}
+
+/** Interface des valeurs et actions exposées par le hook */
+interface UseExecutionsResult {
+ executions: Execution[];
+ isLoading: boolean;
+ isRefreshing: boolean;
+ /** Exécution chargée en détail (avec logs de nœuds) */
+ selectedExecution: Execution | null;
+ /** Recharge la liste (pull-to-refresh) */
+ refresh: () => Promise;
+ /**
+ * Charge les logs complets d'une exécution pour l'écran de détail.
+ * Peuple selectedExecution.
+ *
+ * @param id - Identifiant de l'exécution
+ */
+ loadExecutionDetail: (id: string) => Promise;
+ /**
+ * Supprime une exécution de l'historique et la retire de l'état local.
+ *
+ * @param id - Identifiant de l'exécution à supprimer
+ */
+ removeExecution: (id: string) => Promise;
+ /**
+ * Change le filtre de statut actif et relance le chargement.
+ *
+ * @param status - Statut cible ou undefined pour tout afficher
+ */
+ setStatusFilter: (status: ExecutionStatus | undefined) => void;
+}
+
+/**
+ * Hook de gestion des exécutions : listing avec filtres, polling, détail et suppression.
+ * Sépare clairement la liste (vue Executions) du détail (vue Logs).
+ */
+export const useExecutions = (options: UseExecutionsOptions = {}): UseExecutionsResult => {
+ const { preferences, config } = useAppStore();
+ const [executions, setExecutions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [selectedExecution, setSelectedExecution] = useState(null);
+ const [statusFilter, setStatusFilter] = useState(
+ options.statusFilter
+ );
+
+ /** Charge la liste avec les filtres courants */
+ const loadExecutions = useCallback(async (): Promise => {
+ const params: FetchExecutionsParams = {
+ limit: options.limit ?? 50,
+ ...(statusFilter !== undefined && { status: statusFilter }),
+ ...(options.workflowId && { workflowId: options.workflowId }),
+ };
+
+ try {
+ const result = await fetchExecutions(params);
+ setExecutions(result.data);
+ } catch {
+ // Géré par l'intercepteur Axios
+ } finally {
+ setIsLoading(false);
+ }
+ }, [options.limit, options.workflowId, statusFilter]);
+
+ /** Polling automatique — désactivé si l'app n'est pas encore configurée */
+ usePolling({
+ callback: loadExecutions,
+ interval: preferences.pollingInterval,
+ enabled: config.isConfigured,
+ immediate: true,
+ });
+
+ const refresh = useCallback(async (): Promise => {
+ setIsRefreshing(true);
+ try {
+ await loadExecutions();
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [loadExecutions]);
+
+ /**
+ * Charge les logs détaillés d'une exécution.
+ * Appelle GET /executions/:id qui inclut le champ `data` avec les nœuds.
+ */
+ const loadExecutionDetail = useCallback(async (id: string): Promise => {
+ try {
+ const detail = await fetchExecutionById(id);
+ setSelectedExecution(detail);
+ } catch {
+ // Géré par l'intercepteur Axios
+ }
+ }, []);
+
+ /**
+ * Supprime une exécution côté serveur et la retire optimistiquement de l'état local.
+ */
+ const removeExecution = useCallback(async (id: string): Promise => {
+ try {
+ await deleteExecution(id);
+ setExecutions((prev) => prev.filter((e) => e.id !== id));
+ } catch {
+ // Géré par l'intercepteur Axios
+ }
+ }, []);
+
+ return {
+ executions,
+ isLoading,
+ isRefreshing,
+ selectedExecution,
+ refresh,
+ loadExecutionDetail,
+ removeExecution,
+ setStatusFilter,
+ };
+};
diff --git a/src/hooks/usePolling.ts b/src/hooks/usePolling.ts
new file mode 100644
index 0000000..dcb9f85
--- /dev/null
+++ b/src/hooks/usePolling.ts
@@ -0,0 +1,66 @@
+import { useEffect, useRef, useCallback } from 'react';
+
+/** Options de configuration du hook de polling générique */
+interface UsePollingOptions {
+ /** Callback exécuté à chaque intervalle (peut être async) */
+ callback: () => void | Promise;
+ /** Intervalle entre chaque appel en millisecondes */
+ interval: number;
+ /** Si false, le polling est suspendu sans démonter le hook */
+ enabled?: boolean;
+ /** Si true, déclenche le callback immédiatement au montage sans attendre le premier intervalle */
+ immediate?: boolean;
+}
+
+/**
+ * Hook générique de polling configurable.
+ * Gère automatiquement le nettoyage de l'intervalle au démontage du composant.
+ * Le callback est mémorisé par ref pour éviter de redémarrer le polling à chaque render.
+ *
+ * @returns stopPolling - Fonction pour arrêter manuellement le polling
+ */
+export const usePolling = ({
+ callback,
+ interval,
+ enabled = true,
+ immediate = true,
+}: UsePollingOptions): { stopPolling: () => void } => {
+ /** Ref sur le callback pour éviter de re-créer l'intervalle si la fonction change */
+ const callbackRef = useRef(callback);
+ const intervalRef = useRef | null>(null);
+
+ /** Synchronise la ref à chaque render sans déclencher d'effet */
+ useEffect(() => {
+ callbackRef.current = callback;
+ }, [callback]);
+
+ /** Arrête proprement l'intervalle actif */
+ const stopPolling = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!enabled) {
+ stopPolling();
+ return;
+ }
+
+ // Appel immédiat au montage si demandé (évite l'attente du premier intervalle)
+ if (immediate) {
+ void callbackRef.current();
+ }
+
+ // Mise en place du polling régulier
+ intervalRef.current = setInterval(() => {
+ void callbackRef.current();
+ }, interval);
+
+ // Nettoyage automatique au démontage
+ return stopPolling;
+ }, [enabled, interval, immediate, stopPolling]);
+
+ return { stopPolling };
+};
diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts
new file mode 100644
index 0000000..abc86a8
--- /dev/null
+++ b/src/hooks/useWorkflows.ts
@@ -0,0 +1,114 @@
+import { useState, useCallback } from 'react';
+import { useAppStore } from '../store/appStore';
+import {
+ fetchWorkflows,
+ activateWorkflow,
+ deactivateWorkflow,
+ runWorkflow,
+ Workflow,
+} from '../api/workflows';
+import { usePolling } from './usePolling';
+
+/** Interface des valeurs et actions exposées par le hook */
+interface UseWorkflowsResult {
+ workflows: Workflow[];
+ isLoading: boolean;
+ isRefreshing: boolean;
+ /**
+ * Recharge manuellement la liste des workflows.
+ * Utilisé pour le pull-to-refresh.
+ */
+ refresh: () => Promise;
+ /**
+ * Bascule le statut actif/inactif d'un workflow.
+ * Met à jour l'état local après confirmation serveur.
+ *
+ * @param workflow - Workflow à basculer
+ */
+ toggleWorkflow: (workflow: Workflow) => Promise;
+ /**
+ * Déclenche manuellement l'exécution d'un workflow.
+ *
+ * @param id - Identifiant du workflow à déclencher
+ */
+ triggerWorkflow: (id: string) => Promise;
+}
+
+/**
+ * Hook de gestion des workflows : chargement, polling automatique et actions.
+ * Délègue la gestion des erreurs à l'intercepteur Axios (errorHandler).
+ * Les screens ne font pas d'appels API directs — tout passe par ce hook.
+ */
+export const useWorkflows = (): UseWorkflowsResult => {
+ const { preferences, config } = useAppStore();
+ const [workflows, setWorkflows] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ /** Charge la liste des workflows depuis la couche API */
+ const loadWorkflows = useCallback(async (): Promise => {
+ try {
+ const data = await fetchWorkflows();
+ setWorkflows(data);
+ } catch {
+ // Erreur déjà remontée via l'intercepteur Axios → toast affiché
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ /**
+ * Polling automatique déclenché uniquement si l'app est configurée.
+ * L'intervalle est lu depuis les préférences utilisateur (configurable dans Settings).
+ */
+ usePolling({
+ callback: loadWorkflows,
+ interval: preferences.pollingInterval,
+ enabled: config.isConfigured,
+ immediate: true,
+ });
+
+ /** Pull-to-refresh : indicateur distinct du chargement initial */
+ const refresh = useCallback(async (): Promise => {
+ setIsRefreshing(true);
+ try {
+ await loadWorkflows();
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [loadWorkflows]);
+
+ /**
+ * Bascule actif/inactif d'un workflow.
+ * Met à jour l'entrée correspondante dans l'état local après retour serveur.
+ */
+ const toggleWorkflow = useCallback(async (workflow: Workflow): Promise => {
+ try {
+ const updated = workflow.active
+ ? await deactivateWorkflow(workflow.id)
+ : await activateWorkflow(workflow.id);
+
+ // Mise à jour ciblée sans recharger toute la liste
+ setWorkflows((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
+ } catch {
+ // Géré par l'intercepteur Axios
+ }
+ }, []);
+
+ const triggerWorkflow = useCallback(async (id: string): Promise => {
+ try {
+ await runWorkflow(id);
+ } catch {
+ // Géré par l'intercepteur Axios
+ }
+ }, []);
+
+ return {
+ workflows,
+ isLoading,
+ isRefreshing,
+ refresh,
+ toggleWorkflow,
+ triggerWorkflow,
+ };
+};
diff --git a/src/screens/DashboardScreen.tsx b/src/screens/DashboardScreen.tsx
new file mode 100644
index 0000000..c9c92c6
--- /dev/null
+++ b/src/screens/DashboardScreen.tsx
@@ -0,0 +1,204 @@
+import React, { useMemo } from 'react';
+import { ScrollView, View, StyleSheet, RefreshControl } from 'react-native';
+import { Text, Card, ActivityIndicator, useTheme } from 'react-native-paper';
+import { useWorkflows } from '../hooks/useWorkflows';
+import { useExecutions } from '../hooks/useExecutions';
+import StatusBadge from '../components/StatusBadge';
+import { formatShortDate } from '../utils/formatters';
+
+/**
+ * Carte de métrique simple pour le tableau de bord.
+ * Affiche un chiffre clé avec un label et un sous-titre coloré.
+ */
+interface MetricCardProps {
+ label: string;
+ value: string | number;
+ subtitle: string;
+ valueColor: string;
+}
+
+const MetricCard: React.FC = ({ label, value, subtitle, valueColor }) => {
+ const theme = useTheme();
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+ {subtitle}
+
+
+
+ );
+};
+
+/**
+ * Écran principal — tableau de bord.
+ * Affiche les métriques clés (total workflows, taux de succès)
+ * et les 5 dernières exécutions avec leur statut.
+ * Supporte le pull-to-refresh via useWorkflows.
+ */
+const DashboardScreen: React.FC = () => {
+ const theme = useTheme();
+ const { workflows, isLoading: wLoading, isRefreshing, refresh } = useWorkflows();
+ const { executions, isLoading: eLoading } = useExecutions({ limit: 5 });
+
+ /** Métriques calculées localement depuis l'état des hooks */
+ const metrics = useMemo(() => {
+ const successCount = executions.filter((e) => e.status === 'success').length;
+ return {
+ total: workflows.length,
+ active: workflows.filter((w) => w.active).length,
+ successRate:
+ executions.length > 0
+ ? Math.round((successCount / executions.length) * 100)
+ : 0,
+ };
+ }, [workflows, executions]);
+
+ /**
+ * Couleur du taux de succès : vert > 80%, orange > 50%, rouge sinon.
+ * Donne un indicateur visuel immédiat de la santé des workflows.
+ */
+ const successRateColor =
+ metrics.successRate >= 80
+ ? '#A5D6A7'
+ : metrics.successRate >= 50
+ ? '#FFCC80'
+ : '#EF9A9A';
+
+ const isLoading = wLoading || eLoading;
+
+ return (
+
+ }
+ >
+
+ Tableau de bord
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {/* Métriques rapides */}
+
+
+
+
+
+ {/* Section dernières exécutions */}
+
+ Exécutions récentes
+
+
+ {executions.length === 0 ? (
+
+ Aucune exécution récente.
+
+ ) : (
+ executions.slice(0, 5).map((exec) => (
+
+
+
+ {exec.workflowName ?? `Workflow ${exec.workflowId}`}
+
+
+
+
+
+ {formatShortDate(exec.startedAt)}
+
+
+
+ ))
+ )}
+ >
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ title: {
+ margin: 16,
+ fontWeight: '700',
+ },
+ loader: {
+ marginTop: 48,
+ },
+ metricsRow: {
+ flexDirection: 'row',
+ marginHorizontal: 16,
+ gap: 12,
+ marginBottom: 8,
+ },
+ metricCard: {
+ flex: 1,
+ elevation: 2,
+ },
+ sectionTitle: {
+ marginHorizontal: 16,
+ marginTop: 20,
+ marginBottom: 8,
+ fontWeight: '600',
+ },
+ execCard: {
+ marginHorizontal: 16,
+ marginVertical: 4,
+ elevation: 1,
+ },
+ execHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingBottom: 4,
+ },
+ execMeta: {
+ paddingTop: 0,
+ },
+ empty: {
+ textAlign: 'center',
+ marginTop: 24,
+ fontStyle: 'italic',
+ },
+});
+
+export default DashboardScreen;
diff --git a/src/screens/ExecutionsScreen.tsx b/src/screens/ExecutionsScreen.tsx
new file mode 100644
index 0000000..20c30b6
--- /dev/null
+++ b/src/screens/ExecutionsScreen.tsx
@@ -0,0 +1,134 @@
+import React, { useState } from 'react';
+import { FlatList, StyleSheet, View } from 'react-native';
+import { Text, Chip, Snackbar, useTheme } from 'react-native-paper';
+import { useRouter } from 'expo-router';
+import { useExecutions } from '../hooks/useExecutions';
+import ExecutionCard from '../components/ExecutionCard';
+import SkeletonLoader from '../components/SkeletonLoader';
+import { ExecutionStatus } from '../api/executions';
+
+/** Filtres de statut disponibles dans l'interface */
+const STATUS_FILTERS: Array<{ label: string; value: ExecutionStatus | undefined }> = [
+ { label: 'Tous', value: undefined },
+ { label: 'Succès', value: 'success' },
+ { label: 'Erreur', value: 'error' },
+ { label: 'En cours', value: 'running' },
+];
+
+/**
+ * Écran d'historique des exécutions.
+ * Permet de filtrer par statut via des chips et de naviguer vers
+ * les logs détaillés d'une exécution via Expo Router.
+ */
+const ExecutionsScreen: React.FC = () => {
+ const theme = useTheme();
+ const router = useRouter();
+ const [activeFilter, setActiveFilter] = useState(undefined);
+ const [snackMessage, setSnackMessage] = useState('');
+
+ const {
+ executions,
+ isLoading,
+ isRefreshing,
+ refresh,
+ removeExecution,
+ setStatusFilter,
+ } = useExecutions({ limit: 50 });
+
+ /**
+ * Change le filtre actif et notifie le hook pour relancer le fetch.
+ *
+ * @param value - Nouveau statut filtré, ou undefined pour tout afficher
+ */
+ const handleFilterChange = (value: ExecutionStatus | undefined): void => {
+ setActiveFilter(value);
+ setStatusFilter(value);
+ };
+
+ /**
+ * Supprime une exécution et affiche une confirmation.
+ *
+ * @param id - Identifiant de l'exécution à supprimer
+ */
+ const handleDelete = async (id: string): Promise => {
+ await removeExecution(id);
+ setSnackMessage('Exécution supprimée');
+ };
+
+ return (
+
+ {/* Barre de filtres de statut horizontale */}
+
+ {STATUS_FILTERS.map((filter) => (
+ handleFilterChange(filter.value)}
+ style={styles.chip}
+ compact
+ >
+ {filter.label}
+
+ ))}
+
+
+ {isLoading ? (
+
+ ) : (
+ item.id}
+ renderItem={({ item }) => (
+ router.push(`/execution/${id}` as `/${string}`)}
+ onDelete={handleDelete}
+ />
+ )}
+ refreshing={isRefreshing}
+ onRefresh={refresh}
+ contentContainerStyle={styles.list}
+ ListEmptyComponent={
+
+ Aucune exécution trouvée.
+
+ }
+ />
+ )}
+
+ setSnackMessage('')}
+ duration={2500}
+ >
+ {snackMessage}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ filterBar: {
+ flexDirection: 'row',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ gap: 8,
+ },
+ chip: {
+ height: 32,
+ },
+ list: {
+ paddingVertical: 4,
+ paddingBottom: 24,
+ },
+ empty: {
+ textAlign: 'center',
+ marginTop: 48,
+ fontStyle: 'italic',
+ },
+});
+
+export default ExecutionsScreen;
diff --git a/src/screens/LogsScreen.tsx b/src/screens/LogsScreen.tsx
new file mode 100644
index 0000000..cde9538
--- /dev/null
+++ b/src/screens/LogsScreen.tsx
@@ -0,0 +1,207 @@
+import React, { useEffect, useState } from 'react';
+import { ScrollView, StyleSheet, View, ActivityIndicator } from 'react-native';
+import { Text, Card, Divider, useTheme } from 'react-native-paper';
+import { useLocalSearchParams } from 'expo-router';
+import { useExecutions } from '../hooks/useExecutions';
+import StatusBadge from '../components/StatusBadge';
+import { formatShortDate, formatDuration, formatMode } from '../utils/formatters';
+import { NodeExecutionData } from '../api/executions';
+
+/**
+ * Écran de logs détaillés d'une exécution n8n.
+ * Reçoit l'identifiant de l'exécution via les paramètres de route Expo Router (/execution/:id).
+ * Charge puis affiche les données de chaque nœud exécuté, avec les erreurs éventuelles.
+ */
+const LogsScreen: React.FC = () => {
+ const theme = useTheme();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { selectedExecution, loadExecutionDetail } = useExecutions();
+ const [isLoading, setIsLoading] = useState(true);
+
+ /** Charge les logs détaillés dès que l'ID de route est disponible */
+ useEffect(() => {
+ if (id) {
+ loadExecutionDetail(id).finally(() => setIsLoading(false));
+ }
+ }, [id]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!selectedExecution) {
+ return (
+
+
+ Exécution introuvable ou données indisponibles.
+
+
+ );
+ }
+
+ const runData = selectedExecution.data?.resultData?.runData ?? {};
+ const globalError = selectedExecution.data?.resultData?.error;
+ const nodeNames = Object.keys(runData);
+
+ return (
+
+ {/* En-tête : résumé de l'exécution */}
+
+
+
+
+ {selectedExecution.workflowName ?? `Workflow ${selectedExecution.workflowId}`}
+
+
+
+
+ {formatMode(selectedExecution.mode)} ·{' '}
+ {formatShortDate(selectedExecution.startedAt)} ·{' '}
+ {formatDuration(selectedExecution.startedAt, selectedExecution.stoppedAt)}
+
+
+
+
+ {/* Erreur globale de l'exécution (niveau workflow) */}
+ {globalError && (
+
+
+
+ Erreur workflow
+
+
+ {globalError.message}
+
+
+
+ )}
+
+ {/* Liste des nœuds exécutés */}
+
+ Nœuds exécutés ({nodeNames.length})
+
+
+ {nodeNames.map((nodeName, index) => {
+ const nodeRuns: NodeExecutionData[] = runData[nodeName] ?? [];
+ const firstRun = nodeRuns[0];
+ const hasError = !!firstRun?.error;
+
+ return (
+
+
+ {/* Nom du nœud et durée d'exécution */}
+
+
+ {index + 1}. {nodeName}
+
+ {firstRun && (
+
+ {firstRun.executionTime}ms
+
+ )}
+
+
+ {/* Erreur spécifique à ce nœud */}
+ {firstRun?.error && (
+ <>
+
+
+ {firstRun.error.message}
+
+ >
+ )}
+
+
+ );
+ })}
+
+ {nodeNames.length === 0 && (
+
+ Aucune donnée de nœud disponible pour cette exécution.
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ padding: 16,
+ paddingBottom: 40,
+ },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerCard: {
+ marginBottom: 12,
+ elevation: 2,
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ },
+ errorCard: {
+ marginBottom: 12,
+ elevation: 2,
+ },
+ sectionTitle: {
+ marginBottom: 8,
+ fontWeight: '600',
+ },
+ nodeCard: {
+ marginBottom: 8,
+ elevation: 1,
+ },
+ nodeHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ empty: {
+ textAlign: 'center',
+ marginTop: 24,
+ fontStyle: 'italic',
+ },
+});
+
+export default LogsScreen;
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
new file mode 100644
index 0000000..91e74f2
--- /dev/null
+++ b/src/screens/SettingsScreen.tsx
@@ -0,0 +1,329 @@
+import React, { useState, useEffect } from 'react';
+import { ScrollView, StyleSheet, View, Alert } from 'react-native';
+import {
+ Text,
+ TextInput,
+ Button,
+ Switch,
+ Divider,
+ useTheme,
+ Snackbar,
+} from 'react-native-paper';
+import * as SecureStore from 'expo-secure-store';
+import * as LocalAuthentication from 'expo-local-authentication';
+import { useAppStore } from '../store/appStore';
+import { SECURE_STORE_KEYS } from '../api/client';
+
+/**
+ * Intervalles de polling proposés à l'utilisateur.
+ * Le choix 30s est le défaut — bon compromis batterie/fraîcheur.
+ */
+const POLLING_INTERVALS: Array<{ label: string; value: number }> = [
+ { label: '15s', value: 15_000 },
+ { label: '30s', value: 30_000 },
+ { label: '1 min', value: 60_000 },
+ { label: '5 min', value: 300_000 },
+];
+
+/**
+ * Écran de configuration de l'application.
+ * Gère :
+ * - La connexion à l'instance n8n (URL + clé API + token HAProxy)
+ * - L'activation de l'authentification biométrique
+ * - L'intervalle de polling automatique
+ *
+ * SÉCURITÉ : aucune valeur sensible n'est stockée dans l'état React au-delà de la saisie.
+ * Après sauvegarde, apiKey et appToken sont vidés des champs et ne vivent que dans le secure store.
+ */
+const SettingsScreen: React.FC = () => {
+ const theme = useTheme();
+ const { config, preferences, setConfig, setPreferences } = useAppStore();
+
+ const [baseUrl, setBaseUrl] = useState('');
+ const [apiKey, setApiKey] = useState('');
+ const [appToken, setAppToken] = useState('');
+ const [urlError, setUrlError] = useState('');
+ const [isBiometricAvailable, setIsBiometricAvailable] = useState(false);
+ const [snackMessage, setSnackMessage] = useState('');
+ const [isSaving, setIsSaving] = useState(false);
+
+ /** Vérifie la disponibilité du hardware biométrique sur cet appareil */
+ useEffect(() => {
+ LocalAuthentication.hasHardwareAsync().then(setIsBiometricAvailable);
+ }, []);
+
+ /** Pré-remplit l'URL depuis le store (jamais la clé API ni le token) */
+ useEffect(() => {
+ setBaseUrl(config.baseUrl);
+ }, [config.baseUrl]);
+
+ /**
+ * Valide que l'URL est bien en HTTPS (TLS obligatoire).
+ * L'app refuse toute URL HTTP pour protéger les secrets en transit.
+ *
+ * @param url - URL saisie par l'utilisateur
+ * @returns true si l'URL est valide et en HTTPS
+ */
+ const validateUrl = (url: string): boolean => {
+ if (!url.startsWith('https://')) {
+ setUrlError("L'URL doit commencer par https:// (TLS obligatoire)");
+ return false;
+ }
+ try {
+ new URL(url);
+ setUrlError('');
+ return true;
+ } catch {
+ setUrlError('URL invalide — vérifiez le format');
+ return false;
+ }
+ };
+
+ /**
+ * Sauvegarde la configuration dans le secure store Android Keystore.
+ * Vide les champs sensibles de l'état React immédiatement après sauvegarde.
+ */
+ const handleSave = async (): Promise => {
+ if (!validateUrl(baseUrl)) return;
+ if (!apiKey.trim()) {
+ setSnackMessage('La clé API n8n est obligatoire');
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await setConfig(baseUrl.trim(), apiKey.trim(), appToken.trim());
+ // Nettoyage immédiat des champs sensibles de l'état React
+ setApiKey('');
+ setAppToken('');
+ setSnackMessage('Configuration sauvegardée avec succès');
+ } catch {
+ setSnackMessage('Erreur lors de la sauvegarde');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ /**
+ * Réinitialise complètement la configuration après confirmation explicite.
+ * Supprime toutes les entrées du secure store.
+ */
+ const handleReset = (): void => {
+ Alert.alert(
+ 'Réinitialiser la configuration',
+ 'Toutes les données de connexion seront supprimées. Cette action est irréversible.',
+ [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Réinitialiser',
+ style: 'destructive',
+ onPress: async () => {
+ await Promise.all([
+ SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BASE_URL),
+ SecureStore.deleteItemAsync(SECURE_STORE_KEYS.API_KEY),
+ SecureStore.deleteItemAsync(SECURE_STORE_KEYS.APP_TOKEN),
+ ]);
+ setBaseUrl('');
+ setApiKey('');
+ setAppToken('');
+ setSnackMessage('Configuration réinitialisée');
+ },
+ },
+ ]
+ );
+ };
+
+ return (
+
+ {/* Section : connexion à l'instance n8n */}
+
+ Connexion n8n
+
+
+ {
+ setBaseUrl(v);
+ if (urlError) validateUrl(v);
+ }}
+ onBlur={() => baseUrl && validateUrl(baseUrl)}
+ error={!!urlError}
+ mode="outlined"
+ style={styles.input}
+ autoCapitalize="none"
+ autoCorrect={false}
+ keyboardType="url"
+ left={}
+ />
+ {!!urlError && (
+
+ {urlError}
+
+ )}
+
+ }
+ placeholder={config.isConfigured ? '••••••••••••••••' : undefined}
+ />
+
+ }
+ placeholder={config.isConfigured ? '••••••••••••••••' : undefined}
+ />
+
+
+
+
+
+ {/* Section : préférences utilisateur */}
+
+ Préférences
+
+
+ {/* Biométrie — affichée uniquement si le hardware est disponible */}
+ {isBiometricAvailable && (
+
+
+ Déverrouillage biométrique
+
+ setPreferences({ biometricEnabled: v })}
+ color={theme.colors.primary}
+ />
+
+ )}
+
+ {/* Intervalle de rafraîchissement automatique */}
+
+ Intervalle de rafraîchissement
+
+
+ {POLLING_INTERVALS.map((item) => (
+
+ ))}
+
+
+
+
+ {/* Zone de danger — réinitialisation */}
+
+ Zone de danger
+
+
+
+ setSnackMessage('')}
+ duration={3000}
+ >
+ {snackMessage}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ padding: 16,
+ paddingBottom: 40,
+ },
+ sectionTitle: {
+ fontWeight: '600',
+ marginBottom: 12,
+ },
+ input: {
+ marginBottom: 12,
+ },
+ saveButton: {
+ marginTop: 4,
+ marginBottom: 8,
+ },
+ divider: {
+ marginVertical: 20,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ intervalRow: {
+ flexDirection: 'row',
+ gap: 8,
+ flexWrap: 'wrap',
+ },
+ intervalButton: {
+ flex: 1,
+ minWidth: 60,
+ },
+ resetButton: {
+ marginTop: 4,
+ },
+});
+
+export default SettingsScreen;
diff --git a/src/screens/WorkflowsScreen.tsx b/src/screens/WorkflowsScreen.tsx
new file mode 100644
index 0000000..d07360b
--- /dev/null
+++ b/src/screens/WorkflowsScreen.tsx
@@ -0,0 +1,95 @@
+import React, { useState } from 'react';
+import { FlatList, StyleSheet, View } from 'react-native';
+import { Text, Snackbar, useTheme } from 'react-native-paper';
+import { useWorkflows } from '../hooks/useWorkflows';
+import WorkflowCard from '../components/WorkflowCard';
+import SkeletonLoader from '../components/SkeletonLoader';
+import { Workflow } from '../api/workflows';
+
+/**
+ * Écran de liste de tous les workflows de l'instance n8n.
+ * Supporte le pull-to-refresh et affiche un squelette au premier chargement.
+ * Les actions (toggle, run) sont déléguées à useWorkflows — aucun appel API direct ici.
+ */
+const WorkflowsScreen: React.FC = () => {
+ const theme = useTheme();
+ const [snackMessage, setSnackMessage] = useState('');
+ const { workflows, isLoading, isRefreshing, refresh, toggleWorkflow, triggerWorkflow } =
+ useWorkflows();
+
+ /**
+ * Déclenche l'exécution d'un workflow et affiche une confirmation à l'utilisateur.
+ *
+ * @param id - Identifiant du workflow à lancer
+ */
+ const handleRun = async (id: string): Promise => {
+ await triggerWorkflow(id);
+ setSnackMessage('Workflow déclenché avec succès');
+ };
+
+ /**
+ * Bascule le statut actif/inactif avec retour visuel.
+ * L'état local est mis à jour par le hook après confirmation serveur.
+ *
+ * @param workflow - Workflow à basculer
+ */
+ const handleToggle = async (workflow: Workflow): Promise => {
+ await toggleWorkflow(workflow);
+ setSnackMessage(workflow.active ? 'Workflow désactivé' : 'Workflow activé');
+ };
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+ item.id}
+ renderItem={({ item }) => (
+
+ )}
+ refreshing={isRefreshing}
+ onRefresh={refresh}
+ contentContainerStyle={styles.list}
+ ListEmptyComponent={
+
+ Aucun workflow trouvé. Vérifiez la connexion à l'instance n8n.
+
+ }
+ />
+ )}
+
+ {/* Confirmation d'action non destructive */}
+ setSnackMessage('')}
+ duration={2500}
+ >
+ {snackMessage}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ list: {
+ paddingVertical: 8,
+ paddingBottom: 80,
+ },
+ empty: {
+ textAlign: 'center',
+ marginTop: 48,
+ marginHorizontal: 32,
+ fontStyle: 'italic',
+ },
+});
+
+export default WorkflowsScreen;
diff --git a/src/store/appStore.ts b/src/store/appStore.ts
new file mode 100644
index 0000000..b6454c5
--- /dev/null
+++ b/src/store/appStore.ts
@@ -0,0 +1,105 @@
+import { create } from 'zustand';
+import * as SecureStore from 'expo-secure-store';
+import { SECURE_STORE_KEYS } from '../api/client';
+
+/** Configuration de connexion à l'instance n8n auto-hébergée */
+interface AppConfig {
+ /** URL de base de l'instance (ex: https://n8n.example.com) */
+ baseUrl: string;
+ /** Vrai si une configuration complète a déjà été sauvegardée dans le secure store */
+ isConfigured: boolean;
+}
+
+/** Préférences utilisateur persistées localement */
+interface AppPreferences {
+ /** Intervalle de polling automatique en millisecondes */
+ pollingInterval: number;
+ /** Active l'authentification biométrique au démarrage de l'app */
+ biometricEnabled: boolean;
+}
+
+/** Interface complète de l'état global Zustand */
+interface AppState {
+ config: AppConfig;
+ preferences: AppPreferences;
+ /** Vrai si l'authentification biométrique a été validée pour la session courante */
+ isAuthenticated: boolean;
+ isLoading: boolean;
+
+ /**
+ * Sauvegarde baseUrl, apiKey et appToken dans le secure store Android Keystore.
+ * N'appeler qu'avec des valeurs non vides et validées (HTTPS obligatoire pour baseUrl).
+ *
+ * @param baseUrl - URL de l'instance n8n en HTTPS
+ * @param apiKey - Clé API n8n (scope minimal recommandé)
+ * @param appToken - Token personnalisé HAProxy (X-App-Token)
+ */
+ setConfig: (baseUrl: string, apiKey: string, appToken: string) => Promise;
+
+ /**
+ * Charge la configuration au démarrage depuis le secure store.
+ * Appelé une seule fois par le root layout (_layout.tsx).
+ */
+ loadConfig: () => Promise;
+
+ /**
+ * Met à jour les préférences utilisateur de manière partielle.
+ *
+ * @param prefs - Sous-ensemble des préférences à modifier
+ */
+ setPreferences: (prefs: Partial) => void;
+
+ /**
+ * Marque la session comme authentifiée (biométrie validée).
+ *
+ * @param auth - true si la biométrie a été acceptée
+ */
+ setAuthenticated: (auth: boolean) => void;
+}
+
+export const useAppStore = create((set) => ({
+ config: {
+ baseUrl: '',
+ isConfigured: false,
+ },
+ preferences: {
+ pollingInterval: 30_000, // 30 secondes — bon compromis entre fraîcheur et batterie
+ biometricEnabled: false,
+ },
+ isAuthenticated: false,
+ isLoading: false,
+
+ setConfig: async (baseUrl, apiKey, appToken) => {
+ // Écriture parallèle dans le secure store pour minimiser la latence
+ await Promise.all([
+ SecureStore.setItemAsync(SECURE_STORE_KEYS.BASE_URL, baseUrl),
+ SecureStore.setItemAsync(SECURE_STORE_KEYS.API_KEY, apiKey),
+ SecureStore.setItemAsync(SECURE_STORE_KEYS.APP_TOKEN, appToken),
+ ]);
+ // On ne stocke que l'URL dans l'état React ; jamais la clé API ni le token
+ set({ config: { baseUrl, isConfigured: true } });
+ },
+
+ loadConfig: async () => {
+ set({ isLoading: true });
+ try {
+ // Seule l'URL est lue dans le store visible — les secrets restent dans le secure store
+ const baseUrl = await SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL);
+ set({
+ config: {
+ baseUrl: baseUrl ?? '',
+ isConfigured: !!baseUrl,
+ },
+ });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ setPreferences: (prefs) =>
+ set((state) => ({
+ preferences: { ...state.preferences, ...prefs },
+ })),
+
+ setAuthenticated: (auth) => set({ isAuthenticated: auth }),
+}));
diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts
new file mode 100644
index 0000000..b6bfdb4
--- /dev/null
+++ b/src/utils/errorHandler.ts
@@ -0,0 +1,73 @@
+import { AxiosError } from 'axios';
+
+/** Signature du callback de toast enregistré depuis le root layout */
+type ToastCallback = (message: string, type: 'error' | 'warning' | 'info') => void;
+
+/** Référence au callback toast, initialisée au montage du root layout */
+let toastCallback: ToastCallback | null = null;
+
+/**
+ * Enregistre le callback de toast depuis le root layout (_layout.tsx).
+ * Doit être appelé une seule fois au démarrage de l'application.
+ *
+ * @param callback - Fonction qui affiche un toast dans l'UI
+ */
+export const registerToastCallback = (callback: ToastCallback): void => {
+ toastCallback = callback;
+};
+
+/**
+ * Affiche un toast via le callback enregistré.
+ * Ne fait rien si le callback n'a pas encore été enregistré.
+ */
+const showToast = (message: string, type: 'error' | 'warning' | 'info' = 'error'): void => {
+ if (toastCallback) {
+ toastCallback(message, type);
+ }
+};
+
+/**
+ * Extrait un message d'erreur lisible depuis une erreur Axios ou générique.
+ * Ne logue jamais les détails de la requête (qui pourraient contenir des headers secrets).
+ *
+ * @param error - Erreur Axios ou Error générique
+ * @returns Message d'erreur lisible en français
+ */
+const extractMessage = (error: unknown): string => {
+ if (error instanceof AxiosError) {
+ if (error.response) {
+ // Erreur HTTP avec réponse du serveur (4xx, 5xx)
+ const data = error.response.data as { message?: string; error?: string } | null;
+ return data?.message ?? data?.error ?? `Erreur ${error.response.status}`;
+ }
+ if (error.request) {
+ // Requête émise mais pas de réponse : timeout ou serveur inaccessible
+ return 'Serveur inaccessible. Vérifiez votre connexion ou l\'URL n8n.';
+ }
+ return error.message;
+ }
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return 'Une erreur inattendue est survenue.';
+};
+
+/**
+ * Point d'entrée unifié pour la gestion des erreurs applicatives.
+ * Affiche un toast et émet un warning console en développement UNIQUEMENT.
+ * Jamais de log en production, jamais de log des valeurs de headers ou tokens.
+ *
+ * @param error - Erreur à traiter
+ * @param context - Contexte optionnel (nom de la fonction appelante)
+ */
+export const handleError = (error: unknown, context?: string): void => {
+ const message = extractMessage(error);
+ const fullMessage = context ? `[${context}] ${message}` : message;
+
+ if (__DEV__) {
+ // En développement : log du message uniquement, jamais des headers/tokens
+ console.warn(`[ErrorHandler] ${fullMessage}`);
+ }
+
+ showToast(fullMessage, 'error');
+};
diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts
new file mode 100644
index 0000000..de13dbc
--- /dev/null
+++ b/src/utils/formatters.ts
@@ -0,0 +1,87 @@
+import { formatDistanceToNow, format, differenceInSeconds } from 'date-fns';
+import { fr } from 'date-fns/locale';
+
+/**
+ * Formate une date ISO en temps relatif lisible en français.
+ * Ex : "il y a 3 minutes", "dans 2 heures"
+ *
+ * @param dateString - Date au format ISO 8601
+ * @returns Chaîne relative en français, ou "date inconnue" si parsing impossible
+ */
+export const formatRelativeDate = (dateString: string): string => {
+ try {
+ return formatDistanceToNow(new Date(dateString), { addSuffix: true, locale: fr });
+ } catch {
+ return 'date inconnue';
+ }
+};
+
+/**
+ * Formate une date ISO en format court DD/MM/YYYY HH:mm.
+ *
+ * @param dateString - Date au format ISO 8601
+ * @returns Chaîne formatée, ou "date inconnue" si parsing impossible
+ */
+export const formatShortDate = (dateString: string): string => {
+ try {
+ return format(new Date(dateString), 'dd/MM/yyyy HH:mm', { locale: fr });
+ } catch {
+ return 'date inconnue';
+ }
+};
+
+/**
+ * Calcule et formate la durée entre deux dates.
+ * Si stoppedAt est absent, calcule jusqu'à maintenant (exécution en cours).
+ *
+ * @param startedAt - Date de début ISO 8601
+ * @param stoppedAt - Date de fin ISO 8601 (optionnelle)
+ * @returns Durée formatée ex : "2m 34s", "45s"
+ */
+export const formatDuration = (startedAt: string, stoppedAt?: string): string => {
+ try {
+ const start = new Date(startedAt);
+ const end = stoppedAt ? new Date(stoppedAt) : new Date();
+ const seconds = differenceInSeconds(end, start);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
+ } catch {
+ return 'durée inconnue';
+ }
+};
+
+/**
+ * Traduit le statut d'une exécution n8n en libellé français.
+ *
+ * @param status - Statut brut de l'API n8n
+ * @returns Libellé français correspondant
+ */
+export const formatStatus = (status: string): string => {
+ const statusMap: Record = {
+ success: 'Succès',
+ error: 'Erreur',
+ waiting: 'En attente',
+ running: 'En cours',
+ canceled: 'Annulé',
+ };
+ return statusMap[status] ?? status;
+};
+
+/**
+ * Traduit le mode de déclenchement d'une exécution n8n.
+ *
+ * @param mode - Mode brut retourné par l'API
+ * @returns Libellé français correspondant
+ */
+export const formatMode = (mode: string): string => {
+ const modeMap: Record = {
+ manual: 'Manuel',
+ trigger: 'Déclencheur',
+ webhook: 'Webhook',
+ internal: 'Interne',
+ retry: 'Réessai',
+ };
+ return modeMap[mode] ?? mode;
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..299cf8c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".expo/types/**/*.d.ts",
+ "expo-env.d.ts"
+ ]
+}