Aller au contenu

Ionic Framework — Cours complet pour développeurs et développeuses React

Créer des applications mobiles cross-platform · Vue.js · Spring Boot · Symfony · Windows


Sommaire


1. Introduction à Ionic

1.1. Qu’est-ce qu’Ionic ?

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.

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.

1.2. Comment ça marche techniquement ?

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
Couche Rôle Analogie React
Ionic UI Composants visuels natifs shadcn/ui ou Material UI
Vue / React Logique, state, routing Ce que vous faites déjà
Capacitor APIs mobiles (caméra, GPS…) react-native modules

1.3. Ionic + Vue ou Ionic + React ?

A savoir : Ionic supporte 3 Frameworks : Angular, React et Vue. Ce cours utilise Vue.js pour deux raisons :

  1. Pédagogique : vous connaissez React, Vue est différent mais plus simple à découvrir. Cela élargit votre palette de compétences et c’est le framework que j’utilise.
  2. Pratique : Vue + Ionic est la combinaison la plus populaire dans les projets Ionic modernes (hors utilisation d’Angular qui est puissant mais demande davantage de compétences).

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.

1.4. Ionic vs React Native vs Flutter

Critère Ionic + Vue React Native Flutter
Langage HTML/CSS/JS + Vue JavaScript/JSX Dart
Rendu WebView + composants natifs Composants natifs Moteur graphique custom
Courbe d’apprentissage Faible (web devs) Moyenne Élevée
Réutilisation code web Maximale Partielle Nulle
Performance Bonne (Capacitor) Excellente Excellente
PWA Natif Non Limité
Adapté si vous savez… Vue / React / Angular React Rien (repart de 0)

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.

1.5. Ce que vous allez construire dans ce cours

À 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.


2. Installation et environnement Windows

2.1. Prérequis — Ce qu’il vous faut

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.

2.2. Vérification de l’environnement

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.

2.3. Installation de l’Ionic CLI

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 :

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

2.4. Extensions VS Code recommandées

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.

2.5. Configuration d’Android Studio

Pour compiler sur Android, Android Studio est nécessaire. Après l’installation :

Étape 1 — Installer le SDK Android

  1. Ouvrir Android Studio
  2. FileSettingsAppearance & BehaviorSystem SettingsAndroid SDK
  3. Cocher Android 14 (API 34) et Android 13 (API 33)
  4. Cliquer Apply → pour lancer le téléchargement

É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

2.6. Diagnostic de l’environnement Ionic

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.


3. Votre première application Ionic + Vue

3.1. Créer le 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 :

Template Description Idéal pour
tabs 3 onglets en bas + pages Apps de gestion, dashboards
sidemenu Menu latéral déroulant Apps avec beaucoup de sections
blank Page vide, partir de zéro Projets personnalisés
list Liste avec détail Catalogues, actualités

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)

3.2. Structure du projet

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.

3.3. Lancer l’application en développement

# 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

3.4. Anatomy d’une page Ionic

Voici un fichier Tab1Page.vue généré par le template :

<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

3.5. Modifier votre première page

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.


4. Vue.js pour développeurs React — La transition

4.1. Vue vs React — Les grandes différences

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 :

4.2. Les fichiers .vue — Single File Components

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.

4.3. Tableau comparatif React ↔ Vue

Concept React Vue 3 (Composition API)
État local const [val, setVal] = useState('') const val = ref('')
Modifier l’état setVal('nouveau') val.value = 'nouveau'
Computed useMemo(() => ...) const x = computed(() => ...)
Effet de bord useEffect(() => {}, [dep]) watch(dep, () => {})
Au montage useEffect(() => {}, []) onMounted(() => {})
Props function Comp({ nom }) {} const props = defineProps(['nom'])
Événements onClick={handler} @click="handler"
Condition {condition && <div/>} <div v-if="condition"/>
Boucle items.map(i => <li key={i.id}/>) <li v-for="i in items" :key="i.id"/>
Binding attr <input value={val}/> <input :value="val"/>
Two-way binding onChange={e => setVal(e.target.value)} <input v-model="val"/>

4.4. Les refs — L’équivalent de useState

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

4.5. reactive() — Pour les objets complexes

<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.

4.6. Les props et les événements

<!-- 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>

4.7. Les directives Vue — v-if, v-for, v-model

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

5. Composants Ionic essentiels

5.1. Philosophie des composants Ionic

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é

5.2. IonButton — Boutons

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

5.3. IonList et IonItem — Listes

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>

5.4. IonCard — Cartes

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

5.5. IonModal et IonAlert — Popups

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

5.6. IonToast — Notifications légères

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

5.7. IonRefresher — Pull-to-refresh

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

5.8. IonInfiniteScroll — Chargement infini

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

6. Navigation et routing

6.1. Vue Router dans Ionic

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;

6.2. Naviguer entre les pages

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

6.3. IonTabs — Navigation par onglets

<!-- 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>

6.4. Transitions et animations

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>

6.5. Guards de navigation — Protection des routes

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
  }
});

7. Formulaires et validation

7.1. Formulaire de connexion complet

<!-- 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>

7.2. Formulaire de création avec sélecteurs

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

8. Connexion aux backends — Spring Boot et Symfony

8.1. Configuration d’Axios

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

8.2. Services — Couche d’abstraction des API calls

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;
  },
};

8.3. Consommer l’API dans un composant Vue

<!-- 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>

8.4. Backend Spring Boot — Configuration CORS

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);
            }
        };
    }
}

8.5. Backend Symfony — Configuration CORS

# 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

8.6. Gestion de l’authentification JWT

// 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;
  },
};

9. État global avec Pinia

9.1. Pinia — L’équivalent de Redux/Zustand pour Vue

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

9.2. Store d’authentification

// 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,
  };
});

9.3. Store des tâches

// 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,
  };
});

9.4. Utilisation des stores dans les composants

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

10. Fonctionnalités natives — Capacitor

10.1. Qu’est-ce que Capacitor ?

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.

10.2. Plugins disponibles

# 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

10.3. La caméra

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

10.4. La géolocalisation

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

10.5. Les préférences (stockage persistant)

// 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 });
  },
};

10.6. État de la connexion réseau

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

11. Thèmes, styles et responsive

11.1. Variables CSS Ionic

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 */

/*  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;
  }
}

11.2. Classes utilitaires Ionic

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>

11.3. Grid responsive

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

11.4. Mode sombre — Contrôle manuel

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

12. Déploiement — Android et iOS

12.1. Compiler l’application

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

12.2. Lancer sur un émulateur Android

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

  1. Créer un émulateur : ToolsDevice ManagerCreate Device
  2. Choisir un téléphone (ex : Pixel 7) et une version Android (API 33)
  3. Lancer l’émulateur
  4. Appuyer sur la touche ▶ (Run)

12.3. Tester sur un vrai téléphone Android

# 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

12.4. Générer un APK de distribution

# 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

12.5. Variables de configuration Capacitor

// 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;

12.6. Icône et splash screen

# 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

13. Projet fil rouge — Application mobile bancaire

13.1. Présentation — TaskManager Pro

L’application TaskManager Pro illustre tous les concepts du cours dans un projet cohérent :

Fonctionnalité Pattern utilisé Chapitre
Authentification JWT Service + Store Pinia 8, 9
Liste de tâches + CRUD Composants Ionic + DAO 5, 8
Filtres et recherche Computed + v-model 4, 5
Navigation par onglets IonTabs + Vue Router 6
Formulaire de création v-model + validation 7
Pull-to-refresh IonRefresher 5
Mode hors ligne Network + Preferences 10
Photo de profil Camera 10
Thème sombre Variables CSS 11
Déploiement Android Capacitor build 12

13.2. Structure complète du projet

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

13.3. Page d’accueil — Dashboard

<!-- 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>

14. Exercices d’application

14.1. Exercices guidés

Exercice 1 — Installation et première page

  1. Créez un nouveau projet Ionic Vue avec le template blank.
  2. Ajoutez une page d’accueil avec votre nom, une courte description et un bouton.
  3. Connectez le bouton à une fonction qui change le texte d’un paragraphe.
  4. Ajoutez deux couleurs personnalisées dans variables.css.
  5. Lancez l’application avec ionic serve et vérifiez le résultat.

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…).

Exercice 3 — Navigation et routing

  1. Créez 3 pages : AccueilPage, ListePage, DetailPage.
  2. Configurez Vue Router pour naviguer entre ces pages.
  3. Sur ListePage, affichez une liste de 5 éléments cliquables.
  4. En cliquant sur un élément, naviguez vers DetailPage en passant l’ID en paramètre.
  5. Sur DetailPage, affichez l’ID reçu et un bouton “Retour”.

14.2. Exercices intermédiaires

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 :

  1. Créez le fichier src/services/api.ts avec la configuration Axios.
  2. Créez un service utilisateurService.ts avec trouverTous() et trouverParId().
  3. Créez une page qui liste les utilisateurs depuis l’API.
  4. Gérez les états : chargement, erreur, liste vide.
  5. Implémentez le pull-to-refresh.

Exercice 6 — Store Pinia

Créez un store panier.ts pour une application e-commerce :

  1. State : articles (tableau), remise (pourcentage)
  2. Getters : total, nombreArticles, totalApresRemise
  3. Actions : ajouterArticle, supprimerArticle, viderPanier, appliquerRemise
  4. Persistez le panier dans Capacitor Preferences.
  5. Créez une page panier qui utilise le store et affiche tous les getters.

14.3. Exercice final — Application complète

Exercice 7 — Notes vocales / Application de type blog personnel

Créez une application complète avec :

  1. Authentification : écran de login connecté à votre backend, avec stockage JWT.
  2. Liste : liste des notes avec titre, date, extrait du contenu.
  3. Création : formulaire de création avec titre (obligatoire), contenu (textarea), catégorie (select), image optionnelle (via Camera Capacitor).
  4. Détail : page de détail avec possibilité de modifier et supprimer.
  5. Recherche : barre de recherche filtrante dans la liste.
  6. Profil : page profil avec photo (Camera), nom, email, bouton déconnexion.
  7. Thème : bouton pour basculer entre mode clair et sombre.
  8. Déploiement : générez un APK Android fonctionnel.

Annexe — Commandes, outils et ressources

Commandes Ionic CLI essentielles

# 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

Comparatif final React ↔ Vue ↔ Ionic

Concept React + React Native Vue + Ionic
État local useState ref(), reactive()
État global Redux, Zustand, Jotai Pinia
Routing React Router Vue Router
Formulaires Contrôlé (onChange) v-model (two-way)
Cycle de vie useEffect onMounted, watch
HTTP Axios, fetch Axios (même lib !)
Mobile natif React Native Capacitor
UI mobile React Native Paper Ionic Components
Build Android Gradle (via Expo) Gradle (via Capacitor)

Ressources officielles

Ressource URL
Documentation Ionic https://ionicframework.com/docs
Documentation Vue 3 https://vuejs.org/guide
Documentation Capacitor https://capacitorjs.com/docs
Documentation Pinia https://pinia.vuejs.org
Ionic Components https://ionicframework.com/docs/components
Ionicons (icônes) https://ionic.io/ionicons
Spring Boot CORS https://spring.io/guides/gs/rest-service-cors
Symfony API Platform https://api-platform.com

Checklist avant déploiement Android

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