Si vous connaissez déjà React et le développement web. Bonne nouvelle : Ionic vous permet de créer des applications mobiles iOS et Android en réutilisant exactement ces compétences. Pas besoin d’apprendre Swift, Kotlin ou Flutter depuis zéro, quoique, personnellement, j’aime bien Flutter qui est écrit en DART et est très proche du Java.
React
Ionic est un framework open-source de création d’applications mobiles cross-platform. Il repose sur les technologies web (HTML, CSS, JavaScript/TypeScript) et permet de produire :
Le tout depuis une seule source de code.
Ionic, c’est un peu comme si vos compétences React/Vue devenaient des super-pouvoirs mobiles. Vous connaissez déjà 70% de ce qu’il faut savoir.
Ionic se compose de 2 parties distinctes qu’il faut bien comprendre dès le départ :
┌─────────────────────────────────────────────────────┐ │ VOTRE APPLICATION │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ Ionic UI Components │ │ │ │ IonButton, IonCard, IonList, IonTabs... │ │ │ │ (Composants avec le look mobile natif) │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ Framework JS — Vue / React / Angular │ │ │ │ (Votre logique applicative) │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ Capacitor │ │ │ │ (Pont vers les APIs natives : caméra, │ │ │ │ GPS, notifications, fichiers...) │ │ │ └─────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ↓ ↓ Application Android Application iOS
shadcn/ui
Material UI
react-native
A savoir : Ionic supporte 3 Frameworks : Angular, React et Vue. Ce cours utilise Vue.js pour deux raisons :
Si vous préférez rester sur React, la grande majorité de ce cours s’applique : seule la partie “composants” et “syntaxe de template” change. Ionic fonctionne exactement pareil avec React.
Pour des applications de gestion, dashboards mobiles, applications d’entreprise connectées à des API REST (Spring Boot, Symfony) : Ionic est le choix idéal. Les performances de React Native sont meilleures pour des jeux ou des apps très intensives graphiquement, mais pour 90% des applications métier, Ionic est largement suffisant et bien plus productif.
À la fin de ce cours, vous saurez (enfin, on espère) :
Le projet fil rouge sera une application de gestion de tâches avec authentification, connectée à un backend Spring Boot.
Avant d’installer Ionic, assurez-vous d’avoir ces outils sur votre machine Windows :
Prérequis obligatoires ├── Node.js 20 LTS ou supérieur │ └── https://nodejs.org (choisir "LTS") ├── npm (inclus avec Node.js) ├── Git │ └── https://git-scm.com/download/win └── VS Code (recommandé) └── https://code.visualstudio.com Prérequis pour Android (recommandé) ├── Android Studio │ └── https://developer.android.com/studio └── JDK 17 (si vous faites Spring Boot, vous l'avez déjà !)
Si vous avez déjà un environnement Spring Boot ou Symfony configuré, vous avez probablement déjà Node.js et Git. Vérifiez juste les versions.
Ouvrez un terminal PowerShell ou CMD et vérifiez :
# Vérifier Node.js — doit afficher v20.x.x ou supérieur node --version # Vérifier npm npm --version # Vérifier Git git --version
Si une commande n’est pas reconnue, installez l’outil correspondant avant de continuer.
L’Ionic CLI est l’outil en ligne de commande qui vous permet de créer, tester et déployer vos applications (un peu comme le CLI de Symfony) :
# installer l'Ionic CLI globalement npm install -g @ionic/cli # vérifier votre installation ionic --version # Doit afficher : 7.x.x
Si PowerShell refuse l’exécution de scripts (erreur ExecutionPolicy), exécutez cette commande en tant qu’administrateur :
ExecutionPolicy
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Installez ces extensions dans VS Code pour un environnement de développement optimal :
Extensions recommandées pour Ionic + Vue ├── Vue - Official (anciennement Volar) (Syntaxe Vue, autocomplétion) ├── Ionic (Preview dans le navigateur) ├── ESLint (Qualité du code) ├── Prettier (Formatage automatique) ├── Thunder Client (Tester vos API REST comme Postman) └── GitLens (Visualisation Git)
Pour installer une extension : Ctrl+Shift+X puis chercher le nom puis Installer.
Ctrl+Shift+X
Pour compiler sur Android, Android Studio est nécessaire. Après l’installation :
Étape 1 — Installer le SDK Android
File
Settings
Appearance & Behavior
System Settings
Android SDK
Android 14 (API 34)
Android 13 (API 33)
Apply
Étape 2 — Configurer les variables d’environnement
Dans le menu Démarrer, cherchez “Variables d’environnement” :
# Variable ANDROID_HOME (adapter le chemin) # Valeur : C:\Users\VOTRE_NOM\AppData\Local\Android\Sdk # Ajouter dans PATH : # C:\Users\VOTRE_NOM\AppData\Local\Android\Sdk\tools # C:\Users\VOTRE_NOM\AppData\Local\Android\Sdk\platform-tools
Vérification :
adb --version # Android Debug Bridge version 1.x.x
Ionic propose une commande pratique pour vérifier que tout est bien configuré :
ionic doctor check
Cette commande liste les problèmes détectés et propose des corrections. Idéalement, vous devez avoir zéro erreur avant de commencer un projet.
# Créer une nouvelle application Ionic avec Vue ionic start MonApplication tabs --type=vue # Explication des options : # "MonApplication" (nom du projet sans espaces) # "tabs" (template de départ, voir liste ci-dessous) # "--type=vue" (utiliser Vue.js comme framework)
Les templates disponibles :
tabs
sidemenu
blank
list
Pour ce cours, nous utilisons tabs : c’est le template le plus représentatif d’une vraie application mobile (comme Instagram, Twitter, etc.).
Ionic vous pose quelques questions lors de la création :
? Please select the JavaScript framework to use: Vue ? Would you like to integrate your new app with Capacitor to target native iOS and Android? Yes ? Which platforms would you like to add? Android, iOS (ou juste Android sur Windows)
mon-application/ ├── src/ │ ├── components/ Composants Vue réutilisables │ │ └── ExploreContainer.vue │ ├── views/ Pages de l'application (équivalent des "screens") │ │ ├── Tab1Page.vue │ │ ├── Tab2Page.vue │ │ └── Tab3Page.vue │ ├── router/ Configuration des routes (Vue Router) │ │ └── index.ts │ ├── theme/ Variables CSS globales (couleurs, tailles) │ │ └── variables.css │ ├── App.vue Composant racine │ └── main.ts Point d'entrée (équivalent index.js en React) ├── android/ Projet Android natif (généré par Capacitor) ├── ios/ Projet iOS natif (généré par Capacitor) ├── public/ Fichiers statiques ├── capacitor.config.ts Configuration Capacitor ├── ionic.config.json Configuration Ionic ├── package.json └── vite.config.ts Configuration Vite (bundler, remplace Webpack)
Si vous venez de React, views/ = vos “pages” ou “screens”, components/ = vos composants. La logique est identique.
views/
components/
# Se placer dans le dossier du projet cd mon-application # Lancer le serveur de développement ionic serve # L'application s'ouvre automatiquement dans votre navigateur # → http://localhost:8100
Ionic ouvre l’application dans le navigateur avec un mode “mobile” simulé. Vous voyez en temps réel les changements à chaque modification du code.
# Pour simuler un iPhone ou Android spécifique dans le navigateur ionic serve --lab # affiche iOS et Android côte à côte, très utile pour comparer les styles
Voici un fichier Tab1Page.vue généré par le template :
Tab1Page.vue
<template> <!-- IonPage : conteneur obligatoire pour chaque page --> <ion-page> <!-- En-tête de la page --> <ion-header> <ion-toolbar> <ion-title>Onglet 1</ion-title> </ion-toolbar> </ion-header> <!-- Contenu scrollable de la page --> <ion-content :fullscreen="true"> <!-- En-tête qui se réduit au scroll (effet iOS natif) --> <ion-header collapse="condense"> <ion-toolbar> <ion-title size="large">Onglet 1</ion-title> </ion-toolbar> </ion-header> <!-- Votre contenu ici --> <explore-container name="Onglet 1" /> </ion-content> </ion-page> </template> <script setup lang="ts"> // Vue 3 Composition API avec <script setup> — c'est le style moderne import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue'; import ExploreContainer from '@/components/ExploreContainer.vue'; </script>
Voici la structure minimale de toute page Ionic :
ion-page ├── ion-header Barre du haut (titre, boutons) │ └── ion-toolbar │ └── ion-title └── ion-content Zone de contenu (scrollable) └── votre contenu
Remplacez le contenu de Tab1Page.vue par ce code simple pour vérifier que tout fonctionne :
<template> <ion-page> <ion-header> <ion-toolbar color="primary"> <ion-title>Accueil</ion-title> </ion-toolbar> </ion-header> <ion-content class="ion-padding"> <!-- Carte de bienvenue --> <ion-card> <ion-card-header> <ion-card-title>Bienvenue dans Ionic !</ion-card-title> <ion-card-subtitle>Votre première application mobile</ion-card-subtitle> </ion-card-header> <ion-card-content> Cette application est construite avec Ionic + Vue.js. Modifiez ce fichier et voyez les changements en direct. </ion-card-content> </ion-card> <!-- Bouton interactif --> <ion-button expand="block" color="primary" @click="direBonjour"> Dire bonjour </ion-button> <!-- Message dynamique --> <p v-if="message" class="ion-text-center ion-padding"> {{ message }} </p> </ion-content> </ion-page> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCardContent, IonButton } from '@ionic/vue'; // État réactif — équivalent de useState en React const message = ref(''); function direBonjour() { message.value = `Bonjour ! Il est ${new Date().toLocaleTimeString()} `; } </script>
Sauvegardez et observez la mise à jour instantanée dans le navigateur. Le Hot Module Replacement (HMR) fonctionne exactement comme avec React.
Vous connaissez React. Vue.js partage les mêmes concepts fondamentaux (composants, état réactif, props, événements) mais avec une syntaxe différente. Ce chapitre vous guide dans la transition.
La philosophie :
.vue
Un fichier .vue contient 3 sections :
<!-- MonComposant.vue --> <!-- 1. TEMPLATE : ce qui est affiché (HTML enrichi) --> <template> <div> <h1>{{ titre }}</h1> <p>{{ description }}</p> </div> </template> <!-- 2. SCRIPT : logique du composant (JavaScript / TypeScript) --> <script setup lang="ts"> import { ref } from 'vue'; // Variables réactives const titre = ref('Mon composant'); const description = ref('Description ici'); </script> <!-- 3. STYLE : CSS scopé (ne s'applique qu'à CE composant) --> <style scoped> h1 { color: #3880ff; font-size: 1.5rem; } </style>
En React, tout est dans un seul fichier .tsx (HTML dans le JSX, style souvent externe ou CSS-in-JS). En Vue, les trois sections sont explicitement séparées dans le même fichier — beaucoup de développeurs trouvent ça plus lisible.
.tsx
const [val, setVal] = useState('')
const val = ref('')
setVal('nouveau')
val.value = 'nouveau'
useMemo(() => ...)
const x = computed(() => ...)
useEffect(() => {}, [dep])
watch(dep, () => {})
useEffect(() => {}, [])
onMounted(() => {})
function Comp({ nom }) {}
const props = defineProps(['nom'])
onClick={handler}
@click="handler"
{condition && <div/>}
<div v-if="condition"/>
items.map(i => <li key={i.id}/>)
<li v-for="i in items" :key="i.id"/>
<input value={val}/>
<input :value="val"/>
onChange={e => setVal(e.target.value)}
<input v-model="val"/>
<script setup lang="ts"> import { ref, computed, watch, onMounted } from 'vue'; // ref() — équivalent de useState // Pour accéder à la valeur : variable.value (dans le script) // Dans le template : {{ variable }} (sans .value) const compteur = ref(0); const prenom = ref(''); const estCharge = ref(false); // computed() — équivalent de useMemo const messageComplet = computed(() => { return `Bonjour ${prenom.value}, vous avez cliqué ${compteur.value} fois.`; }); // watch() — équivalent de useEffect avec dépendances watch(prenom, (nouvelleValeur, ancienneValeur) => { console.log(`Prénom changé : ${ancienneValeur} → ${nouvelleValeur}`); }); // onMounted() — équivalent de useEffect(() => {}, []) onMounted(async () => { console.log('Composant monté — ici vous appelez votre API !'); // const data = await fetch('/api/users'); }); function incrementer() { compteur.value++; // toujours modifier via .value en dehors du template } </script> <template> <div> <!-- Dans le template, .value n'est pas nécessaire --> <p>{{ messageComplet }}</p> <input v-model="prenom" placeholder="Votre prénom" /> <button @click="incrementer">Clics : {{ compteur }}</button> </div> </template>
<script setup lang="ts"> import { reactive } from 'vue'; // reactive() — pour les objets (pas besoin de .value !) // Équivalent de useState pour un objet en React const formulaire = reactive({ nom: '', email: '', age: 0, estActif: true, }); // On modifie directement les propriétés (pas de .value) function reinitialiser() { formulaire.nom = ''; formulaire.email = ''; formulaire.age = 0; } </script> <template> <!-- v-model sur les propriétés d'un reactive --> <input v-model="formulaire.nom" placeholder="Nom" /> <input v-model="formulaire.email" placeholder="Email" /> <button @click="reinitialiser">Réinitialiser</button> <p>Bonjour {{ formulaire.nom }} ({{ formulaire.age }} ans)</p> </template>
Règle simple : utilisez ref() pour les valeurs primitives (string, number, boolean) et reactive() pour les objets et les formulaires. En pratique, beaucoup de développeurs Vue utilisent ref() pour tout — c’est valide aussi.
ref()
reactive()
<!-- Composant enfant : CarteUtilisateur.vue --> <template> <ion-card> <ion-card-header> <ion-card-title>{{ nom }}</ion-card-title> <ion-card-subtitle>{{ email }}</ion-card-subtitle> </ion-card-header> <ion-card-content> <ion-button @click="$emit('selectionner', { nom, email })"> Sélectionner </ion-button> </ion-card-content> </ion-card> </template> <script setup lang="ts"> import { IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCardContent, IonButton } from '@ionic/vue'; // Définition des props — équivalent de PropTypes ou TypeScript interfaces en React const props = defineProps<{ nom: string; email: string; }>(); // Définition des événements émis — équivalent des callbacks en React const emit = defineEmits<{ selectionner: [utilisateur: { nom: string; email: string }]; }>(); </script>
<!-- Composant parent : ListeUtilisateurs.vue --> <template> <ion-list> <!-- Passage de props avec : (deux-points) — équivalent des {} en React --> <carte-utilisateur v-for="user in utilisateurs" :key="user.id" :nom="user.nom" :email="user.email" @selectionner="surSelection" /> </ion-list> <p v-if="selectionne">Sélectionné : {{ selectionne.nom }}</p> </template> <script setup lang="ts"> import { ref } from 'vue'; import CarteUtilisateur from './CarteUtilisateur.vue'; import { IonList } from '@ionic/vue'; const utilisateurs = ref([ { id: 1, nom: 'Alice Martin', email: 'alice@example.com' }, { id: 2, nom: 'Bob Dupont', email: 'bob@example.com' }, { id: 3, nom: 'Claire Renard', email: 'claire@example.com' }, ]); const selectionne = ref<{ nom: string; email: string } | null>(null); function surSelection(utilisateur: { nom: string; email: string }) { selectionne.value = utilisateur; } </script>
<template> <ion-content class="ion-padding"> <!-- v-if / v-else — équivalent de {condition && ...} en React --> <div v-if="estConnecte"> <p>Bienvenue, {{ nomUtilisateur }} !</p> </div> <div v-else> <p>Vous n'êtes pas connecté.</p> </div> <!-- v-show — comme v-if mais avec display:none (l'élément reste dans le DOM) --> <ion-spinner v-show="estEnChargement" /> <!-- v-for avec index — équivalent de .map() en React --> <ion-list> <ion-item v-for="(tache, index) in taches" :key="tache.id" > <ion-label>{{ index + 1 }}. {{ tache.titre }}</ion-label> <ion-badge v-if="tache.urgent" color="danger" slot="end">Urgent</ion-badge> </ion-item> </ion-list> <!-- v-model — two-way binding, BEAUCOUP plus simple qu'en React --> <ion-input v-model="recherche" placeholder="Rechercher..." /> <!-- Liste filtrée --> <ion-item v-for="t in tachesFiltrees" :key="t.id"> {{ t.titre }} </ion-item> </ion-content> </template> <script setup lang="ts"> import { ref, computed } from 'vue'; import { IonContent, IonList, IonItem, IonLabel, IonBadge, IonInput, IonSpinner } from '@ionic/vue'; const estConnecte = ref(true); const nomUtilisateur = ref('Alice'); const estEnChargement = ref(false); const recherche = ref(''); const taches = ref([ { id: 1, titre: 'Réunion client', urgent: true }, { id: 2, titre: 'Mise à jour documentation', urgent: false }, { id: 3, titre: 'Déploiement production', urgent: true }, { id: 4, titre: 'Review code', urgent: false }, ]); // Liste filtrée automatiquement quand "recherche" change const tachesFiltrees = computed(() => taches.value.filter(t => t.titre.toLowerCase().includes(recherche.value.toLowerCase()) ) ); </script>
Les composants Ionic s’adaptent automatiquement à la plateforme : ils ont l’apparence iOS sur iPhone et l’apparence Material Design sur Android. Vous n’avez rien à faire — Ionic détecte la plateforme et applique le bon style.
Le même <ion-button> rendu sur : iOS Bouton arrondi style Apple Android Bouton Material Design avec ripple Web Style intermédiaire adapté
<template> <ion-content class="ion-padding"> <!-- Styles de boutons --> <ion-button>Défaut (filled)</ion-button> <ion-button fill="outline">Contour</ion-button> <ion-button fill="clear">Texte seul</ion-button> <ion-button fill="solid" shape="round">Arrondi</ion-button> <!-- Tailles --> <ion-button size="small">Petit</ion-button> <ion-button size="default">Normal</ion-button> <ion-button size="large">Grand</ion-button> <!-- Largeur totale --> <ion-button expand="block">Pleine largeur</ion-button> <ion-button expand="full">Bord à bord</ion-button> <!-- Couleurs Ionic --> <ion-button color="primary">Primary (bleu)</ion-button> <ion-button color="secondary">Secondary (violet)</ion-button> <ion-button color="success">Succès (vert)</ion-button> <ion-button color="warning">Attention (jaune)</ion-button> <ion-button color="danger">Danger (rouge)</ion-button> <ion-button color="light">Clair</ion-button> <ion-button color="dark">Sombre</ion-button> <!-- Bouton avec icône --> <ion-button> <ion-icon slot="start" :icon="addCircle" /> Ajouter </ion-button> <!-- Bouton icône seule --> <ion-button shape="round"> <ion-icon slot="icon-only" :icon="heart" /> </ion-button> <!-- État chargement --> <ion-button :disabled="enChargement" @click="sauvegarder"> <ion-spinner v-if="enChargement" slot="start" name="crescent" /> {{ enChargement ? 'Sauvegarde...' : 'Sauvegarder' }} </ion-button> </ion-content> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonContent, IonButton, IonIcon, IonSpinner } from '@ionic/vue'; import { addCircle, heart } from 'ionicons/icons'; const enChargement = ref(false); async function sauvegarder() { enChargement.value = true; await new Promise(resolve => setTimeout(resolve, 2000)); // Simuler une API enChargement.value = false; } </script>
Les listes sont le composant le plus utilisé dans les applications mobiles :
<template> <ion-list> <!-- Item simple --> <ion-item> <ion-label>Élément simple</ion-label> </ion-item> <!-- Item avec icône --> <ion-item> <ion-icon :icon="person" slot="start" color="primary" /> <ion-label> <h2>Alicia Martini</h2> <p>alicia@exemple.com</p> </ion-label> </ion-item> <!-- Item avec badge --> <ion-item> <ion-label>Notifications</ion-label> <ion-badge slot="end" color="danger">5</ion-badge> </ion-item> <!-- Item cliquable (avec flèche) --> <ion-item button :detail="true" @click="ouvrirDetail(tache)" v-for="tache in taches" :key="tache.id"> <ion-icon :icon="checkmarkCircle" slot="start" :color="tache.termine ? 'success' : 'medium'" /> <ion-label> <h2>{{ tache.titre }}</h2> <p>{{ tache.description }}</p> </ion-label> <ion-note slot="end" color="medium">{{ tache.date }}</ion-note> </ion-item> <!-- Item avec toggle --> <ion-item> <ion-label>Notifications push</ion-label> <ion-toggle v-model="notificationsActives" slot="end" /> </ion-item> <!-- Item avec checkbox --> <ion-item v-for="tache in taches" :key="tache.id"> <ion-checkbox slot="start" v-model="tache.termine" /> <ion-label :class="{ 'barre': tache.termine }">{{ tache.titre }}</ion-label> </ion-item> </ion-list> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonList, IonItem, IonLabel, IonIcon, IonBadge, IonNote, IonToggle, IonCheckbox } from '@ionic/vue'; import { person, checkmarkCircle } from 'ionicons/icons'; const notificationsActives = ref(true); const taches = ref([ { id: 1, titre: 'Réviser le chapitre 3', description: 'Vue.js Components', date: 'Aujourd\'hui', termine: false }, { id: 2, titre: 'Exercice navigation', description: 'Ionic Router', date: 'Demain', termine: true }, { id: 3, titre: 'Projet fil rouge', description: 'App complète', date: 'Vendredi', termine: false }, ]); function ouvrirDetail(tache: any) { console.log('Ouvrir détail :', tache.titre); // router.push(`/taches/${tache.id}`) } </script> <style scoped> .barre { text-decoration: line-through; opacity: 0.5; } </style>
<template> <ion-content class="ion-padding"> <!-- Carte standard --> <ion-card> <img src="https://picsum.photos/400/200" alt="Image" /> <ion-card-header> <ion-card-title>Titre de la carte</ion-card-title> <ion-card-subtitle>Sous-titre</ion-card-subtitle> </ion-card-header> <ion-card-content> Contenu de la carte. Peut contenir du texte, des boutons, n'importe quoi. </ion-card-content> </ion-card> <!-- Carte de statistique (pattern courant dans les dashboards) --> <ion-card v-for="stat in statistiques" :key="stat.label"> <ion-card-content> <div class="stat-card"> <ion-icon :icon="stat.icone" :color="stat.couleur" class="stat-icon" /> <div> <h2 class="stat-valeur">{{ stat.valeur }}</h2> <p class="stat-label">{{ stat.label }}</p> </div> </div> </ion-card-content> </ion-card> </ion-content> </template> <script setup lang="ts"> import { IonContent, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCardContent, IonIcon } from '@ionic/vue'; import { people, checkmarkDone, time, alertCircle } from 'ionicons/icons'; const statistiques = [ { label: 'Utilisateurs actifs', valeur: '1 284', icone: people, couleur: 'primary' }, { label: 'Tâches terminées', valeur: '342', icone: checkmarkDone, couleur: 'success' }, { label: 'En attente', valeur: '28', icone: time, couleur: 'warning' }, { label: 'Erreurs', valeur: '3', icone: alertCircle, couleur: 'danger' }, ]; </script> <style scoped> .stat-card { display: flex; align-items: center; gap: 16px; } .stat-icon { font-size: 2.5rem; } .stat-valeur { font-size: 1.8rem; font-weight: bold; margin: 0; } .stat-label { color: var(--ion-color-medium); margin: 0; } </style>
<template> <ion-content class="ion-padding"> <ion-button @click="ouvrirModal">Ouvrir le Modal</ion-button> <ion-button color="danger" @click="confirmerSuppression">Supprimer</ion-button> <!-- Modal --> <ion-modal :is-open="modalOuvert" @did-dismiss="fermerModal"> <ion-header> <ion-toolbar> <ion-title>Détails</ion-title> <ion-buttons slot="end"> <ion-button @click="fermerModal">Fermer</ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content class="ion-padding"> <h2>Contenu du modal</h2> <p>Ici vous pouvez mettre un formulaire, des détails, etc.</p> <ion-button expand="block" @click="fermerModal">Valider</ion-button> </ion-content> </ion-modal> <!-- Alert (confirmation) --> <ion-alert :is-open="alerteOuverte" header="Confirmer la suppression" message="Cette action est irréversible. Voulez-vous continuer ?" :buttons="boutonsAlerte" @did-dismiss="alerteOuverte = false" /> </ion-content> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonContent, IonButton, IonModal, IonHeader, IonToolbar, IonTitle, IonButtons, IonAlert } from '@ionic/vue'; const modalOuvert = ref(false); const alerteOuverte = ref(false); const boutonsAlerte = [ { text: 'Annuler', role: 'cancel' }, { text: 'Supprimer', role: 'destructive', handler: () => supprimerElement() } ]; function ouvrirModal() { modalOuvert.value = true; } function fermerModal() { modalOuvert.value = false; } function confirmerSuppression() { alerteOuverte.value = true; } function supprimerElement() { console.log('Élément supprimé !'); // appel API ici } </script>
<script setup lang="ts"> import { toastController } from '@ionic/vue'; // Toast — notification légère en bas de l'écran async function afficherToast(message: string, couleur = 'success') { const toast = await toastController.create({ message, duration: 2500, // millisecondes position: 'bottom', // 'top', 'middle', 'bottom' color: couleur, buttons: [{ text: 'OK', role: 'cancel' }], }); await toast.present(); } // Utilisation function surSauvegarde() { // ... logique de sauvegarde ... afficherToast('Sauvegardé avec succès ! ✅'); } function surErreur() { afficherToast('Une erreur est survenue.', 'danger'); } </script>
<template> <ion-content> <!-- Pull-to-refresh — geste natif (tirer vers le bas pour recharger) --> <ion-refresher slot="fixed" @ionRefresh="recharger($event)"> <ion-refresher-content pulling-icon="chevron-down-circle-outline" refreshing-spinner="crescent" refreshing-text="Chargement..." /> </ion-refresher> <ion-list> <ion-item v-for="item in donnees" :key="item.id"> <ion-label>{{ item.titre }}</ion-label> </ion-item> </ion-list> </ion-content> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonContent, IonList, IonItem, IonLabel, IonRefresher, IonRefresherContent } from '@ionic/vue'; const donnees = ref([ { id: 1, titre: 'Élément 1' }, { id: 2, titre: 'Élément 2' }, ]); async function recharger(event: CustomEvent) { // Simuler un appel API await new Promise(resolve => setTimeout(resolve, 1500)); donnees.value.push({ id: Date.now(), titre: `Nouvel élément ${Date.now()}` }); (event.target as HTMLIonRefresherElement).complete(); // ✅ Arrêter l'animation } </script>
<template> <ion-content> <ion-list> <ion-item v-for="item in elements" :key="item.id"> {{ item.titre }} </ion-item> </ion-list> <!-- Chargement au bas de la liste --> <ion-infinite-scroll @ionInfinite="chargerPlus($event)"> <ion-infinite-scroll-content loading-spinner="bubbles" loading-text="Chargement..." /> </ion-infinite-scroll> </ion-content> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonContent, IonList, IonItem, IonInfiniteScroll, IonInfiniteScrollContent } from '@ionic/vue'; const elements = ref(Array.from({ length: 20 }, (_, i) => ({ id: i+1, titre: `Élément ${i+1}` }))); let page = 1; async function chargerPlus(event: CustomEvent) { await new Promise(resolve => setTimeout(resolve, 1000)); const nouveaux = Array.from({ length: 20 }, (_, i) => ({ id: page * 20 + i + 1, titre: `Élément ${page * 20 + i + 1}` })); elements.value.push(...nouveaux); page++; (event.target as HTMLIonInfiniteScrollElement).complete(); } </script>
Ionic utilise Vue Router pour la navigation. Si vous avez déjà utilisé React Router, la logique est identique : des routes correspondent à des composants/pages.
// src/router/index.ts import { createRouter, createWebHistory } from '@ionic/vue-router'; import { RouteRecordRaw } from 'vue-router'; // Lazy loading — les pages sont chargées à la demande (meilleures performances) const routes: Array<RouteRecordRaw> = [ { path: '/', redirect: '/tabs/accueil' // Redirection vers la page par défaut }, { path: '/tabs/', component: () => import('@/views/TabsPage.vue'), children: [ // Pages dans les onglets { path: 'accueil', component: () => import('@/views/AccueilPage.vue') }, { path: 'taches', component: () => import('@/views/TachesPage.vue') }, { path: 'profil', component: () => import('@/views/ProfilPage.vue') }, ] }, // Page en dehors des onglets (plein écran) { path: '/taches/:id', // Route avec paramètre dynamique component: () => import('@/views/TacheDetailPage.vue') }, { path: '/login', component: () => import('@/views/LoginPage.vue') }, ]; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }); export default router;
<script setup lang="ts"> import { useRouter, useRoute } from 'vue-router'; const router = useRouter(); // Pour naviguer const route = useRoute(); // Pour lire la route courante // Naviguer vers une page function allerAuProfil() { router.push('/tabs/profil'); } // Naviguer avec un paramètre function ouvrirTache(id: number) { router.push(`/taches/${id}`); // Ou avec un objet nommé : // router.push({ name: 'tache-detail', params: { id } }); } // Revenir en arrière function retour() { router.back(); } // Lire un paramètre de route const idTache = route.params.id; // Depuis /taches/:id const filtre = route.query.filtre; // Depuis /taches?filtre=urgent </script> <template> <!-- Lien de navigation dans le template --> <ion-item :router-link="'/taches/' + tache.id" router-direction="forward"> <ion-label>{{ tache.titre }}</ion-label> </ion-item> </template>
<!-- src/views/TabsPage.vue --> <template> <ion-page> <ion-tabs> <!-- Zone d'affichage des pages --> <ion-router-outlet /> <!-- Barre d'onglets en bas --> <ion-tab-bar slot="bottom"> <ion-tab-button tab="accueil" href="/tabs/accueil"> <ion-icon :icon="home" /> <ion-label>Accueil</ion-label> </ion-tab-button> <ion-tab-button tab="taches" href="/tabs/taches"> <ion-icon :icon="checkboxOutline" /> <ion-label>Tâches</ion-label> <!-- Badge de notification --> <ion-badge color="danger">{{ nombreTachesUrgentes }}</ion-badge> </ion-tab-button> <ion-tab-button tab="profil" href="/tabs/profil"> <ion-icon :icon="personCircle" /> <ion-label>Profil</ion-label> </ion-tab-button> </ion-tab-bar> </ion-tabs> </ion-page> </template> <script setup lang="ts"> import { ref } from 'vue'; import { IonPage, IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, IonBadge, IonRouterOutlet } from '@ionic/vue'; import { home, checkboxOutline, personCircle } from 'ionicons/icons'; const nombreTachesUrgentes = ref(3); </script>
Ionic gère les animations de navigation automatiquement (glissement à droite sur iOS, etc.). Vous pouvez aussi les contrôler :
<script setup lang="ts"> import { useRouter } from 'vue-router'; import { NavController } from '@ionic/vue'; const navCtrl = new NavController(); // Navigation avec animation "back" (glissement vers la droite) function retourAvecAnimation() { navCtrl.back(); } // Navigation sans animation function sansAnimation() { router.push('/login', { replace: true }); // replace: true = pas d'historique } </script>
Un pattern indispensable pour les applications avec authentification :
// src/router/index.ts import { useAuthStore } from '@/stores/auth'; // Guard global — vérifié avant chaque navigation router.beforeEach(async (to, from, next) => { const authStore = useAuthStore(); // Pages accessibles sans authentification const pagesPubliques = ['/login', '/inscription', '/mot-de-passe-oublie']; if (!pagesPubliques.includes(to.path) && !authStore.estConnecte) { // Rediriger vers le login en mémorisant la destination next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); // Continuer la navigation } });
<!-- src/views/LoginPage.vue --> <template> <ion-page> <ion-header> <ion-toolbar color="primary"> <ion-title>Connexion</ion-title> </ion-toolbar> </ion-header> <ion-content class="ion-padding"> <div class="logo-container"> <ion-icon :icon="lockClosed" color="primary" class="logo-icon" /> <h2>Bienvenue</h2> <p class="ion-color-medium">Connectez-vous pour continuer</p> </div> <!-- Formulaire avec reactive() --> <ion-list lines="full" class="ion-no-margin"> <!-- Champ Email --> <ion-item :class="{ 'ion-invalid': erreurs.email, 'ion-valid': !erreurs.email && form.email }"> <ion-label position="stacked">Email <ion-text color="danger">*</ion-text></ion-label> <ion-input v-model="form.email" type="email" placeholder="votre@email.com" autocomplete="email" @ion-blur="validerEmail" /> <ion-note slot="error">{{ erreurs.email }}</ion-note> </ion-item> <!-- Champ Mot de passe --> <ion-item :class="{ 'ion-invalid': erreurs.motDePasse }"> <ion-label position="stacked">Mot de passe <ion-text color="danger">*</ion-text></ion-label> <ion-input v-model="form.motDePasse" :type="motDePasseVisible ? 'text' : 'password'" placeholder="••••••••" autocomplete="current-password" /> <!-- Bouton afficher/masquer le mot de passe --> <ion-button fill="clear" slot="end" @click="motDePasseVisible = !motDePasseVisible"> <ion-icon :icon="motDePasseVisible ? eyeOff : eye" /> </ion-button> <ion-note slot="error">{{ erreurs.motDePasse }}</ion-note> </ion-item> </ion-list> <!-- Lien mot de passe oublié --> <div class="ion-text-end ion-padding-top ion-padding-end"> <ion-router-link href="/mot-de-passe-oublie" color="primary"> Mot de passe oublié ? </ion-router-link> </div> <!-- Bouton de connexion --> <div class="ion-padding-top"> <ion-button expand="block" color="primary" :disabled="enChargement || !formulaireValide" @click="seConnecter" > <ion-spinner v-if="enChargement" slot="start" name="crescent" /> {{ enChargement ? 'Connexion...' : 'Se connecter' }} </ion-button> </div> <!-- Message d'erreur global --> <ion-card v-if="erreurGlobale" color="danger" class="ion-margin-top"> <ion-card-content> <ion-icon :icon="alertCircle" /> {{ erreurGlobale }} </ion-card-content> </ion-card> </ion-content> </ion-page> </template> <script setup lang="ts"> import { ref, reactive, computed } from 'vue'; import { useRouter } from 'vue-router'; import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonInput, IonButton, IonText, IonNote, IonIcon, IonSpinner, IonCard, IonCardContent, IonRouterLink } from '@ionic/vue'; import { lockClosed, eye, eyeOff, alertCircle } from 'ionicons/icons'; import { useAuthStore } from '@/stores/auth'; const router = useRouter(); const authStore = useAuthStore(); // Données du formulaire const form = reactive({ email: '', motDePasse: '', }); // Erreurs de validation const erreurs = reactive({ email: '', motDePasse: '', }); const motDePasseVisible = ref(false); const enChargement = ref(false); const erreurGlobale = ref(''); // Formulaire valide seulement si aucune erreur et tous les champs remplis const formulaireValide = computed(() => form.email && form.motDePasse && !erreurs.email && !erreurs.motDePasse ); // Validation en temps réel function validerEmail() { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!form.email) { erreurs.email = 'L\'email est obligatoire.'; } else if (!emailRegex.test(form.email)) { erreurs.email = 'Format d\'email invalide.'; } else { erreurs.email = ''; } } function validerMotDePasse() { if (!form.motDePasse) { erreurs.motDePasse = 'Le mot de passe est obligatoire.'; } else if (form.motDePasse.length < 6) { erreurs.motDePasse = 'Minimum 6 caractères.'; } else { erreurs.motDePasse = ''; } } async function seConnecter() { // Valider tous les champs avant soumission validerEmail(); validerMotDePasse(); if (!formulaireValide.value) return; enChargement.value = true; erreurGlobale.value = ''; try { await authStore.connecter(form.email, form.motDePasse); router.push('/tabs/accueil'); } catch (error: any) { erreurGlobale.value = error.message || 'Erreur de connexion. Vérifiez vos identifiants.'; } finally { enChargement.value = false; } } </script> <style scoped> .logo-container { text-align: center; padding: 2rem 0; } .logo-icon { font-size: 4rem; } </style>
<template> <ion-page> <ion-header> <ion-toolbar> <ion-buttons slot="start"> <ion-back-button default-href="/tabs/taches" /> </ion-buttons> <ion-title>Nouvelle tâche</ion-title> <ion-buttons slot="end"> <ion-button @click="sauvegarder" :disabled="!formulaireValide"> <strong>Sauver</strong> </ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content class="ion-padding"> <ion-list> <!-- Titre --> <ion-item> <ion-label position="stacked">Titre</ion-label> <ion-input v-model="form.titre" placeholder="Ex : Réunion client" /> </ion-item> <!-- Description --> <ion-item> <ion-label position="stacked">Description</ion-label> <ion-textarea v-model="form.description" :rows="4" placeholder="Détails de la tâche..." /> </ion-item> <!-- Priorité (select) --> <ion-item> <ion-label>Priorité</ion-label> <ion-select v-model="form.priorite" interface="popover"> <ion-select-option value="faible">🟢 Faible</ion-select-option> <ion-select-option value="normale">🟡 Normale</ion-select-option> <ion-select-option value="haute">🟠 Haute</ion-select-option> <ion-select-option value="urgente">🔴 Urgente</ion-select-option> </ion-select> </ion-item> <!-- Date d'échéance (datetime) --> <ion-item> <ion-label>Échéance</ion-label> <ion-datetime-button datetime="echeance" /> <ion-modal :keep-contents-mounted="true"> <ion-datetime id="echeance" v-model="form.echeance" presentation="date" :min="today" /> </ion-modal> </ion-item> <!-- Catégorie (radio) --> <ion-radio-group v-model="form.categorie"> <ion-list-header> <ion-label>Catégorie</ion-label> </ion-list-header> <ion-item v-for="cat in categories" :key="cat.valeur"> <ion-radio slot="start" :value="cat.valeur" /> <ion-label>{{ cat.label }}</ion-label> </ion-item> </ion-radio-group> </ion-list> </ion-content> </ion-page> </template> <script setup lang="ts"> import { reactive, computed } from 'vue'; import { useRouter } from 'vue-router'; import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonInput, IonTextarea, IonSelect, IonSelectOption, IonButtons, IonBackButton, IonButton, IonRadioGroup, IonRadio, IonListHeader, IonDatetime, IonDatetimeButton, IonModal } from '@ionic/vue'; import { useTachesStore } from '@/stores/taches'; import { toastController } from '@ionic/vue'; const router = useRouter(); const tachesStore = useTachesStore(); const today = new Date().toISOString(); const form = reactive({ titre: '', description: '', priorite: 'normale', echeance: today, categorie: 'personnel', }); const categories = [ { valeur: 'personnel', label: '👤 Personnel' }, { valeur: 'professionnel', label: '💼 Professionnel' }, { valeur: 'formation', label: '📚 Formation' }, ]; const formulaireValide = computed(() => form.titre.trim().length >= 3); async function sauvegarder() { if (!formulaireValide.value) return; await tachesStore.creer(form); const toast = await toastController.create({ message: 'Tâche créée avec succès !', duration: 2000, color: 'success', }); await toast.present(); router.back(); } </script>
Axios est la bibliothèque recommandée pour les appels HTTP dans Ionic avec Vue. Installez-la :
npm install axios
Créez un fichier de configuration centralisé :
// src/services/api.ts import axios from 'axios'; // Instance Axios configurée pour votre backend const api = axios.create({ // Adaptez selon votre backend : // Spring Boot en local : http://localhost:8080/api // Symfony en local : http://localhost:8000/api baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', timeout: 10000, // 10 secondes max headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, }); // Intercepteur de requête — ajout automatique du token JWT api.interceptors.request.use( (config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); // Intercepteur de réponse — gestion globale des erreurs api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // Token expiré ou invalide → rediriger vers login localStorage.removeItem('token'); window.location.href = '/login'; } if (error.response?.status === 403) { console.error('Accès refusé.'); } if (error.response?.status >= 500) { console.error('Erreur serveur :', error.response.data); } return Promise.reject(error); } ); export default api;
// src/env.d.ts — Déclaration des variables d'environnement interface ImportMetaEnv { readonly VITE_API_URL: string; }
# .env.development — Variables pour le développement local VITE_API_URL=http://localhost:8080/api # .env.production — Variables pour la production VITE_API_URL=https://api.votredomaine.com/api
Organisez vos appels API dans des services dédiés (même pattern que dans Spring Boot !) :
// src/services/tacheService.ts import api from './api'; // Types TypeScript export interface Tache { id: number; titre: string; description: string; priorite: 'faible' | 'normale' | 'haute' | 'urgente'; termine: boolean; echeance: string; categorie: string; createdAt: string; } export interface CreerTacheDTO { titre: string; description: string; priorite: string; echeance: string; categorie: string; } // Service Tâches — tous les appels REST export const tacheService = { // GET /api/taches async trouverToutes(): Promise<Tache[]> { const response = await api.get<Tache[]>('/taches'); return response.data; }, // GET /api/taches/:id async trouverParId(id: number): Promise<Tache> { const response = await api.get<Tache>(`/taches/${id}`); return response.data; }, // GET /api/taches?priorite=urgente&termine=false async trouverAvecFiltres(filtres: Partial<Tache>): Promise<Tache[]> { const response = await api.get<Tache[]>('/taches', { params: filtres }); return response.data; }, // POST /api/taches async creer(dto: CreerTacheDTO): Promise<Tache> { const response = await api.post<Tache>('/taches', dto); return response.data; }, // PUT /api/taches/:id async mettreAJour(id: number, dto: Partial<CreerTacheDTO>): Promise<Tache> { const response = await api.put<Tache>(`/taches/${id}`, dto); return response.data; }, // PATCH /api/taches/:id/terminer async terminer(id: number): Promise<Tache> { const response = await api.patch<Tache>(`/taches/${id}/terminer`); return response.data; }, // DELETE /api/taches/:id async supprimer(id: number): Promise<void> { await api.delete(`/taches/${id}`); }, };
// src/services/authService.ts import api from './api'; export interface LoginRequest { email: string; password: string; } export interface LoginResponse { token: string; refreshToken: string; user: { id: number; nom: string; email: string; roles: string[]; }; } export const authService = { async connecter(credentials: LoginRequest): Promise<LoginResponse> { const response = await api.post<LoginResponse>('/auth/login', credentials); return response.data; }, async deconnecter(): Promise<void> { await api.post('/auth/logout'); }, async rafraichirToken(refreshToken: string): Promise<{ token: string }> { const response = await api.post('/auth/refresh', { refreshToken }); return response.data; }, };
<!-- src/views/TachesPage.vue --> <template> <ion-page> <ion-header> <ion-toolbar color="primary"> <ion-title>Mes Tâches</ion-title> <ion-buttons slot="end"> <ion-button router-link="/nouvelle-tache"> <ion-icon slot="icon-only" :icon="add" /> </ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content> <!-- Pull-to-refresh --> <ion-refresher slot="fixed" @ionRefresh="recharger($event)"> <ion-refresher-content /> </ion-refresher> <!-- État : chargement initial --> <div v-if="enChargement" class="ion-text-center ion-padding"> <ion-spinner name="crescent" color="primary" /> <p>Chargement des tâches...</p> </div> <!-- État : erreur --> <ion-card v-else-if="erreur" color="danger" class="ion-margin"> <ion-card-content> <ion-icon :icon="alertCircle" /> {{ erreur }} <ion-button fill="clear" color="light" @click="chargerTaches">Réessayer</ion-button> </ion-card-content> </ion-card> <!-- État : liste vide --> <div v-else-if="taches.length === 0" class="ion-text-center ion-padding"> <ion-icon :icon="checkmarkDoneCircle" style="font-size: 4rem" color="medium" /> <h3>Aucune tâche !</h3> <p>Appuyez sur + pour créer votre première tâche.</p> </div> <!-- État : liste de tâches --> <ion-list v-else> <ion-item-sliding v-for="tache in taches" :key="tache.id"> <!-- Swipe gauche : supprimer --> <ion-item-options side="end"> <ion-item-option color="danger" expandable @click="supprimerTache(tache.id)"> <ion-icon slot="icon-only" :icon="trash" /> </ion-item-option> </ion-item-options> <!-- Swipe droit : terminer --> <ion-item-options side="start"> <ion-item-option color="success" @click="terminerTache(tache.id)"> <ion-icon slot="icon-only" :icon="checkmark" /> </ion-item-option> </ion-item-options> <!-- Contenu de l'item --> <ion-item :router-link="'/taches/' + tache.id" :detail="true" :class="{ 'termine': tache.termine }" > <ion-icon slot="start" :icon="tache.termine ? checkmarkCircle : ellipseOutline" :color="tache.termine ? 'success' : 'medium'" /> <ion-label> <h2>{{ tache.titre }}</h2> <p>{{ tache.description }}</p> </ion-label> <ion-badge slot="end" :color="couleurPriorite(tache.priorite)" > {{ tache.priorite }} </ion-badge> </ion-item> </ion-item-sliding> </ion-list> </ion-content> </ion-page> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonItemSliding, IonItemOptions, IonItemOption, IonLabel, IonIcon, IonBadge, IonButtons, IonButton, IonCard, IonCardContent, IonSpinner, IonRefresher, IonRefresherContent, toastController, alertController } from '@ionic/vue'; import { add, trash, checkmark, checkmarkCircle, ellipseOutline, checkmarkDoneCircle, alertCircle } from 'ionicons/icons'; import { tacheService, type Tache } from '@/services/tacheService'; const taches = ref<Tache[]>([]); const enChargement = ref(true); const erreur = ref(''); // Chargement au montage du composant onMounted(async () => { await chargerTaches(); }); async function chargerTaches() { enChargement.value = true; erreur.value = ''; try { taches.value = await tacheService.trouverToutes(); } catch (e: any) { erreur.value = 'Impossible de charger les tâches. Vérifiez votre connexion.'; } finally { enChargement.value = false; } } async function recharger(event: CustomEvent) { await chargerTaches(); (event.target as HTMLIonRefresherElement).complete(); } async function terminerTache(id: number) { try { const tacheMaj = await tacheService.terminer(id); const index = taches.value.findIndex(t => t.id === id); if (index !== -1) taches.value[index] = tacheMaj; const toast = await toastController.create({ message: 'Tâche terminée ✅', duration: 1500, color: 'success' }); await toast.present(); } catch { const toast = await toastController.create({ message: 'Erreur lors de la mise à jour', duration: 2000, color: 'danger' }); await toast.present(); } } async function supprimerTache(id: number) { const alert = await alertController.create({ header: 'Confirmer', message: 'Supprimer cette tâche ?', buttons: [ { text: 'Annuler', role: 'cancel' }, { text: 'Supprimer', role: 'destructive', handler: async () => { await tacheService.supprimer(id); taches.value = taches.value.filter(t => t.id !== id); } } ] }); await alert.present(); } function couleurPriorite(priorite: string) { return { faible: 'success', normale: 'warning', haute: 'tertiary', urgente: 'danger' }[priorite] || 'medium'; } </script> <style scoped> .termine { opacity: 0.5; text-decoration: line-through; } </style>
Votre backend Spring Boot doit autoriser les requêtes depuis l’application Ionic :
// Configuration CORS pour Spring Boot @Configuration public class CorsConfig { @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins( "http://localhost:8100", // Ionic dev server "http://localhost:3000", // Si vous utilisez un autre port "capacitor://localhost", // Application Capacitor sur Android "ionic://localhost" // Application Capacitor sur iOS ) .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }; } }
# config/packages/nelmio_cors.yaml # Après : composer require nelmio/cors-bundle nelmio_cors: defaults: allow_credentials: false allow_origin: [] allow_headers: [] allow_methods: [] expose_headers: [] max_age: 0 paths: '^/api/': allow_credentials: true allow_origin: - 'http://localhost:8100' - 'capacitor://localhost' - 'ionic://localhost' allow_headers: ['Content-Type', 'Authorization', 'Accept'] allow_methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] max_age: 3600
// src/services/authService.ts — avec gestion du token export const authService = { async connecter(email: string, password: string) { const response = await api.post('/auth/login', { email, password }); // Stocker le token JWT const { token, user } = response.data; localStorage.setItem('token', token); localStorage.setItem('user', JSON.stringify(user)); return response.data; }, async deconnecter() { localStorage.removeItem('token'); localStorage.removeItem('user'); }, estConnecte(): boolean { const token = localStorage.getItem('token'); if (!token) return false; // Vérifier si le token est expiré (décodage du JWT) try { const payload = JSON.parse(atob(token.split('.')[1])); const maintenant = Math.floor(Date.now() / 1000); return payload.exp > maintenant; } catch { return false; } }, obtenirUtilisateur() { const user = localStorage.getItem('user'); return user ? JSON.parse(user) : null; }, };
Pinia est la solution officielle de gestion d’état pour Vue 3. Elle est incluse dans les nouveaux projets Ionic Vue. Si vous avez utilisé Zustand en React, Pinia est encore plus simple.
# Pinia est inclus dans les projets Ionic Vue récents # Si absent : npm install pinia
// src/stores/auth.ts import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { authService } from '@/services/authService'; // Syntax "Setup Store" — la plus moderne, proche de la Composition API export const useAuthStore = defineStore('auth', () => { // ===== STATE ===== const token = ref<string | null>(localStorage.getItem('token')); const utilisateur = ref<{ id: number; nom: string; email: string; roles: string[]; } | null>(null); const chargement = ref(false); // ===== GETTERS (computed) ===== const estConnecte = computed(() => !!token.value); const estAdmin = computed(() => utilisateur.value?.roles.includes('ROLE_ADMIN') ?? false); const nomComplet = computed(() => utilisateur.value?.nom ?? 'Invité'); // ===== ACTIONS ===== async function connecter(email: string, motDePasse: string) { chargement.value = true; try { const data = await authService.connecter(email, motDePasse); token.value = data.token; utilisateur.value = data.user; // Persister dans le localStorage localStorage.setItem('token', data.token); localStorage.setItem('user', JSON.stringify(data.user)); } finally { chargement.value = false; } } function deconnecter() { token.value = null; utilisateur.value = null; localStorage.removeItem('token'); localStorage.removeItem('user'); } function reinitialiserDepuisStorage() { const tokenStocke = localStorage.getItem('token'); const userStocke = localStorage.getItem('user'); if (tokenStocke) { token.value = tokenStocke; utilisateur.value = userStocke ? JSON.parse(userStocke) : null; } } return { // State token, utilisateur, chargement, // Getters estConnecte, estAdmin, nomComplet, // Actions connecter, deconnecter, reinitialiserDepuisStorage, }; });
// src/stores/taches.ts import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { tacheService, type Tache, type CreerTacheDTO } from '@/services/tacheService'; export const useTachesStore = defineStore('taches', () => { // ===== STATE ===== const taches = ref<Tache[]>([]); const chargement = ref(false); const erreur = ref(''); const tacheActive = ref<Tache | null>(null); // ===== GETTERS ===== const tachesTerminees = computed(() => taches.value.filter(t => t.termine)); const tachesEnCours = computed(() => taches.value.filter(t => !t.termine)); const tachesUrgentes = computed(() => taches.value.filter(t => t.priorite === 'urgente' && !t.termine) ); const nombreTotal = computed(() => taches.value.length); const tauxCompletion = computed(() => { if (taches.value.length === 0) return 0; return Math.round((tachesTerminees.value.length / taches.value.length) * 100); }); // ===== ACTIONS ===== async function charger() { chargement.value = true; erreur.value = ''; try { taches.value = await tacheService.trouverToutes(); } catch (e: any) { erreur.value = e.message || 'Erreur de chargement'; } finally { chargement.value = false; } } async function creer(dto: CreerTacheDTO) { const nouvelleTache = await tacheService.creer(dto); taches.value.unshift(nouvelleTache); // Ajouter en tête de liste return nouvelleTache; } async function mettreAJour(id: number, dto: Partial<CreerTacheDTO>) { const tacheMaj = await tacheService.mettreAJour(id, dto); const index = taches.value.findIndex(t => t.id === id); if (index !== -1) taches.value[index] = tacheMaj; return tacheMaj; } async function terminer(id: number) { const tacheMaj = await tacheService.terminer(id); const index = taches.value.findIndex(t => t.id === id); if (index !== -1) taches.value[index] = tacheMaj; } async function supprimer(id: number) { await tacheService.supprimer(id); taches.value = taches.value.filter(t => t.id !== id); } return { // State taches, chargement, erreur, tacheActive, // Getters tachesTerminees, tachesEnCours, tachesUrgentes, nombreTotal, tauxCompletion, // Actions charger, creer, mettreAJour, terminer, supprimer, }; });
<template> <ion-page> <ion-content class="ion-padding"> <!-- Statistiques depuis le store --> <ion-card> <ion-card-content> <h2>Tableau de bord</h2> <p>{{ tachesStore.tauxCompletion }}% complété</p> <ion-progress-bar :value="tachesStore.tauxCompletion / 100" /> <ion-grid> <ion-row> <ion-col> <div class="stat"> <strong>{{ tachesStore.tachesEnCours.length }}</strong> <span>En cours</span> </div> </ion-col> <ion-col> <div class="stat"> <strong>{{ tachesStore.tachesUrgentes.length }}</strong> <span>Urgentes</span> </div> </ion-col> </ion-row> </ion-grid> </ion-card-content> </ion-card> <!-- Info utilisateur depuis le store auth --> <ion-chip color="primary"> <ion-avatar> <ion-icon :icon="person" /> </ion-avatar> <ion-label>{{ authStore.nomComplet }}</ion-label> <ion-icon :icon="close" @click="authStore.deconnecter()" /> </ion-chip> </ion-content> </ion-page> </template> <script setup lang="ts"> import { onMounted } from 'vue'; import { useAuthStore } from '@/stores/auth'; import { useTachesStore } from '@/stores/taches'; import { IonPage, IonContent, IonCard, IonCardContent, IonProgressBar, IonGrid, IonRow, IonCol, IonChip, IonAvatar, IonLabel, IonIcon } from '@ionic/vue'; import { person, close } from 'ionicons/icons'; const authStore = useAuthStore(); const tachesStore = useTachesStore(); onMounted(async () => { await tachesStore.charger(); }); </script>
Capacitor est le pont entre votre application web Ionic et les APIs natives du téléphone. C’est lui qui vous permet d’accéder à la caméra, la géolocalisation, les notifications push, les fichiers, etc.
Votre code Vue ↓ Capacitor (JavaScript) ← vous appelez des fonctions JS simples ↓ Plugin natif (Java/Kotlin pour Android, Swift pour iOS) ↓ API du téléphone (caméra, GPS, capteurs...)
Capacitor fonctionne dans le navigateur aussi (avec des fallbacks), ce qui facilite le développement.
# Plugins officiels Capacitor (@capacitor/*) npm install @capacitor/camera # Caméra et galerie npm install @capacitor/geolocation # GPS et localisation npm install @capacitor/push-notifications # Notifications push npm install @capacitor/local-notifications # Notifications locales npm install @capacitor/filesystem # Accès aux fichiers npm install @capacitor/preferences # Stockage clé-valeur (remplace localStorage) npm install @capacitor/share # Partage natif npm install @capacitor/haptics # Vibrations / retour tactile npm install @capacitor/network # État de la connexion réseau npm install @capacitor/status-bar # Contrôle de la barre de statut npm install @capacitor/splash-screen # Écran de démarrage npm install @capacitor/device # Infos sur l'appareil
Après l’installation de chaque plugin :
# Synchroniser les plugins natifs avec les projets Android/iOS npx cap sync
<template> <ion-page> <ion-header> <ion-toolbar><ion-title>Photo</ion-title></ion-toolbar> </ion-header> <ion-content class="ion-padding"> <!-- Aperçu de la photo --> <div v-if="photoUrl" class="photo-container"> <img :src="photoUrl" alt="Photo prise" class="photo-preview" /> </div> <div v-else class="photo-placeholder ion-text-center"> <ion-icon :icon="cameraOutline" style="font-size: 5rem" color="medium" /> <p>Aucune photo sélectionnée</p> </div> <!-- Boutons d'action --> <ion-button expand="block" @click="prendrePhoto"> <ion-icon slot="start" :icon="camera" /> Prendre une photo </ion-button> <ion-button expand="block" fill="outline" @click="choisirGalerie"> <ion-icon slot="start" :icon="images" /> Choisir depuis la galerie </ion-button> </ion-content> </ion-page> </template> <script setup lang="ts"> import { ref } from 'vue'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonIcon } from '@ionic/vue'; import { camera, cameraOutline, images } from 'ionicons/icons'; const photoUrl = ref<string | null>(null); async function prendrePhoto() { try { const photo = await Camera.getPhoto({ resultType: CameraResultType.DataUrl, // Base64 URL source: CameraSource.Camera, // Caméra directe quality: 90, // Qualité JPEG (1-100) allowEditing: true, // Recadrage après la photo }); photoUrl.value = photo.dataUrl ?? null; } catch (error) { console.log('Annulé ou erreur :', error); } } async function choisirGalerie() { try { const photo = await Camera.getPhoto({ resultType: CameraResultType.DataUrl, source: CameraSource.Photos, // Galerie quality: 90, }); photoUrl.value = photo.dataUrl ?? null; } catch (error) { console.log('Annulé ou erreur :', error); } } </script> <style scoped> .photo-preview { width: 100%; border-radius: 12px; margin-bottom: 1rem; } .photo-placeholder { padding: 3rem 0; } </style>
<script setup lang="ts"> import { ref } from 'vue'; import { Geolocation } from '@capacitor/geolocation'; interface Position { latitude: number; longitude: number; precision: number; } const position = ref<Position | null>(null); const enChargement = ref(false); const erreurGeo = ref(''); async function obtenirPosition() { enChargement.value = true; erreurGeo.value = ''; try { // Demande automatiquement la permission si nécessaire const coords = await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 10000, }); position.value = { latitude: coords.coords.latitude, longitude: coords.coords.longitude, precision: coords.coords.accuracy, }; } catch (e: any) { erreurGeo.value = e.message === 'User denied Geolocation' ? 'Permission refusée. Activez la localisation dans les paramètres.' : 'Impossible d\'obtenir la position.'; } finally { enChargement.value = false; } } // Suivi en temps réel de la position let watchId: string; async function demarrerSuivi() { watchId = await Geolocation.watchPosition( { enableHighAccuracy: true }, (pos, err) => { if (pos) { position.value = { latitude: pos.coords.latitude, longitude: pos.coords.longitude, precision: pos.coords.accuracy, }; } } ); } function arreterSuivi() { if (watchId) Geolocation.clearWatch({ id: watchId }); } </script>
// Capacitor Preferences remplace localStorage pour les applications natives import { Preferences } from '@capacitor/preferences'; // Sauvegarder une valeur await Preferences.set({ key: 'token', value: 'mon-jwt-token' }); // Lire une valeur const { value } = await Preferences.get({ key: 'token' }); // Supprimer une valeur await Preferences.remove({ key: 'token' }); // Vider tout le stockage await Preferences.clear(); // Exemple d'utilisation dans un service export const storageService = { async sauvegarder(cle: string, valeur: any) { await Preferences.set({ key: cle, value: JSON.stringify(valeur) }); }, async lire<T>(cle: string): Promise<T | null> { const { value } = await Preferences.get({ key: cle }); return value ? JSON.parse(value) : null; }, async supprimer(cle: string) { await Preferences.remove({ key: cle }); }, };
<script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; import { Network } from '@capacitor/network'; const estConnecte = ref(true); const typeConnexion = ref('unknown'); onMounted(async () => { // État initial const status = await Network.getStatus(); estConnecte.value = status.connected; typeConnexion.value = status.connectionType; // Écouter les changements de connexion Network.addListener('networkStatusChange', (status) => { estConnecte.value = status.connected; typeConnexion.value = status.connectionType; if (!status.connected) { // Afficher une bannière "Mode hors ligne" console.log('Connexion perdue — mode hors ligne activé'); } }); }); onUnmounted(() => { Network.removeAllListeners(); }); </script> <template> <!-- Bannière de mode hors ligne --> <div v-if="!estConnecte" class="offline-banner"> Mode hors ligne — Vos modifications seront synchronisées dès la reconnexion </div> </template>
Ionic utilise des variables CSS personnalisées pour tout le thème. Vous pouvez tout modifier depuis src/theme/variables.css :
src/theme/variables.css
/* src/theme/variables.css */ /* Palette de couleurs principale */ :root { /* Couleur principale (bleu par défaut) */ --ion-color-primary: #3880ff; --ion-color-primary-rgb: 56, 128, 255; --ion-color-primary-shade: #3171e0; --ion-color-primary-tint: #4c8dff; /* Couleur secondaire */ --ion-color-secondary: #3dc2ff; /* Couleur de succès */ --ion-color-success: #2dd36f; /* Couleur d'avertissement */ --ion-color-warning: #ffc409; /* Couleur de danger */ --ion-color-danger: #eb445a; /* Fond de l'application */ --ion-background-color: #ffffff; /* Couleur du texte */ --ion-text-color: #000000; /* Police de caractères globale */ --ion-font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; } /* Mode sombre — activé automatiquement selon les préférences système */ @media (prefers-color-scheme: dark) { :root { --ion-background-color: #121212; --ion-background-color-rgb: 18, 18, 18; --ion-text-color: #ffffff; --ion-color-step-50: #1e1e1e; --ion-item-background: #1e1e1e; --ion-toolbar-background: #1f1f1f; --ion-tab-bar-background: #1f1f1f; } }
Ionic fournit des classes CSS prêtes à l’emploi (similaires à Tailwind) :
<!-- Espacement --> <div class="ion-padding"> <!-- padding: 16px --> <div class="ion-padding-top"> <!-- padding-top: 16px --> <div class="ion-padding-horizontal"> <!-- padding gauche + droite --> <div class="ion-margin"> <!-- margin: 16px --> <div class="ion-no-padding"> <!-- padding: 0 --> <!-- Texte --> <p class="ion-text-center"> <!-- text-align: center --> <p class="ion-text-start"> <!-- text-align: left --> <p class="ion-text-end"> <!-- text-align: right --> <!-- Couleurs de texte --> <p class="ion-color-primary"> <!-- couleur primaire --> <p class="ion-color-danger"> <!-- rouge --> <p class="ion-color-medium"> <!-- gris --> <!-- Affichage --> <div class="ion-hide"> <!-- display: none --> <div class="ion-hide-md-up"> <!-- caché si écran >= medium --> <!-- Grid responsive --> <ion-grid> <ion-row> <ion-col size="12" size-md="6" size-lg="4"> <!-- Pleine largeur mobile, 1/2 tablet, 1/3 desktop --> </ion-col> </ion-row> </ion-grid>
<template> <ion-grid> <!-- Liste de cartes responsive --> <ion-row> <ion-col v-for="produit in produits" :key="produit.id" size="12" <!-- Mobile : pleine largeur --> size-sm="6" <!-- Tablette : 2 colonnes --> size-lg="4" <!-- Desktop : 3 colonnes --> > <ion-card> <ion-card-header> <ion-card-title>{{ produit.nom }}</ion-card-title> </ion-card-header> <ion-card-content> {{ produit.description }} </ion-card-content> </ion-card> </ion-col> </ion-row> </ion-grid> </template>
<script setup lang="ts"> import { ref, watch, onMounted } from 'vue'; const modeSombre = ref(false); onMounted(() => { // Lire la préférence sauvegardée const preference = localStorage.getItem('mode-sombre'); modeSombre.value = preference === 'true'; appliquerTheme(); }); function toggleModeSombre() { modeSombre.value = !modeSombre.value; localStorage.setItem('mode-sombre', String(modeSombre.value)); appliquerTheme(); } function appliquerTheme() { document.body.classList.toggle('dark', modeSombre.value); } </script> <template> <ion-item> <ion-label>Mode sombre</ion-label> <ion-toggle :checked="modeSombre" @ionChange="toggleModeSombre" /> </ion-item> </template>
Avant de déployer, compilez le code web :
# 1. Compiler le code web (Vue → HTML/CSS/JS optimisé) ionic build # 2. Synchroniser avec les projets natifs (Android, iOS) npx cap sync # Optionnel : copier uniquement (sans réinstaller les plugins) npx cap copy
# Ouvrir le projet dans Android Studio npx cap open android # OU lancer directement sur l'émulateur depuis le terminal ionic capacitor run android # Lancer avec rechargement en direct (live reload) # Très pratique pendant le développement ! ionic capacitor run android --livereload --external
Dans Android Studio :
Tools
Device Manager
Create Device
# 1. Activer le mode développeur sur le téléphone : # Paramètres → À propos → Appuyer 7 fois sur "Numéro de build" # 2. Activer le débogage USB : # Paramètres → Options développeur → Débogage USB → Activé # 3. Connecter le téléphone en USB et accepter la demande de débogage # 4. Vérifier que le téléphone est détecté adb devices # Doit afficher votre téléphone # 5. Lancer sur le téléphone ionic capacitor run android --target=VOTRE_DEVICE_ID
# Dans Android Studio # Build → Generate Signed Bundle / APK → APK → Next # (Créer ou utiliser un keystore de signature) # OU depuis la ligne de commande dans android/ cd android ./gradlew assembleRelease # APK Release ./gradlew bundleRelease # AAB (pour le Play Store)
L’APK généré se trouve dans :
android/app/build/outputs/apk/release/app-release.apk
// capacitor.config.ts import type { CapacitorConfig } from '@capacitor/cli'; const config: CapacitorConfig = { appId: 'com.votrenom.monapplication', // ID unique de l'app (comme un package Java) appName: 'MonApplication', webDir: 'dist', // Dossier de sortie de ionic build server: { // Pour le live reload sur appareil physique // androidScheme: 'https', }, plugins: { SplashScreen: { launchShowDuration: 2000, backgroundColor: '#3880ff', showSpinner: false, }, StatusBar: { style: 'DARK', backgroundColor: '#3880ff', overlaysWebView: false, }, PushNotifications: { presentationOptions: ['badge', 'sound', 'alert'], }, }, }; export default config;
# Installer le générateur d'assets npm install @capacitor/assets --save-dev # Préparer vos images : # resources/icon.png → 1024x1024px, fond uni (pas transparent) # resources/splash.png → 2732x2732px (image centrée) # Générer toutes les tailles automatiquement npx capacitor-assets generate --android --ios
L’application TaskManager Pro illustre tous les concepts du cours dans un projet cohérent :
task-manager-pro/ ├── src/ │ ├── components/ │ │ ├── TacheItem.vue Item de liste réutilisable │ │ ├── StatCard.vue Carte de statistique │ │ └── EmptyState.vue Écran vide réutilisable │ ├── views/ │ │ ├── LoginPage.vue Page de connexion │ │ ├── TabsPage.vue Conteneur des onglets │ │ ├── AccueilPage.vue Dashboard │ │ ├── TachesPage.vue Liste des tâches │ │ ├── TacheDetailPage.vue Détail / modification │ │ ├── NouvelleTachePage.vue Formulaire de création │ │ └── ProfilPage.vue Profil utilisateur │ ├── services/ │ │ ├── api.ts Instance Axios configurée │ │ ├── authService.ts Authentification │ │ └── tacheService.ts CRUD tâches │ ├── stores/ │ │ ├── auth.ts Store authentification (Pinia) │ │ └── taches.ts Store tâches (Pinia) │ └── router/ │ └── index.ts Routes + guards └── capacitor.config.ts
<!-- src/views/AccueilPage.vue --> <template> <ion-page> <ion-header> <ion-toolbar color="primary"> <ion-title>Bonjour, {{ authStore.nomComplet }} 👋</ion-title> <ion-buttons slot="end"> <ion-button @click="authStore.deconnecter(); router.replace('/login')"> <ion-icon slot="icon-only" :icon="logOut" /> </ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content class="ion-padding"> <!-- Barre de progression globale --> <ion-card color="primary"> <ion-card-content> <div class="progress-header"> <span>Progression globale</span> <strong>{{ tachesStore.tauxCompletion }}%</strong> </div> <ion-progress-bar :value="tachesStore.tauxCompletion / 100" color="light" /> <p class="ion-no-margin ion-padding-top" style="font-size: 0.85rem; opacity: 0.8"> {{ tachesStore.tachesTerminees.length }} / {{ tachesStore.nombreTotal }} tâches terminées </p> </ion-card-content> </ion-card> <!-- Statistiques rapides --> <ion-grid class="ion-no-padding"> <ion-row> <ion-col size="6" v-for="stat in statistiques" :key="stat.label"> <ion-card class="stat-card"> <ion-card-content class="ion-text-center"> <ion-icon :icon="stat.icone" :color="stat.couleur" style="font-size: 2rem" /> <h2 class="stat-valeur">{{ stat.valeur }}</h2> <p class="stat-label">{{ stat.label }}</p> </ion-card-content> </ion-card> </ion-col> </ion-row> </ion-grid> <!-- Tâches urgentes --> <div v-if="tachesStore.tachesUrgentes.length > 0"> <h3 class="section-title">Tâches urgentes</h3> <ion-list> <ion-item v-for="tache in tachesStore.tachesUrgentes.slice(0, 3)" :key="tache.id" :router-link="'/taches/' + tache.id" :detail="true" > <ion-icon :icon="alertCircle" color="danger" slot="start" /> <ion-label> <h2>{{ tache.titre }}</h2> <p>Échéance : {{ formatDate(tache.echeance) }}</p> </ion-label> </ion-item> </ion-list> </div> <!-- Bouton d'ajout rapide --> <ion-fab vertical="bottom" horizontal="end" slot="fixed"> <ion-fab-button router-link="/nouvelle-tache" color="primary"> <ion-icon :icon="add" /> </ion-fab-button> </ion-fab> </ion-content> </ion-page> </template> <script setup lang="ts"> import { computed, onMounted } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { useTachesStore } from '@/stores/taches'; import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons, IonButton, IonIcon, IonCard, IonCardContent, IonProgressBar, IonGrid, IonRow, IonCol, IonList, IonItem, IonLabel, IonFab, IonFabButton } from '@ionic/vue'; import { logOut, alertCircle, add, checkmarkDone, timeOutline, warning, listOutline } from 'ionicons/icons'; const router = useRouter(); const authStore = useAuthStore(); const tachesStore = useTachesStore(); onMounted(async () => { await tachesStore.charger(); }); const statistiques = computed(() => [ { label: 'Total', valeur: tachesStore.nombreTotal, icone: listOutline, couleur: 'primary', }, { label: 'En cours', valeur: tachesStore.tachesEnCours.length, icone: timeOutline, couleur: 'warning', }, { label: 'Terminées', valeur: tachesStore.tachesTerminees.length, icone: checkmarkDone, couleur: 'success', }, { label: 'Urgentes', valeur: tachesStore.tachesUrgentes.length, icone: warning, couleur: 'danger', }, ]); function formatDate(dateIso: string): string { return new Date(dateIso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long' }); } </script> <style scoped> .progress-header { display: flex; justify-content: space-between; margin-bottom: 8px; color: white; } .stat-card { margin: 4px; height: 100%; } .stat-valeur { font-size: 1.6rem; font-weight: bold; margin: 8px 0 4px; } .stat-label { font-size: 0.8rem; color: var(--ion-color-medium); margin: 0; } .section-title { padding: 0 16px; font-size: 1rem; font-weight: bold; color: var(--ion-color-dark); } </style>
Exercice 1 — Installation et première page
variables.css
ionic serve
Exercice 2 — Transition React vers Vue
Convertissez ce composant React en composant Vue :
// Composant React à convertir import { useState, useEffect } from 'react'; export default function Compteur({ titre, depart = 0 }) { const [valeur, setValeur] = useState(depart); const [historique, setHist] = useState([]); useEffect(() => { document.title = `Compteur : ${valeur}`; }, [valeur]); const incrementer = () => { setValeur(v => v + 1); setHist(h => [...h, `+1 → ${valeur + 1}`]); }; const reinitialiser = () => { setValeur(depart); setHist([]); }; return ( <div> <h1>{titre}</h1> <p>Valeur : {valeur}</p> <button onClick={incrementer}>+1</button> <button onClick={reinitialiser}>Reset</button> <ul> {historique.map((h, i) => <li key={i}>{h}</li>)} </ul> </div> ); }
Adaptez-le avec des composants Ionic (IonButton, IonList, IonItem…).
IonButton
IonList
IonItem
Exercice 3 — Navigation et routing
AccueilPage
ListePage
DetailPage
Exercice 4 — Formulaire complet avec validation
Créez un formulaire d’inscription avec les champs suivants :
Ajoutez : validation en temps réel, messages d’erreur, bouton désactivé si formulaire invalide, toast de confirmation.
Exercice 5 — Connexion à votre backend
Connectez l’application à votre backend Spring Boot ou Symfony :
src/services/api.ts
utilisateurService.ts
trouverTous()
trouverParId()
Exercice 6 — Store Pinia
Créez un store panier.ts pour une application e-commerce :
panier.ts
articles
remise
total
nombreArticles
totalApresRemise
ajouterArticle
supprimerArticle
viderPanier
appliquerRemise
Exercice 7 — Notes vocales / Application de type blog personnel
Créez une application complète avec :
# Créer un projet ionic start NomProjet template --type=vue # Lancer en développement ionic serve ionic serve --lab # Vue iOS + Android côte à côte # Build ionic build # Compiler le code web ionic build --prod # Build de production (optimisé) # Capacitor npx cap add android # Ajouter la plateforme Android npx cap add ios # Ajouter la plateforme iOS npx cap sync # Synchroniser web + plugins natifs npx cap copy # Copier web vers natif (sans sync plugins) npx cap open android # Ouvrir Android Studio npx cap run android # Compiler et lancer sur Android # Diagnostic ionic doctor check # Vérifier l'environnement ionic info # Infos sur la version et l'environnement # Génération de composants (optionnel) ionic generate page NomPage # Créer une page ionic generate component NomComposant # Créer un composant
useState
onChange
v-model
useEffect
onMounted
watch
Avant de générer l'APK, vérifiez : Configuration : ☐ capacitor.config.ts : appId unique (com.entreprise.app) ☐ capacitor.config.ts : appName correct ☐ .env.production : VITE_API_URL pointe vers l'API de production ☐ Icône et splash screen générés (npx capacitor-assets generate) Code : ☐ ionic build --prod réussit sans erreur ☐ npx cap sync exécuté après le build ☐ Tous les console.log de debug supprimés ☐ Gestion des erreurs réseau (mode hors ligne) ☐ Permissions déclarées dans AndroidManifest.xml Tests : ☐ Testé sur émulateur Android API 33 ☐ Testé sur appareil physique Android ☐ Pull-to-refresh fonctionnel ☐ Navigation aller/retour sans bugs ☐ Mode sombre testé Sécurité : ☐ Token JWT stocké dans Capacitor Preferences (pas localStorage) ☐ HTTPS forcé en production ☐ Pas de clés API exposées dans le code