---
title: "Symfony UX Native : Transformez votre app web en application mobile native"
excerpt: "Et si vous pouviez transformer votre application Symfony en app mobile native, sans réécrire une seule ligne de code ? Avec UX Native et Hotwire Native, c'est possible. On construit ensemble un Carnet de Notes et on le prépare pour iOS et Android, pas à pas."
publishDate: 2026-03-22T00:00:00.000Z
tags: ["symfony", "ux-native", "hotwire", "hotwire-native", "mobile", "native", "ios", "android", "turbo", "stimulus", "php", "tutorial"]
canonical: "https://yoandev.co/symfony-ux-native"
---

Et si vous pouviez transformer votre application Symfony en **app mobile native**, sans réécrire une seule ligne de code ?

Aujourd'hui, pour proposer une version mobile d'une app web, les options classiques sont lourdes : réécrire en Swift/Kotlin, maintenir une codebase React Native en parallèle, ou se contenter d'un PWA aux possibilités limitées.

**[Symfony UX Native](https://ux.symfony.com/native)** change la donne. Ce bundle **expérimental** (introduit dans Symfony UX 2.33) intègre **[Hotwire Native](https://native.hotwired.dev/)** (par 37signals, les créateurs de Basecamp et HEY) dans l'écosystème Symfony. Le principe : votre app web existante est **encapsulée dans un shell natif** iOS/Android. L'utilisateur bénéficie de barres de titre natives, de transitions fluides et de gestes natifs, tandis que vous conservez une **seule codebase web**.

Dans cet article, on va construire ensemble **NotePad**, un carnet de notes simple avec Symfony. Puis on va le **préparer pour le mobile** avec UX Native : détection des requêtes natives et configuration des comportements par URL (modale, pull-to-refresh...).

> **Important** : UX Native est marqué comme **expérimental**. L'API peut changer entre les versions. Ce tutoriel est basé sur Symfony 8.0 et UX Native 2.33+.

---

## Comment ça marche ?

### L'architecture Hotwire Native

Hotwire Native fonctionne avec une **WebView** embarquée dans un shell natif iOS (Swift) ou Android (Kotlin). L'app native charge les pages HTML de votre serveur Symfony et fournit automatiquement :

- **Navigation native** entre les écrans
- **Transitions animées** fluides
- **Barre de titre native** automatique
- **Gestes natifs** (swipe back, pull-to-refresh...)

Contrairement à React Native ou Flutter, **pas de JavaScript framework côté client** pour le rendu : c'est votre HTML serveur qui s'affiche. Turbo (déjà dans Symfony UX) gère les navigations sans rechargement complet.

### Ce qu'apporte UX Native côté Symfony

Le bundle ajoute deux fonctionnalités côté serveur :

| Fonctionnalité | Description |
|---|---|
| **Détection native** | Détecte automatiquement si la requête vient d'une app Hotwire Native (via le `User-Agent`) |
| **Configuration des chemins** | Définit en PHP comment chaque URL se comporte dans l'app native (modale, push, pull-to-refresh...) |

```mermaid
sequenceDiagram
    participant App as App Native (iOS/Android)
    participant WV as WebView
    participant SF as Serveur Symfony

    App->>WV: Charge l'URL /notes
    WV->>SF: GET /notes (User-Agent: Hotwire Native)
    SF->>SF: NativeListener détecte le User-Agent
    SF->>WV: HTML sans navbar web
    WV->>App: Rendu dans le shell natif
    App->>App: Ajoute barre de titre + transitions natives
```

> **Note** : la majeure partie de ce tutoriel se concentre sur la partie **serveur** (Symfony). Pour tester la détection native depuis un navigateur, on utilisera la modification du User-Agent dans les DevTools. En bonus (étape 7), on construira un **vrai APK Android** pour voir le résultat dans un émulateur, sans Android Studio.

---

## Pré-requis

Avant de commencer, assurez-vous d'avoir :

- **PHP 8.4+** installé
- **Composer** installé globalement
- La **CLI Symfony** installée ([symfony.com/download](https://symfony.com/download))
- **SQLite** (extension PHP `pdo_sqlite`)

---

## Étape 1 : Créer le projet Symfony

Les étapes 1 à 3 consistent à créer une **application web Symfony classique**. Si vous avez déjà un projet existant, vous pouvez passer directement à l'**étape 4**, c'est là que commence la partie UX Native. On construit ici un mini projet "NotePad" volontairement simple pour que la demo reste focalisée sur l'intégration native.

On part d'un projet Symfony vierge avec le pack webapp (qui inclut Twig, Doctrine, Turbo, Stimulus...) :

```bash
symfony new notepad --webapp
```

Entrez dans le projet :

```bash
cd notepad
```

### Configurer SQLite

Pour simplifier, on utilise SQLite au lieu de PostgreSQL. Modifiez la variable `DATABASE_URL` dans le fichier `.env` :

```bash
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
```

Vérifiez que tout fonctionne :

```bash
symfony server:start -d
```

Ouvrez votre navigateur sur `https://127.0.0.1:8000`. Vous devriez voir la page d'accueil Symfony par défaut.

> **Astuce** : avec SQLite, aucun serveur de base de données à installer ou configurer.

---

## Étape 2 : Créer l'entité Note

On crée une entité simple avec un titre, un contenu et une date de création :

```bash
php bin/console make:entity Note
```

Répondez aux questions du maker :

- `title` : `string`, `255`, not nullable
- `content` : `text`, not nullable
- `createdAt` : `datetime_immutable`, not nullable

<details>
<summary>Entité complète (cliquez pour déplier)</summary>

```php
<?php

namespace App\Entity;

use App\Repository\NoteRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: NoteRepository::class)]
class Note
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT)]
    private ?string $content = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;
        return $this;
    }

    public function getContent(): ?string
    {
        return $this->content;
    }

    public function setContent(string $content): static
    {
        $this->content = $content;
        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeImmutable $createdAt): static
    {
        $this->createdAt = $createdAt;
        return $this;
    }
}
```

</details>

Créez et exécutez la migration :

```bash
php bin/console make:migration
php bin/console doctrine:migrations:migrate -n
```

---

## Étape 3 : Créer le contrôleur et les templates

### Le contrôleur

Créez le fichier `src/Controller/NoteController.php` :

```php
<?php

namespace App\Controller;

use App\Entity\Note;
use App\Repository\NoteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/notes')]
class NoteController extends AbstractController
{
    #[Route('', name: 'note_index', methods: ['GET'])]
    public function index(NoteRepository $repository): Response
    {
        return $this->render('note/index.html.twig', [
            'notes' => $repository->findBy([], ['createdAt' => 'DESC']),
        ]);
    }

    #[Route('/new', name: 'note_new', methods: ['GET', 'POST'], priority: 1)]
    public function new(Request $request, EntityManagerInterface $em): Response
    {
        if ($request->isMethod('POST')) {
            $note = new Note();
            $note->setTitle($request->request->get('title'));
            $note->setContent($request->request->get('content'));
            $em->persist($note);
            $em->flush();

            return $this->redirectToRoute('note_index');
        }

        return $this->render('note/new.html.twig');
    }

    #[Route('/{id}', name: 'note_show', methods: ['GET'])]
    public function show(Note $note): Response
    {
        return $this->render('note/show.html.twig', [
            'note' => $note,
        ]);
    }
}
```

> **Note** : la route `/notes/new` a `priority: 1` pour être matchée avant `/notes/{id}`.

### Le template de base

Mettez à jour `templates/base.html.twig` :

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}NotePad{% endblock %}</title>
    {% block stylesheets %}{% endblock %}
    {% block javascripts %}
        {% block importmap %}{{ importmap('app') }}{% endblock %}
    {% endblock %}
</head>
<body>
    <nav class="navbar">
        <a href="{{ path('note_index') }}">NotePad</a>
    </nav>

    <main class="container">
        {% block body %}{% endblock %}
    </main>
</body>
</html>
```

### Les templates de notes

Créez le dossier `templates/note/` puis les trois fichiers suivants.

**`templates/note/index.html.twig`**, liste des notes :

```twig
{% extends 'base.html.twig' %}

{% block title %}Mes Notes{% endblock %}

{% block body %}
    <h1>Mes Notes</h1>
    <a href="{{ path('note_new') }}" class="btn-new">+ Nouvelle note</a>

    <div class="notes-list">
        {% for note in notes %}
            <article class="note-card">
                <h2><a href="{{ path('note_show', {id: note.id}) }}">{{ note.title }}</a></h2>
                <p>{{ note.content|u.truncate(100) }}</p>
                <time datetime="{{ note.createdAt|date('Y-m-d') }}">
                    {{ note.createdAt|date('d/m/Y à H:i') }}
                </time>
            </article>
        {% else %}
            <p class="empty">Aucune note pour le moment. Créez-en une !</p>
        {% endfor %}
    </div>
{% endblock %}
```

**`templates/note/show.html.twig`**, détail d'une note :

```twig
{% extends 'base.html.twig' %}

{% block title %}{{ note.title }}{% endblock %}

{% block body %}
    <article class="note-detail">
        <h1>{{ note.title }}</h1>
        <time datetime="{{ note.createdAt|date('Y-m-d') }}">
            {{ note.createdAt|date('d/m/Y à H:i') }}
        </time>
        <div class="note-content">
            {{ note.content|nl2br }}
        </div>
    </article>

    <a href="{{ path('note_index') }}" class="back-link">&larr; Retour aux notes</a>
{% endblock %}
```

**`templates/note/new.html.twig`**, formulaire de création :

```twig
{% extends 'base.html.twig' %}

{% block title %}Nouvelle Note{% endblock %}

{% block body %}
    <h1>Nouvelle Note</h1>

    <form method="POST" action="{{ path('note_new') }}" class="note-form">
        <div class="form-group">
            <label for="title">Titre</label>
            <input type="text" id="title" name="title" required>
        </div>
        <div class="form-group">
            <label for="content">Contenu</label>
            <textarea id="content" name="content" rows="8" required></textarea>
        </div>
        <button type="submit" class="btn-submit">Enregistrer</button>
    </form>

    <a href="{{ path('note_index') }}" class="back-link">&larr; Annuler</a>
{% endblock %}
```

### Installer twig/string-extra

Le filtre `|u.truncate()` utilisé dans le template de liste nécessite un package supplémentaire, non inclus par défaut :

```bash
composer require twig/string-extra
```

### Un peu de style

Remplacez le contenu de `assets/styles/app.css` :

<details>
<summary>Code CSS complet (cliquez pour déplier)</summary>

```css
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #f5f5f7;
    color: #1d1d1f;
    line-height: 1.6;
}

.navbar {
    background: #1d1d1f;
    padding: 1rem 2rem;
}

.navbar a {
    color: #fff;
    text-decoration: none;
    font-size: 1.2rem;
    font-weight: 600;
}

.container {
    max-width: 700px;
    margin: 2rem auto;
    padding: 0 1rem;
}

h1 {
    margin-bottom: 1rem;
}

.btn-new {
    display: inline-block;
    background: #0071e3;
    color: #fff;
    padding: 0.6rem 1.2rem;
    border-radius: 8px;
    text-decoration: none;
    margin-bottom: 1.5rem;
}

.note-card {
    background: #fff;
    border-radius: 12px;
    padding: 1.2rem;
    margin-bottom: 1rem;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

.note-card h2 { font-size: 1.1rem; margin-bottom: 0.4rem; }
.note-card h2 a { color: #1d1d1f; text-decoration: none; }
.note-card p { color: #6e6e73; font-size: 0.95rem; }
.note-card time { color: #86868b; font-size: 0.8rem; }

.note-detail time { display: block; color: #86868b; margin-bottom: 1.5rem; }
.note-content { font-size: 1.05rem; line-height: 1.8; }

.form-group { margin-bottom: 1.2rem; }
.form-group label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
.form-group input,
.form-group textarea {
    width: 100%;
    padding: 0.7rem;
    border: 1px solid #d2d2d7;
    border-radius: 8px;
    font-size: 1rem;
    font-family: inherit;
}

.btn-submit {
    background: #0071e3;
    color: #fff;
    border: none;
    padding: 0.7rem 2rem;
    border-radius: 8px;
    font-size: 1rem;
    cursor: pointer;
}

.back-link {
    display: inline-block;
    margin-top: 1.5rem;
    color: #0071e3;
    text-decoration: none;
}

.empty { color: #86868b; font-style: italic; }
```

</details>

Lancez le serveur et naviguez sur `https://127.0.0.1:8000/notes`. Créez quelques notes pour avoir des données de test.

---

## Étape 4 : Installer UX Native

À ce stade, on a une application Symfony classique qui fonctionne dans un navigateur. **NotePad ne sait rien du mobile.** C'est maintenant qu'on va la préparer pour qu'elle puisse vivre dans une app native, sans toucher au code métier.

Le rôle de UX Native n'est pas de transformer votre app en app mobile. C'est de **fournir les outils côté serveur** pour que votre app web coopère intelligemment avec un shell natif Hotwire Native. Concrètement, le bundle va nous permettre de :

1. **Détecter** si la requête vient d'une app native ou d'un navigateur (étape 5)
2. **Générer un fichier JSON** qui dit à l'app native comment se comporter sur chaque URL (étape 6)

Installez le bundle :

```bash
composer require symfony/ux-native
```

> Avec AssetMapper (inclus dans le pack `--webapp`), aucune étape supplémentaire côté JavaScript n'est nécessaire. Si vous utilisez Webpack Encore, lancez `npm install --force && npm run watch`.

---

## Étape 5 : Détecter les requêtes natives

### Pourquoi détecter ?

Quand votre app tourne dans un shell natif, certains éléments de l'interface web deviennent **redondants**. Par exemple, l'app native a sa propre barre de navigation avec un titre et un bouton retour. Afficher en plus votre `<nav>` HTML ferait doublon. À l'inverse, certains éléments n'ont de sens **que** dans le contexte natif : un bouton "Partager" qui déclenche la feuille de partage iOS/Android n'a pas d'utilité dans un navigateur classique.

La détection native permet de **servir un HTML adapté au contexte** : le même contrôleur, le même template, mais avec des blocs conditionnels.

### Comment ça marche ?

UX Native fournit une fonction Twig **`ux_is_native()`** qui détecte automatiquement si la requête vient d'une app Hotwire Native. Sous le capot, le bundle enregistre un `NativeListener` qui inspecte le header `User-Agent` : si celui-ci contient la chaîne `Hotwire Native` (envoyée automatiquement par tous les clients Hotwire Native), il ajoute un attribut `_hotwire_native` à la requête.

### Adapter le layout

Modifiez `templates/base.html.twig` pour **masquer la navbar web** quand l'utilisateur est dans l'app native (l'app native a sa propre barre de navigation) :

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}NotePad{% endblock %}</title>
    {% block stylesheets %}{% endblock %}
    {% block javascripts %}
        {% block importmap %}{{ importmap('app') }}{% endblock %}
    {% endblock %}
</head>
<body>
    {% if not ux_is_native() %}
        <nav class="navbar">
            <a href="{{ path('note_index') }}">NotePad</a>
        </nav>
    {% endif %}

    <main class="container">
        {% block body %}{% endblock %}
    </main>
</body>
</html>
```

C'est aussi simple que ça. Un `{% if %}` et votre UI s'adapte au contexte.

### Tester sans app mobile

Pas besoin d'un iPhone pour tester ! Ouvrez les **DevTools** de votre navigateur (F12), puis :

1. Ouvrez le panneau **Network Conditions** (Chrome) ou **Settings > User Agent** (Firefox)
2. Désactivez "Use browser default" et ajoutez `Hotwire Native` au User-Agent
3. Rechargez la page

La navbar devrait **disparaître**. Remettez le User-Agent par défaut : elle réapparaît.

---

## Étape 6 : Configurer les chemins natifs

### Le concept clé : la Path Configuration

C'est sans doute la partie la plus importante à comprendre. Quand une app Hotwire Native démarre, **elle télécharge un fichier JSON depuis votre serveur**. Ce fichier est un contrat entre votre back-end et l'app mobile : il décrit **comment chaque URL doit se comporter** dans le contexte natif.

Par exemple, ce JSON peut dire :
- *"Toutes les pages s'affichent normalement avec pull-to-refresh activé"*
- *"La page `/notes/new` doit s'ouvrir en **modale** (une feuille qui glisse depuis le bas)"*
- *"La page `/settings` doit remplacer l'écran courant sans animation"*

Sans ce fichier, l'app native affiche toutes les pages de la même façon. Avec, vous **pilotez l'expérience native depuis votre serveur PHP**, sans recompiler l'app mobile.

```mermaid
flowchart LR
    A[App native démarre] --> B[Télécharge /config/android_v1.json]
    B --> C[L'app sait comment se comporter]
    C --> D["/ → navigation classique"]
    C --> E["/notes/new → ouvrir en modale"]
    C --> F["Pull-to-refresh activé partout"]
```

Le gros avantage : **vous pouvez modifier le comportement de l'app native sans publier de mise à jour** sur le Play Store ou l'App Store. Il suffit de changer le JSON côté serveur.

### Écrire la config en PHP plutôt qu'en JSON

Plutôt que de maintenir un fichier JSON à la main, UX Native permet de **définir cette configuration en PHP** avec des attributs. Le bundle se charge de générer le JSON. Vous bénéficiez de l'autocomplétion, du typage, et vous pouvez versionner vos configs proprement.

### Créer le provider de configuration

Créez le fichier `src/Native/AppNativeConfiguration.php` :

```php
<?php

namespace App\Native;

use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;

#[AsNativeConfigurationProvider]
final class AppNativeConfiguration
{
    #[AsNativeConfiguration('/config/ios_v1.json')]
    public function iosV1(): Configuration
    {
        return new Configuration(
            settings: [
                'tabs' => [
                    ['title' => 'Notes', 'url' => '/notes', 'icon' => 'note.text'],
                ],
            ],
            rules: [
                // Règle par défaut : navigation classique avec pull-to-refresh
                new Rule(
                    patterns: ['.*'],
                    properties: [
                        'context' => 'default',
                        'pull_to_refresh_enabled' => true,
                    ],
                ),
                // Le formulaire de création s'ouvre en modale
                new Rule(
                    patterns: ['/notes/new'],
                    properties: [
                        'context' => 'modal',
                        'modal_style' => 'large',
                        'pull_to_refresh_enabled' => false,
                    ],
                ),
            ],
        );
    }

    #[AsNativeConfiguration('/config/android_v1.json')]
    public function androidV1(): Configuration
    {
        return new Configuration(
            settings: [],
            rules: [
                // Règle par défaut
                new Rule(
                    patterns: ['.*'],
                    properties: [
                        'context' => 'default',
                        'pull_to_refresh_enabled' => true,
                        'uri' => 'hotwire://fragment/web',
                    ],
                ),
                // Modale pour le formulaire de création
                new Rule(
                    patterns: ['/notes/new'],
                    properties: [
                        'context' => 'modal',
                        'pull_to_refresh_enabled' => false,
                        'uri' => 'hotwire://fragment/web',
                    ],
                ),
            ],
        );
    }
}
```

Décortiquons ce code :

- **`#[AsNativeConfigurationProvider]`** marque la classe comme fournisseur de configurations natives. Le bundle la détecte automatiquement grâce à l'autoconfiguration Symfony.
- **`#[AsNativeConfiguration('/config/ios_v1.json')]`** associe la méthode au chemin JSON `/config/ios_v1.json`. C'est cette URL que l'app native appellera au démarrage pour récupérer sa configuration.
- **`Configuration`** contient des `settings` (paramètres globaux envoyés à l'app) et des `rules` (règles ordonnées qui dictent le comportement par URL).
- **`Rule`** associe des `patterns` (regex matchant les URLs visitées) à des `properties` (comportements natifs). Par exemple, le pattern `.*` matche toutes les URLs, tandis que `/notes/new` ne matche que le formulaire de création.
- Les règles sont appliquées **séquentiellement** : les dernières écrasent les premières. C'est pour ça qu'on met la règle générique (`.*`) en premier et les exceptions après.
- **`uri`** (Android uniquement) : indique quel type de fragment Android utiliser. `hotwire://fragment/web` est le fragment WebView par défaut fourni par Hotwire Native. C'est la propriété `context` (et non l'URI) qui détermine si la page s'ouvre en navigation normale ou en modale.

> **Pourquoi deux configs (iOS et Android) ?** Parce que les propriétés disponibles diffèrent selon la plateforme. iOS a `modal_style` pour contrôler l'apparence de la modale, Android a `uri` qui est obligatoire. En les séparant, chaque plateforme reçoit exactement ce dont elle a besoin.

### Comprendre les propriétés

Voici les propriétés disponibles pour contrôler le comportement natif :

| Propriété | Valeurs possibles | Défaut | Effet |
|---|---|---|---|
| `context` | `default`, `modal` | `default` | Navigation standard ou ouverture en modale |
| `presentation` | `push`, `pop`, `replace`, `replace_root`, `clear_all`, `refresh`, `none` | `default` | Type de transition entre écrans |
| `pull_to_refresh_enabled` | `true`, `false` | iOS: `true`, Android: `false` | Active le "tirer pour rafraîchir" |
| `animated` | `true`, `false` | `true` | Active/désactive les animations de transition |
| `modal_style` (iOS uniquement) | `large`, `medium`, `full`, `page_sheet`, `form_sheet` | - | Style visuel de la modale |
| `uri` (Android, **requis**) | `hotwire://...` | - | URI de destination pour Android |

### Générer et vérifier les fichiers JSON

Pour que l'app native puisse récupérer cette configuration, il faut **générer les fichiers JSON** dans le dossier `public/` :

```bash
php bin/console ux-native:dump
```

Cette commande transforme vos objets PHP `Configuration` / `Rule` en fichiers JSON statiques : `public/config/ios_v1.json` et `public/config/android_v1.json`. Le serveur web les sert directement, sans passer par le kernel Symfony.

Vérifiez le résultat en accédant à `https://127.0.0.1:8000/config/ios_v1.json` :

```json
{
    "settings": {
        "tabs": [
            {
                "title": "Notes",
                "url": "/notes",
                "icon": "note.text"
            }
        ]
    },
    "rules": [
        {
            "patterns": [".*"],
            "properties": {
                "context": "default",
                "pull_to_refresh_enabled": true
            }
        },
        {
            "patterns": ["/notes/new"],
            "properties": {
                "context": "modal",
                "modal_style": "large",
                "pull_to_refresh_enabled": false
            }
        }
    ]
}
```

C'est exactement le **contrat** entre votre serveur Symfony et l'app native. Quand l'app iOS démarre, elle fait un `GET /config/ios_v1.json`, parse ce JSON, et sait que :
- Par défaut, chaque page s'affiche en navigation classique avec pull-to-refresh
- **Sauf** `/notes/new`, qui s'ouvre en modale style "large" (une feuille qui couvre la majorité de l'écran)

Toute cette logique est **pilotée depuis PHP**. Si demain vous voulez que `/notes/{id}/edit` s'ouvre aussi en modale, il suffit d'ajouter une `Rule` dans votre provider, sans toucher au code de l'app mobile.

> **Important** : pensez à **relancer cette commande** à chaque modification de votre `AppNativeConfiguration.php`, et ajoutez-la à votre pipeline de déploiement (juste après `cache:clear`).

---

## Récapitulatif

Voici tout ce qu'on a mis en place :

| Composant | Rôle |
|-----------|------|
| `symfony/ux-native` | Bundle d'intégration Hotwire Native pour Symfony |
| `ux_is_native()` | Fonction Twig, détecte si la requête vient d'une app native |
| `#[AsNativeConfigurationProvider]` | Attribut, déclare une classe comme fournisseur de config |
| `#[AsNativeConfiguration('/path.json')]` | Attribut, mappe une méthode à un fichier JSON |
| `Configuration` + `Rule` | Objets PHP, définissent les règles de comportement natif |
| `ux-native:dump` | Commande, génère les fichiers JSON statiques dans `public/` |

| Fichier créé | Rôle |
|---------|------|
| `src/Native/AppNativeConfiguration.php` | Configurations des chemins iOS et Android |
| `public/config/ios_v1.json` | Configuration iOS (générée par `ux-native:dump`) |
| `public/config/android_v1.json` | Configuration Android (générée par `ux-native:dump`) |

---

## Étape 7 (Bonus) : Builder un APK Android

On a préparé le côté serveur. Mais à quoi ça ressemble **concrètement** dans une app native ? Bonne nouvelle : le shell Android se résume à **3 fichiers Kotlin/XML** et une vingtaine de lignes de code.

Voici ce que fait le shell Android, en résumé :

| Fichier | Rôle | Équivalent Symfony |
|---|---|---|
| `AndroidManifest.xml` | Déclare l'app et ses permissions | `config/services.yaml` |
| `MainActivity.kt` | Point d'entrée, indique l'URL de départ | Un contrôleur avec une route |
| `NotepadApplication.kt` | Charge le JSON de path configuration | Consomme ce que `ux-native:dump` a généré |
| `activity_main.xml` | Layout avec la WebView Hotwire | Le "template" de l'app |
| Fichiers Gradle | Dépendances et build | `composer.json` |

Vous n'avez pas besoin de comprendre le Kotlin en détail, le code est volontairement minimal et les commentaires expliquent chaque ligne.

### Pré-requis

Pas besoin d'Android Studio (15 Go). Les **command-line tools** suffisent (~2 Go) :

```bash
# Installer les outils (macOS)
brew install --cask android-commandlinetools
brew install --cask temurin   # Java (nécessite sudo)
brew install gradle

# Installer le SDK Android
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
yes | sdkmanager --sdk_root="$ANDROID_HOME" \
  "platform-tools" \
  "platforms;android-35" \
  "build-tools;34.0.0" \
  "emulator" \
  "system-images;android-34;google_apis;arm64-v8a"

# Créer un émulateur
echo "no" | avdmanager create avd -n notepad_test \
  -k "system-images;android-34;google_apis;arm64-v8a" --force
```

### Structure du projet Android

Créez un dossier `android-shell/` à la racine de votre projet Symfony :

```
android-shell/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties
└── app/
    ├── build.gradle.kts
    └── src/main/
        ├── AndroidManifest.xml
        ├── java/com/notepad/
        │   ├── MainActivity.kt
        │   └── NotepadApplication.kt
        └── res/
            ├── layout/activity_main.xml
            └── values/styles.xml
```

### Les fichiers Gradle

**Gradle** est l'outil de build du monde Android, l'équivalent de **Composer** pour PHP. Il gère les dépendances (ici Hotwire Native, AndroidX...), compile le code Kotlin, et produit l'APK installable. Les fichiers `.gradle.kts` sont ses fichiers de configuration, écrits en Kotlin.

**`settings.gradle.kts`** :

```kotlin
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "NotePad"
include(":app")
```

**`build.gradle.kts`** (racine) :

```kotlin
plugins {
    id("com.android.application") version "8.8.2" apply false
    id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}
```

**`gradle.properties`** :

```properties
android.useAndroidX=true
```

**`app/build.gradle.kts`** :

```kotlin
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.notepad"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.notepad"
        minSdk = 28
        targetSdk = 35
        versionCode = 1
        versionName = "1.0"
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {
    implementation("dev.hotwire:core:1.2.0")
    implementation("dev.hotwire:navigation-fragments:1.2.0")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("com.google.android.material:material:1.12.0")
}
```

Les deux dépendances clés sont `dev.hotwire:core` (le moteur Hotwire Native) et `dev.hotwire:navigation-fragments` (la navigation automatique entre écrans).

### Le code Kotlin (3 fichiers)

**`AndroidManifest.xml`** :

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".NotepadApplication"
        android:allowBackup="true"
        android:label="NotePad"
        android:theme="@style/Theme.NotePad"
        android:usesCleartextTraffic="true">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>
</manifest>
```

> **Note** : `usesCleartextTraffic="true"` autorise le HTTP non chiffré, nécessaire pour le développement local. En production, vous utiliserez HTTPS.

**`MainActivity.kt`**, le coeur de l'app, **10 lignes utiles** :

```kotlin
package com.notepad

import android.os.Bundle
import dev.hotwire.navigation.activities.HotwireActivity
import dev.hotwire.navigation.navigator.NavigatorConfiguration

class MainActivity : HotwireActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun navigatorConfigurations() = listOf(
        NavigatorConfiguration(
            name = "main",
            startLocation = "http://10.0.2.2:8000/notes",
            navigatorHostId = R.id.main_nav_host
        )
    )
}
```

Deux choses à noter :
- **`10.0.2.2`** est l'alias Android pour `127.0.0.1` de la machine hôte (votre Mac). En production, remplacez par l'URL de votre serveur.
- **`navigatorConfigurations()`** est la seule méthode abstraite à implémenter : elle indique où démarrer et quel conteneur de navigation utiliser.

**`NotepadApplication.kt`**, charge la path configuration depuis votre serveur Symfony :

```kotlin
package com.notepad

import android.app.Application
import dev.hotwire.core.config.Hotwire
import dev.hotwire.core.turbo.config.PathConfiguration

class NotepadApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        Hotwire.loadPathConfiguration(
            context = this,
            location = PathConfiguration.Location(
                remoteFileUrl = "http://10.0.2.2:8000/config/android_v1.json"
            )
        )
    }
}
```

C'est ici que le lien se fait avec votre configuration Symfony (étape 6). L'app télécharge le JSON généré par `ux-native:dump` et l'utilise pour savoir comment se comporter sur chaque URL.

### Le layout et le style

**`res/layout/activity_main.xml`** :

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_nav_host"
    android:name="dev.hotwire.navigation.navigator.NavigatorHost"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="false" />
```

Ce layout contient un seul élément : le `NavigatorHost` de Hotwire Native, qui gère automatiquement la WebView et la navigation entre écrans.

**`res/values/styles.xml`** :

```xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Theme.NotePad" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <item name="colorPrimary">#0071E3</item>
        <item name="colorPrimaryVariant">#0062CC</item>
        <item name="colorOnPrimary">#FFFFFF</item>
    </style>
</resources>
```

### Builder et lancer

Avant tout, lancez votre serveur Symfony **sans HTTPS** (la WebView Android ne fait pas confiance aux certificats auto-signés de développement) :

```bash
symfony server:start -d --no-tls
```

Puis buildez et lancez l'app Android :

```bash
# Générer le Gradle wrapper
cd android-shell
gradle wrapper --gradle-version 8.12.1

# Builder l'APK (utiliser Java 17 pour la compatibilité)
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
./gradlew assembleDebug
```

L'APK est généré dans `app/build/outputs/apk/debug/app-debug.apk`.

```bash
# Lancer l'émulateur (dans un terminal séparé)
$ANDROID_HOME/emulator/emulator -avd notepad_test

# Installer et lancer l'app
$ANDROID_HOME/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
$ANDROID_HOME/platform-tools/adb shell am start -n com.notepad/.MainActivity
```

Votre app NotePad s'ouvre dans l'émulateur Android avec :
- **Barre de titre Android native** (plus de navbar HTML)
- **Navigation native** entre les écrans (transitions animées)
- **Pull-to-refresh** sur la liste des notes
- **Formulaire en modale** quand vous cliquez sur "+ Nouvelle note"
- **Swipe back** pour revenir en arrière

Le tout en chargeant le HTML de votre serveur Symfony. **Zéro réécriture de votre code PHP/Twig.**

### Et pour iOS ?

Le principe est identique. Voici un **aperçu** du code Swift minimal (non testé dans ce tutoriel) utilisant le SDK [Hotwire Native iOS](https://github.com/hotwired/hotwire-native-ios). Le shell iOS se résume aussi à quelques dizaines de lignes :

```swift
import HotwireNative
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private let navigator = Navigator()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        window = UIWindow(windowScene: windowScene)

        // Charge la path configuration depuis le serveur Symfony
        Hotwire.loadPathConfiguration(from: [
            .server(URL(string: "https://votre-serveur.com/config/ios_v1.json")!)
        ])

        // Démarre sur la liste des notes
        navigator.route(URL(string: "https://votre-serveur.com/notes")!)
        window?.rootViewController = navigator.rootViewController
        window?.makeKeyAndVisible()
    }
}
```

> **Note** : builder une app iOS nécessite un Mac avec **Xcode** installé (gratuit, ~25 Go). Le simulateur iOS est inclus dans Xcode. Un compte Apple Developer payant (99$/an) n'est nécessaire que pour publier sur l'App Store.

---

## Pour aller plus loin

Quelques pistes pour enrichir votre app :

- **Versioning des configs** : créez plusieurs configurations (`ios_v1`, `ios_v2`...) pour gérer les mises à jour de l'app native sans casser les anciennes versions en circulation
- **Settings dynamiques** : utilisez la section `settings` pour passer des feature flags ou des URLs de services à l'app native
- **Bridge avancé** : explorez `BridgeComponent` pour accéder à d'autres APIs natives (caméra, notifications push, biométrie, haptics...)
- **Service tags** : pour les cas complexes, vous pouvez aussi définir vos configurations via des tags de service YAML au lieu des attributs PHP

---

## Conclusion

Avec **Symfony UX Native**, transformer une app web Symfony en application mobile native devient accessible :

- **Zéro réécriture** : votre code Symfony existant sert de base, votre HTML est rendu directement dans l'app native
- **Adaptation intelligente** : `ux_is_native()` ajuste l'UI au contexte (web vs. native) en une ligne
- **Configuration déclarative** : les comportements natifs (modale, pull-to-refresh, transitions) se définissent en PHP avec des attributs

Le tout avec un bundle expérimental mais prometteur, porté par la philosophie Hotwire : **une seule codebase web, une expérience native**.

> **Ressources** :
> - [Documentation Symfony UX Native](https://symfony.com/bundles/ux-native/current/index.html)
> - [Page UX Native](https://ux.symfony.com/native)
> - [Hotwire Native](https://native.hotwired.dev/)
> - [Hotwire Native, Path Configuration](https://native.hotwired.dev/reference/path-configuration)
> - [GitHub symfony/ux-native](https://github.com/symfony/ux-native)
> - [StimulusBundle](https://symfony.com/bundles/StimulusBundle/current/index.html)
> - [Hotwire Native iOS](https://github.com/hotwired/hotwire-native-ios)
> - [Hotwire Native Android](https://github.com/hotwired/hotwire-native-android)
