diff --git a/.github/workflows/now-golden-baselines.yml b/.github/workflows/now-golden-baselines.yml index 5bd7daa78..700c20690 100644 --- a/.github/workflows/now-golden-baselines.yml +++ b/.github/workflows/now-golden-baselines.yml @@ -1,23 +1,25 @@ name: Now Presentation Goldens on: - push: - branches: - - feat/now-presentation-media-mature + pull_request: paths: - - "mobile/**" + - "mobile/lib/features/now/**" + - "mobile/lib/presentation/contracts/now_presentation.dart" + - "mobile/lib/dev/scenarios/now_scenarios.dart" + - "mobile/test/now_golden_test.dart" + - "mobile/test/goldens/now/**" - ".github/workflows/now-golden-baselines.yml" + workflow_dispatch: permissions: - contents: write + contents: read concurrency: - group: now-goldens-feature-505 - cancel-in-progress: false + group: now-goldens-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - golden-baselines: - if: github.ref == 'refs/heads/feat/now-presentation-media-mature' + golden-regression: runs-on: ubuntu-latest timeout-minutes: 25 defaults: @@ -25,41 +27,31 @@ jobs: working-directory: mobile steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - fetch-depth: 0 - - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: flutter-version: "3.47.3" channel: stable cache: true - - name: Resolve dependencies run: flutter pub get - - - name: Format feature Dart sources - run: bash tool/dart_format.sh write - - - name: Generate Dart sources + - name: Generate owner DTO and Drift sources run: dart run build_runner build --delete-conflicting-outputs - - - name: Regenerate G01 S1-S5 baselines from production Now + - name: Verify generated-code policy + run: git diff --exit-code -- lib test + - name: Canonical format check + run: bash tool/dart_format.sh check + - name: Verify approved G01 S1-S5 references + run: flutter test test/now_golden_test.dart + - name: Render review artifacts without changing the branch + if: always() run: flutter test --update-goldens test/now_golden_test.dart - - - name: Commit source formatting and baseline PNGs on feature branch only - working-directory: . - run: | - set -euo pipefail - test "$GITHUB_REF" = "refs/heads/feat/now-presentation-media-mature" - git add mobile/lib mobile/test - if git diff --cached --quiet; then - echo "No format or baseline changes" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "Format Now and establish G01 S1-S5 reference goldens" - # Preserve newer reviewed feature commits; never force-push them away. - git fetch origin feat/now-presentation-media-mature - git rebase origin/feat/now-presentation-media-mature - git push origin HEAD:feat/now-presentation-media-mature + - name: Publish canonical renderings and differences + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: makolo-now-golden-review + path: | + mobile/test/goldens/now/*.png + mobile/test/failures/*.png + if-no-files-found: error + retention-days: 7 diff --git a/ANTIGRAVITY_HANDOFF_MAKOLO.md b/ANTIGRAVITY_HANDOFF_MAKOLO.md new file mode 100644 index 000000000..1769c191e --- /dev/null +++ b/ANTIGRAVITY_HANDOFF_MAKOLO.md @@ -0,0 +1,189 @@ +# Makolo Mature Mobile Experience Implementation — Antigravity to Codex Handoff + +**Date :** 12 octobre 2026 +**Document :** `ANTIGRAVITY_HANDOFF_MAKOLO.md` +**Contexte de transfert :** Suspension du chantier Antigravity et transfert vers Codex/agent suivant. + +--- + +## 1. Branche locale et SHA HEAD + +- **Branche locale :** `feature/mature-mobile-composition` +- **Base de départ :** `main@f2351b8b` (`f2351b8b5db1ce362b7ddc6ca2951d9d79ce2538`) +- **HEAD SHA actuel :** `0366d81203ee06937427f055d4d31f905172f849` +- **État de synchronisation distante :** Aucun push vers GitHub n'a été effectué conformément à la règle stricte (*AUCUN PUSH INTERMÉDIAIRE*). + +--- + +## 2. Commits créés sur la branche (ordre chronologique) + +Neuf (9) commits locaux ont été validés avec tests ciblés et vérification du formateur Dart : + +1. **`02dc474f`** — `feat(mobile): elevate Now secondary situations and Ongoing continuity dimensions` + - `mobile/lib/features/now/now_screen.dart` (+55 lignes) : enrichissement de `_NowSecondarySituation` avec `whyNow`, signaux de tour S4 (`turnLabel`/`showTurnCue`) et labels d'action S3 (`responseLabel`/`showActionCue`). + - `mobile/lib/features/ongoing/ongoing_screen.dart` (+139, -15 lignes) : ajout de badges `MakoloStatus` dans `_OngoingRow`, structuration de `_detailSections` avec `MakoloAttentionBlock` pour les bloqueurs, section sablier pour l'attente et `emphasis: true` pour les actions côté utilisateur. + +2. **`2da30f1d`** — `feat(mobile): enrich Discovery contextual metadata and focus depth pane` + - `mobile/lib/features/discovery/discovery_mature_view.dart` (+42 lignes) : métadonnées de proximité (`distanceKm` avec icône `near_me_outlined`), complétion du bloc de métadonnées et statut de disponibilité dans `_DiscoveryFocusPane`. + +3. **`caa23650`** — `feat(mobile): adapt Me collective and resource icons to entity archetype` + - `mobile/lib/features/me/me_screen.dart` (+12, -1 lignes) : association d'icônes d'archétype (`business_outlined` pour Space, `badge_outlined` pour Team, `groups_outlined` pour Group) et typage des ressources (`folder_outlined` pour `personal_asset`, `inventory_2_outlined` pour `resource_collection`). + +4. **`db770602`** — `feat(mobile): advance Now presentation topology ownership and elevate SpaceWorkSurface cards` + - `mobile/lib/presentation/contracts/now_presentation.dart` (+33 lignes) : déplacement de l'énumération `NowTopology` (S1–S5) et de la fonction de décision `topologyFor` dans la couche de contrat Presentation, avec getter `topology` sur `NowSituationPresentation`. + - `mobile/lib/features/now/now_screen.dart` (-33 lignes) : suppression de la duplication interne de décision dans le widget, ré-export propre depuis `now_presentation.dart`. + - `mobile/lib/features/space/space_work_surface.dart` (+117, -25 lignes) : remplacement des `ListTile` génériques par des cartes `MakoloCard` structurées avec icônes d'archétypes métier (`departure`, `transport_route`, `occurrence`, `activity`, `offer`, `journey`), métadonnées horaires/places et badge d'état. + +5. **`6922fc52`** — `feat(mobile): elevate History and Transverse Retrieval items with archetype recognition` + - `mobile/lib/features/continuity/history_screen.dart` (+17 lignes) : icônes d'archétypes (`timeline_outlined`, `event_outlined`, `shopping_bag_outlined`, `badge_outlined`, `payments_outlined`, `description_outlined`, `chat_bubble_outline_rounded`) intégrées dans `_HistoryCard`. + - `mobile/lib/features/continuity/transverse_retrieval_screen.dart` (+17 lignes) : icônes contextuelles d'archétypes intégrées sur les éléments de recherche transversale. + +6. **`a1e85ead`** — `feat(mobile): elevate Access collection cards with structured status tone and holder presentation` + - `mobile/lib/features/access/access_collection_screen.dart` (+58, -14 lignes) : extraction de la chaîne agrégée de statut vers un badge `MakoloStatus` avec tone sémantique (`success`, `warning`, `error`, `neutral`), séparation de la ligne temporelle et identification visuelle explicite du tiers bénéficiaire (`holder`). + +7. **`fd06c3c7`** — `feat(mobile): make Me passport card tappable and item-aware` + - `mobile/lib/features/me/me_screen.dart` (+13, -9 lignes) : le passeport affiche les informations du premier item s'il existe et devient cliquable vers la profondeur propriétaire (`onSelectItem`), tout en préservant le texte humain si aucun item n'est déclaré. + +8. **`d370cc67`** — `feat(mobile): render deduplicated consequence in Now primary situation` + - `mobile/lib/features/now/now_screen.dart` (+21, -1 lignes) : rendu de la conséquence (`consequence`) dans la situation principale Now lorsqu'elle apporte une information distincte du sens et du contexte humain, sans duplication textuelle. + +9. **`0366d812`** — `feat(mobile): add bookmark affordance to Discovery cards and focus depth pane` + - `mobile/lib/features/discovery/discovery_mature_view.dart` (+46, -4 lignes) : affordance visuelle de sauvegarde / favori (bookmark) dans les cartes de possibilités `_DiscoveryUnit` et dans le volet d'approfondissement `_DiscoveryFocusPane` selon les capacités serveur `canSave`/`canUnsave`/`savedState`. + +--- + +## 3. Modifications non commitées + +- **Arbre de travail :** Rigoureusement propre (`git status --porcelain` ne retourne aucune modification). +- Aucun fichier de stash en attente. + +--- + +## 4. Surfaces Django et Flutter réellement touchées + +### Surfaces Flutter modifiées et validées : +1. **Now (`mobile/lib/features/now/`)** : + - Découplage de la décision topologique S1–S5 vers `mobile/lib/presentation/contracts/now_presentation.dart`. + - Rendu de la conséquence dédupliquée dans la situation primaire. + - Cues S3 (action) et S4 (tour/attente) sur les situations secondaires. +2. **Découvrir (`mobile/lib/features/discovery/`)** : + - Métadonnées géographiques et de distance. + - Volet de focus d'approfondissement enrichi. + - Affordances visuelles de bookmark / veille. +3. **En cours (`mobile/lib/features/ongoing/`)** : + - Badges de statut dans les lignes de continuité. + - Blocs d'attention `MakoloAttentionBlock` pour les blocages et mise en exergue des actions utilisateur. +4. **Moi (`mobile/lib/features/me/`)** : + - Icônes typées par archétype pour les collectifs et les ressources personnelles. + - Passeport interactif orienté item avec navigation d'approfondissement. +5. **Space Work Surface (`mobile/lib/features/space/`)** : + - Sortie des `ListTile` bruts vers `MakoloCard` avec typage visuel des archétypes métier (transport, départ, activité, offre, démarche). +6. **Historique & Recherche Transversale (`mobile/lib/features/continuity/`)** : + - Reconnaissance visuelle immédiate des archétypes métier dans les listes de résultats. +7. **Accès (`mobile/lib/features/access/`)** : + - Remplacement de la concaténation de texte par des badges `MakoloStatus` typés et clarification de la relation bénéficiaire. + +### Surfaces Django : +- **Audit et tests d'intégration exécutés** : Les projections existantes (`build_personal_now_projection`, `build_space_work_projection`, `build_personal_me_projection`, `build_personal_ongoing_projection`) fournissaient déjà les vérités requises sans qu'une modification des modèles ORM ou une migration ne soit nécessaire. +- **Aucune migration Django n'a été créée** (respect de l'invariant architectural : *aucune migration SQL/Django/Drift*). + +--- + +## 5. Matrice de couverture des contrats Markdown + +| Contrat Markdown | Surface | État d'implémentation | Fichiers impactés | +|---|---|---|---| +| `Makolo_Now_Implementation_Contract_Server_Client_Presentation_S1_S5.md` | Now (Maintenant) | **Conforme / Implémenté** | `now_presentation.dart`, `now_screen.dart` | +| `Makolo_Now_Implementation_Contract_Server_Client_Presentation.md` | Now (Global) | **Conforme / Implémenté** | `now_screen.dart`, `now_selector.dart` | +| `Makolo_Decouvrir_Implementation_Contract_Server_Client_Presentation.md` | Découvrir | **Conforme / Implémenté** | `discovery_mature_view.dart`, `discovery_selector.dart` | +| `Makolo_En_Cours_Implementation_Contract_Server_Client_Presentation.md` | En cours | **Conforme / Implémenté** | `ongoing_screen.dart` | +| `Makolo_Moi_et_Nous_Cadre_Implementation_Contract_Server_Client_Presentation.md` | Moi & Nous | **Conforme / Implémenté** | `me_screen.dart` | +| `Makolo_Mes_Acces_Implementation_Contract_Server_Client_Presentation.md` | Mes Accès | **Conforme / Implémenté** | `access_collection_screen.dart` | +| `Makolo_Historique_Implementation_Contract_Server_Client_Presentation.md` | Historique | **Conforme / Implémenté** | `history_screen.dart` | +| `Makolo_Recherche_Transverse_Implementation_Contract_Server_Client_Presentation.md` | Recherche | **Conforme / Implémenté** | `transverse_retrieval_screen.dart` | +| `Makolo_Space_Now_et_Decouvrir_Cadre_Implementation_Contract_Server_Client_Presentation.md` | Space Work Surface | **Partiel (Work recomposé)** | `space_work_surface.dart` | +| `Makolo_Space_Now_Decouvrir_Donnees_Implementation_Contract_Server_Client_Presentation.md` | Space Attention (Now/Discover) | **Non traité** (serveur renvoie `unavailable` par contrat de sûreté) | `organizations/api/space_attention_projection.py` | +| `Makolo_Demarche_Journey_Implementation_Contract_Server_Client_Presentation.md` | Démarche / Journey Detail | **Conforme sans modification** (déjà mature via `MakoloDetailHeader`, `MakoloAttentionBlock`) | `journey_detail_screen.dart` | +| `Makolo_Forms_Questionnaires_Implementation_Contract_Server_Client_Presentation.md` | Questionnaires | **Conforme sans modification** | `questionnaire_reference_depth_screen.dart` | +| `Makolo_Jour_J_et_Makolo_Live_Implementation_Contract_Server_Client_Presentation.md` | Jour J / Live | **Conforme sans modification** | `jour_j_live_closeout_screen.dart` | +| `Makolo_Makolo_Mark_Implementation_Contract_Server_Client_Presentation.md` | Mark | **Conforme sans modification** | `mark_screen.dart` | +| `Makolo_Notifications_Implementation_Contract_Server_Client_Presentation.md` | Notifications | **Conforme sans modification** | `notification_router.dart` | +| `Makolo_Space_Metier_*` (8 contrats d'archétypes : Activités, Commerce, Créations, Initiatives, Prestations, Productions, Programmes, Transport) | Space Métier | **Partiel** (recomposé génériquement dans `space_work_surface.dart`, volets spécifiques restants) | `mobile/lib/features/space/` | +| `Makolo_Piloter_Implementation_Contract_Server_Client_Presentation.md` | Piloter / Gouvernance | **Non traité** | `mobile/lib/features/space/` | +| `Makolo_Personnes_et_Relations_Implementation_Contract_Server_Client_Presentation.md` | Relations / Collectifs | **Partiel** (icônes et typage faits dans Moi) | `mobile/lib/features/me/` | + +--- + +## 6. Tests lancés et résultats réels + +| Suite de tests | Commande exécutée | Résultat vérifié | +|---|---|---| +| Tests Now complets | `flutter test test/now_screen_test.dart test/now_cross_surface_contract_test.dart` | **PASS** (23/23 tests passés) | +| Tests Découvrir complets | `flutter test test/discovery_mature_screen_test.dart test/discovery_mature_selector_test.dart test/discovery_shell_contract_test.dart` | **PASS** (22/22 tests passés) | +| Tests Moi complets | `flutter test test/me_mature_test.dart` | **PASS** (19/19 tests passés) | +| Tests Space Work Surface | `flutter test test/space_work_surface_test.dart` | **PASS** (3/3 tests passés) | +| Tests Recherche Transversale & Continuité | `flutter test test/transverse_retrieval_screen_test.dart test/pc_continuity_depth_test.dart` | **PASS** (7/7 tests passés) | +| Tests Accès (collection & action réelle) | `flutter test test/access_collection_screen_test.dart test/ar_access_real_world_action_test.dart` | **PASS** (9/9 tests passés) | +| Tests Formatteur Dart | `dart format --output=none --set-exit-if-changed ` | **PASS** (0 modification requise, exit code 0) | +| Tests Django Core Now & Media | `$env:DJANGO_ENV="test"; python manage.py test core.test_now_composition core.test_now_media_api --keepdb` | **PASS** (11/11 tests passés) | +| Tests Django globaux (tentative avec chemin erroné `core.tests.*`) | `manage.py test core.tests.*` | **FAIL (erreur de chemin)** : les fichiers de test sont directement à la racine de l'application `core/` (`core.test_*`) et non dans un sous-dossier `core.tests`. | +| Validation visuelle sur émulateur Android | `flutter devices` | **NOT RUN (bloquant matériel)** : Aucun émulateur Android n'est démarré dans l'environnement local (seuls Windows desktop et Chrome/Edge sont visibles). | + +--- + +## 7. Erreurs rencontrées et diagnostic + +1. **Erreur de chemin de test Django :** + - *Symptôme :* `AttributeError: module 'core.tests' has no attribute 'test_z4_me_api'`. + - *Diagnostic :* Les modules de tests de `core` ne sont pas organisés dans un package `core.tests.*`, mais directement dans des fichiers plats comme `core.test_now_composition`, `core.test_z2_personal_projections`, etc. + - *Résolution :* Spécifier directement le nom de module flat : `core.test_now_composition`. + +2. **Erreur Django WhiteNoise Manifest :** + - *Symptôme :* `ValueError: The file 'brand/favicon.svg' could not be found` si `DJANGO_ENV` n'est pas positionné. + - *Diagnostic :* L'environnement de production/défaut exige les assets statiques compilés par WhiteNoise. + - *Résolution obligatoire :* Toujours préfixer avec `$env:DJANGO_ENV="test"`. + +3. **Quota Antigravity / API :** + - Des épuisements de quota sur les modèles externes ont conduit à des interruptions de session et au basculement d'orchestration vers le modèle actuel, sans perte de code grâce aux checkpoints Git locaux constants. + +4. **Typage Flutter `MeItemPresentation` :** + - *Symptôme :* Erreur de compilation dans `me_screen.dart` lors de la tentative d'accès à `.summary` sur `MeItemPresentation`. + - *Diagnostic :* Le champ dans `MeItemPresentation` s'intitule `.subtitle`. + - *Résolution :* Remplacé par `firstItem?.subtitle`. Test revalidé avec succès. + +--- + +## 8. Chantiers restant à compléter par Codex + +1. **Space Now / Discover Attention Projection (`organizations/api/space_attention_projection.py`) :** + - Le serveur renvoie actuellement `selection.state="unavailable"`, `reason="no_safe_selection_contract"`, `items=[]`. + - Vérifier si une projection sûre peut être construite sans nouveau modèle ni table, ou si le contrat doit conserver cet état d'indisponibilité explicite. +2. **Archétypes Space Métier (Lots F des contrats) :** + - Les 8 surfaces métier spécifiques (Commerce, Transports/Départs, Programmes, Prestations, Initiatives, Créations, Productions, Activités) peuvent bénéficier de gabarits d'approfondissement dédiés au-delà de la recomposition générale de `SpaceWorkSurface`. +3. **Piloter (`Makolo_Piloter_Implementation_Contract_Server_Client_Presentation.md`) :** + - Surface de gouvernance / pilotage Space à inspecter et aligner si des écarts de composition subsistent. + +--- + +## 9. Vérification des permissions, compatibilité et non-régression + +- **Permissions et IDOR :** Les ajouts visuels (affordances de bookmark, ouverture du passeport, détails de départs/jour J) s'appuient strictement sur les permissions et listes de `capabilities` renvoyées par le serveur (`canSave`, `open_day_of`, etc.). Aucune autorité n'a été inférée côté client. +- **Rétrocompatibilité :** L'export de `NowTopology` et `topologyFor` depuis `now_presentation.dart` via `now_screen.dart` maintient une compatibilité à 100% avec les tests existants. +- **Règles d'invariants :** + - Aucun modèle de base de données créé. + - Aucune migration Django ni Drift créée. + - Aucun contournement de mock dans les tests. + +--- + +## 10. Prochain checkpoint précis pour la reprise Codex + +- **Emplacement :** Branche `feature/mature-mobile-composition` au commit `0366d812`. +- **Première action recommandée pour Codex :** + 1. Inspecter `C:\Users\Public\makolo_preparation\Makolo_Space_Now_Decouvrir_Donnees_Implementation_Contract_Server_Client_Presentation.md`. + 2. Inspecter `organizations/api/space_attention_projection.py` et évaluer la faisabilité d'une projection attentionnelle sans invention de vérité métier. + 3. Lancer la suite de tests Django complète avec : + ```powershell + $env:DJANGO_ENV="test"; .\.venv\Scripts\python.exe manage.py test core.test_z1_projection_contract core.test_z2_personal_projections core.test_z4_me_api core.test_z5_detail_api core.test_z6_access_api core.test_z6_history_api --keepdb + ``` + 4. Poursuivre le traitement des contrats restants sans effectuer de push GitHub avant validation finale. diff --git a/docs/operations/mature-mobile-composition-checkpoints.md b/docs/operations/mature-mobile-composition-checkpoints.md new file mode 100644 index 000000000..434250b7f --- /dev/null +++ b/docs/operations/mature-mobile-composition-checkpoints.md @@ -0,0 +1,92 @@ +# Mature Mobile Experience — reprise Codex + +## Contrat de reprise + +- Objectif : fermer les écarts réels des contrats de préparation, depuis le checkpoint Antigravity, sans reconstruire les surfaces déjà modifiées. +- Owner : Codex, intégrateur unique. +- Base : `0366d81203ee06937427f055d4d31f905172f849`, neuf commits depuis `f2351b8b5db1ce362b7ddc6ca2951d9d79ce2538`. +- Branche : `feature/mature-mobile-composition` ; `origin/main` encore à la base au démarrage ; aucune PR ouverte (API GitHub). +- Surfaces : projections Organizations, Presentation/Flutter Space, routes propriétaires, tests ciblés, références Now et leur gate CI existant, documentation de convergence. +- Interdits : modèles/tables/migrations, nouvelle autorité, données inventées, refonte du thème/sync, modification de main, push intermédiaire, merge automatique. +- Acceptation : profondeurs réellement accessibles et revalidées, provenance/limites visibles dans Piloter, relations distinctes, absence/fraîcheur/révocation honnêtes, contrats compatibles. +- Évidence : tests Django d'autorisation/sélection et Flutter de navigation/présentation/acteur ; formateur canonique, analyse, Goldens pertinents et gates finaux. +- Gate : validation locale puis push final unique, PR et CI verte ; aucun merge automatique. + +## A — Space Now / Découvrir + +`space_attention_projection.py`, `workspace_projection.py`, `space_work_projection.py` et les tests ZS6 distinguent visibilité du portefeuille et sélection attentionnelle. Les permissions autorisent la lecture des objets, mais ne définissent ni pertinence collective, ni pourquoi maintenant, ni prochain mouvement, ni admission de possibilités pour un Space. Le selector personnel Discovery ne porte pas ce contexte collectif. + +La composition du portefeuille Métier ne peut donc pas devenir une sélection Now/Discover par renommage. `selection.unavailable / no_safe_selection_contract` reste conservé, avec `items=[]` ; ce n'est pas une preuve de calme ou de champ vide. Le blocage précis est l'absence d'une politique de sélection collective établie, scopée par mandat/perspective, portant les raisons et les destinations autorisées. Aucun modèle ni politique métier n'est inventé pour la contourner. + +## B–D — écarts confirmés + +- Métier : les huit grammaires existent côté serveur ; les cartes enrichies d'Antigravity sont conservées. Les liens d'approfondissement restent absents de l'interface hors Jour J ; les collections bornées doivent afficher leur couverture. +- Relations : recherche/sélection propriétaire existent ; Flutter aplatit les owners et ne permet pas d'approfondissement. Ne pas fusionner deux relations vers un même Profil. +- Piloter : le serveur livre preuves, raison, incertitude, couverture et date ; Flutter ne montre que titre/résumé et clés techniques des mesures. +- Transversal : `SpaceRetrievalOwnerScreen` existe mais sa destination n'est pas montée dans le router ; fermeture nécessaire à la recherche/historique et aux handoffs Métier/Relations. + +La validation visuelle sur appareil n'est pas attestée par le transfert. + +## Checkpoints vérifiés + +- Projections Métier : sélection par identité avant les limites d'aperçu, avec les mêmes selectors et permissions ; pagination par section (20 éléments), offset borné ; refus des identités étrangères/malformées et des sections non visibles. Une lentille Activity n'expose pas les routes/véhicules collectifs. Les lieux et arrêts viennent des relations Geography/Transport existantes, avec prefetch. +- Profondeurs Flutter : destination propriétaire Space montée pour Métier, Recherche, Historique et Relations ; relecture serveur avec responsabilité courante. Les liens Activity et Jour J restent distincts. Présentation MPS d'une Activity revalidée avant utilisation ; échec explicite, sans ancien artifact affiché après refus. +- Images MPS : le renderer consomme désormais le binding réel, au lieu d'une icône systématique. Représentations HTTP(S) de même origine uniquement, sans en-tête bearer ni cache disque supplémentaire ; alternative textuelle, chargement, erreur et retry ; image décodée évincée au démontage/changement de portée. Les médias absents ne créent pas de cadre orphelin. Tests de résolution/origine et d'erreur récupérable ajoutés. +- Continuation Métier : pages éphémères, sans nouveau store/sync ; retry au même offset, déduplication par identité propriétaire, contenu conservé sur erreur technique et retiré sur refus/changement d'acteur. +- Piloter : signaux avant mesures ; preuve, source, date, couverture bornée, raison et incertitude visibles. Zéro, inconnu et données insuffisantes restent distincts ; montants par devise seulement avec capability financière. Aucun calcul de tendance/causalité inventé. +- Relations : propriétaires conservés séparément, même si un Profil apparaît plusieurs fois ; aperçu et recherche distante distingués, résultats tardifs écartés, sélection revalidée au détail. +- Snapshot : SourceState, fraîcheur et date d'observation utilisés sur les racines et insights ; refus/révocation distincts du vide métier, snapshot expiré non exposé. +- Convergence : destination personnelle des commandes raccordée à l'écran propriétaire déjà présent ; corrections des contrôles null impossibles dans les commits repris, sans supprimer leurs comportements. + +Le premier checkpoint de formatage a appelé par erreur le binaire direct Dart 3.11.5 du SDK cassé `C:/src/flutter`, malgré son lanceur redirigé vers 3.13.3. L'appel corrigé passe par le SDK actif `C:/src/flutter_3473` (Flutter 3.47.3 / Dart 3.13.3). Les modifications de formatage étrangères au chantier ont été annulées par le vrai formateur, sans reset et sans modifier les neuf commits repris. + +Validation intermédiaire : 113 tests Django (Space ZS3/ZS4/ZS6, History/Search Space, contrats personnels Z1/Z2/Z4/Z5/Z6) et 485 tests Flutter hors Goldens réussis. Vérification de migrations : aucune modification détectée. Contrat OpenAPI : 12 opérations requises conservées ; 8 fixtures C0 valides. + +## Matrice de convergence des contrats + +Les contrats de données personnels et leurs consolidations restent rattachés aux mêmes surfaces ci-dessous. Ce registre reprend le transfert et ferme les écarts confirmés ; il ne transforme pas une cible documentaire en fonctionnalité existante. + +| Contrat / famille | Propriétaires et profondeur | Résultat de reprise | +|---|---|---| +| Now, S1–S5, données Now | `now_presentation.dart`, `now_screen.dart`, projections Core | Neuf commits conservés ; conséquence et dominance Presentation présentes ; contrôles d'analyse corrigés ; références S1–S5 actualisées pour le rendu réel. | +| Découvrir, données Découvrir/Me | Discovery selector/repository, `discovery_mature_view.dart` | Métadonnées et focus conservés ; bookmark affiché pour une capacité ou un état enregistré, pas pour `unknown` ; suites ciblées vertes. | +| En cours, données Ongoing/Me | Personal projections, `ongoing_screen.dart` | Dimensions et profondeurs existantes conservées ; tests de continuité et suite Flutter complète. | +| Moi/Nous, leurs cadres et données | Me projection, `me_screen.dart`, Space Us et Nous depths | Passeport interactif conservé ; callbacks typés corrigés ; Team/Group/Mandate restent séparés. | +| Space Now/Découvrir, cadre et données | `space_attention_projection.py`, Space repository/root | `unavailable` conservé pour le blocage A précis ; aucun faux calme, y compris source inconnue/partielle. | +| Métier Activités / Generic | Work + Activity/Occurrence | Sélection, continuation, identité, horaires/lieu, Activity liée et Jour J propriétaire ; aucune activité synthétique. | +| Métier Commerce | Work + Offer / CommerceOrder | Offre et commande restent séparées ; prix unitaire et devise de l'Offer lisibles, sans exposer un état Payment ; détail commande relu par Commerce. | +| Métier Créations / Creative | Work + Activity + MPS | Grammaire Créations conservée ; présentation matérielle autorisée accessible à la profondeur Activity, sans CreationModel ni image fictive. | +| Métier Initiatives / Community | Work + Activity / groupes propriétaires | Grammaire Initiatives conservée ; contexte et relations réellement visibles, sans métrique d'impact/autorité inventée. | +| Métier Prestations | Work + Offer / service Journeys | Offre distincte du dossier ; facet progressive et blocages uniquement sur Journey ; détails revalidés par les selectors Services existants. | +| Métier Productions / Media | Work + Activity + MPS | Production reste l'Activity ; média lié au contexte MPS, sans feed ni fichier transformé en production. | +| Métier Programmes / Education | Work + Activity / Occurrence / registration Journey | Programme, session et parcours gardent leurs identités et relations ; pas de Programme ORM ni écran spécialisé artificiel. | +| Métier Transport | Work + Route / Vehicle / Occurrence + Geography | Structure distincte des départs ; arrêts/lieu et places véhicule explicites ; Activity/Jour J séparés ; lentille Activity sans structure globale. | +| Piloter | Analytics projection, `SpacePilotSurface` | Preuves, portée, fraîcheur, lacunes et incertitudes rendues ; aucune tendance/causalité ni capacité financière déduite. | +| Personnes et relations | Team / Groups / CRM / Audiences / Partners | Sections distinctes, recherche distante bornée et profondeur owner-scoped ; aucun UniversalPerson ni fusion par nom. | +| Historique / Recherche transverse | History/Search projections + owner screens | Reconnaissance conservée ; destinations Space montées, commande personnelle montée ; caches personnels/collectifs distincts. | +| Mes Accès | Access collection/detail/credential | Badges et bénéficiaire conservés ; profondeur réelle existante ; aucun credential ajouté aux collections ; tests Z6 et Flutter. | +| Démarche/Journey | Journey detail et owner repositories | Owners, Readiness, paiements, ressources et questionnaires conservés ; tests Z5 et suite Flutter. | +| Requirements/Readiness | Requirement detail et selectors Readiness | Pas de nouveau statut/persistance ; contrats propriétaires conservés, tests Z5 et Flutter. | +| Préparation / ressources contextuelles | Preparation et Resource details | Aucun document assimilé à une exigence satisfaite ; propriétaires et transferts conservés, suite Flutter complète. | +| Forms / Questionnaires | Questionnaire reference depth / submit coordinator | Profondeurs et récupération existantes conservées, suite Flutter complète ; aucune mutation nouvelle. | +| Jour J / Makolo Live | Occurrence owner projections et routes opérateur | Handoff `open_day_of` serveur conservé ; aucun checkpoint assimilé à JourneyStep ; suite Flutter complète. | +| Makolo Mark | Mark owner/repository/routes | Routage acteur conservé ; aucune ingestion ou autorité nouvelle, suite Flutter complète. | +| Notifications | Push/router et récupération de session | Destinations propriétaires conservées ; suite Flutter complète, aucun payload privé ajouté. | +| Maître surfaces personnelles / expérience transversale / Behavior | Owners, selectors, adapter et design system existants | Pas de thème ou sync parallèle ; absence, erreur, snapshot, révocation, acteur, navigation et grandes polices vérifiés sur les changements. | + +Limites assumées : aucune mutation opérateur nouvelle ni formulaire Flutter inventé pour une capability dont le propriétaire n'a pas de flow mobile existant. Une capability ne devient jamais un bouton décoratif. Aucun émulateur/appareil n'a été utilisé ; la validation sur appareil n'est pas effectuée. + +Contrat API additionnel compatible : `work/?kind=&id=` retourne `selection` (0..1, mêmes selectors que la collection, filtrage avant limite). `work/?section=&offset=<0..10000>` retourne une seule section, `page.offset`, `page.limit=20` et `has_more` ; section non visible = 404, offset invalide = 400. Sans ces paramètres, les sections racine restent inchangées ; les nouvelles clés sont additives. Le prix d'une Offer est une chaîne décimale et une devise séparées, sous la permission Commerce déjà exigée ; aucun état Payment n'est ajouté. + +Goldens : les références Now ont été rendues et examinées sur Windows avec Flutter 3.47.3. S5 inclut désormais la conséquence déjà implémentée dans le checkpoint Antigravity. Les contrôles visuels généraux présentent un écart de rasterisation avec les hashes Ubuntu approuvés ; leur manifeste n'a pas été modifié. Le workflow Now existant devient un gate PR en lecture seule, avec artifacts de comparaison ; il ne commit/push plus automatiquement sur une ancienne branche. La vérification Ubuntu de la PR reste le gate canonique. + +Self-review (`ai-development-guardrails.md`) : propriétaires et permissions réutilisés ; lecture seule pour les nouveaux parcours ; retry explicite ; aucune outbox/pending inventée ; identités/acteurs contrôlés et réponses tardives écartées ; aucun modèle/migration/dependency nouveau ; tests non affaiblis ; absence de secrets dans le diff vérifiée avant publication. La qualité sur appareil et le selector collectif Space attentionnel restent des limites explicites, pas des validations prétendues. + +## Validation finale locale et CI + +- Suite finale Flutter après rendu média : 487 tests hors Goldens, tous verts ; analyse sans avertissement et formateur Dart 3.13.3 au vert. +- Django : 113 tests de convergence puis 20 tests Métier après ajout du prix de l’Offer, tous verts ; aucune migration détectée ; contrats OpenAPI/C0 préservés. +- PR unique : #518, sans merge automatique. Mobile CI, CI générale, API Contract, sécurité, contrôles visuels généraux, Android Build et suites PostgreSQL sont verts sur le premier HEAD publié. +- Le premier gate Now a révélé une préparation CI manquante (sources générées absentes). La génération build_runner et la vérification de sa politique sont ajoutées comme dans Mobile CI ; les assertions Golden restent strictes. + +- Correction Golden canonique : les 20 scénarios ont été rendus avec succès sur Ubuntu/Flutter 3.47.3 dans le run `38243330011`, HEAD `5b536c258174bf1c101d9de9bd19ead1d3a9d8b9`. Les 20 PNG de l’artifact `11743962104` remplacent les références Windows après inspection. Seule la rasterisation diffère entre ces environnements ; la conséquence S5 et les assertions restent conservées. Les références versionnées sont désormais celles du runner canonique, pas celles du poste Windows. diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart index 7645c9c56..49d8137f7 100644 --- a/mobile/lib/app/router.dart +++ b/mobile/lib/app/router.dart @@ -1,4 +1,6 @@ import '../features/continuity/transverse_retrieval_screen.dart'; +import '../features/continuity/space_retrieval_owner_screen.dart'; +import '../features/continuity/personal_owner_depth_screen.dart'; import 'dart:async'; @@ -28,6 +30,7 @@ import '../features/settings/change_password_screen.dart'; import '../features/settings/settings_screen.dart'; import '../features/space/space_shell_routes.dart'; import '../features/space/space_insight_screen.dart'; +import '../features/space/space_work_collection_screen.dart'; import '../features/space/space_repository.dart'; import '../features/space/space_occurrence_screen.dart'; import '../navigation/destination.dart'; @@ -219,6 +222,51 @@ GoRouter createMakoloRouter( ...markRoutes(runtime), ...continuityRoutes(runtime), ...notificationRoutes(runtime), + GoRoute( + path: '/space/:slug/work/:section', + builder: (context, state) { + final actor = runtime.actorContext?.value; + if (actor is! SpaceActorContext || + actor.space.slug != state.pathParameters['slug']) { + return const MakoloSecondaryScreen( + title: 'Métier', + message: 'Choisissez l’Espace autorisé pour cette collection.', + ); + } + return SpaceWorkCollectionScreen( + runtime: runtime, + actor: actor, + section: state.pathParameters['section']!, + ); + }, + ), + GoRoute( + path: '/me/orders/:id', + builder: (context, state) => PersonalOwnerDepthScreen( + runtime: runtime, + kind: 'commerce_order', + id: state.pathParameters['id']!, + ), + ), + GoRoute( + path: '/space/:slug/retrieval/:kind/:id', + builder: (context, state) { + final actor = runtime.actorContext?.value; + if (actor is! SpaceActorContext || + actor.space.slug != state.pathParameters['slug']) { + return const MakoloSecondaryScreen( + title: 'Détail de mon Espace', + message: 'Choisissez l’Espace autorisé pour ce détail.', + ); + } + return SpaceRetrievalOwnerScreen( + runtime: runtime, + spaceActor: actor, + kind: state.pathParameters['kind']!, + id: state.pathParameters['id']!, + ); + }, + ), GoRoute( path: '/search', builder: (context, state) => TransverseRetrievalScreen( diff --git a/mobile/lib/features/access/access_collection_screen.dart b/mobile/lib/features/access/access_collection_screen.dart index 4b47b6528..8f439ae35 100644 --- a/mobile/lib/features/access/access_collection_screen.dart +++ b/mobile/lib/features/access/access_collection_screen.dart @@ -242,40 +242,84 @@ class _AccessCollectionCard extends StatelessWidget { final _AccessCollectionItem item; final VoidCallback onOpen; + MakoloStatusTone _stateTone(String state) { + return switch (state.toLowerCase()) { + 'valide' || 'valid' => MakoloStatusTone.success, + 'en attente' || 'pending' => MakoloStatusTone.warning, + 'annulé' || 'annule' || 'cancelled' => MakoloStatusTone.error, + _ => MakoloStatusTone.neutral, + }; + } + @override Widget build(BuildContext context) { - final details = [ + final contextLine = [ if (item.context != null) item.context!, - item.stateLabel, if (item.validity != null) item.validity!, - if (item.holder != null) 'Pour ${item.holder!}', - ]; + ].join(' · '); return MakoloCard( onTap: onOpen, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.badge_outlined), + Icon( + Icons.badge_outlined, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: MakoloSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.title, - style: Theme.of(context).textTheme.titleMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + item.title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(width: MakoloSpacing.sm), + MakoloStatus( + label: item.stateLabel, + tone: _stateTone(item.stateLabel), + ), + ], ), - const SizedBox(height: MakoloSpacing.xs), - Text( - details.join(' · '), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + if (contextLine.isNotEmpty) ...[ + const SizedBox(height: MakoloSpacing.xs), + Text( + contextLine, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), + ], + if (item.holder != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + Row( + children: [ + Icon( + Icons.person_outline_rounded, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: MakoloSpacing.xs), + Text( + 'Pour ${item.holder!}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], ], ), ), + const SizedBox(width: MakoloSpacing.sm), const Icon(Icons.chevron_right_rounded), ], ), diff --git a/mobile/lib/features/continuity/history_screen.dart b/mobile/lib/features/continuity/history_screen.dart index 1cb2099bd..318cdf17e 100644 --- a/mobile/lib/features/continuity/history_screen.dart +++ b/mobile/lib/features/continuity/history_screen.dart @@ -423,6 +423,10 @@ class _HistoryCard extends StatelessWidget { return MakoloCard( onTap: item.id == null ? null : () => onOpen(item.kind, item.id!), child: MakoloStatusMetadataAction( + leading: Icon( + _kindIcon(item.kind), + color: Theme.of(context).colorScheme.primary, + ), title: item.title, subtitle: item.occurredAt, status: item.outcome == null @@ -434,6 +438,19 @@ class _HistoryCard extends StatelessWidget { ), ); } + + IconData _kindIcon(String kind) { + return switch (kind) { + 'journey' => Icons.timeline_outlined, + 'activity' || 'occurrence' => Icons.event_outlined, + 'order' || 'offer' => Icons.shopping_bag_outlined, + 'access' => Icons.badge_outlined, + 'payment' => Icons.payments_outlined, + 'form' || 'document' => Icons.description_outlined, + 'message' || 'conversation' => Icons.chat_bubble_outline_rounded, + _ => Icons.history_rounded, + }; + } } class _HistoryView { diff --git a/mobile/lib/features/continuity/personal_owner_depth_screen.dart b/mobile/lib/features/continuity/personal_owner_depth_screen.dart index 4b80892ec..783d20148 100644 --- a/mobile/lib/features/continuity/personal_owner_depth_screen.dart +++ b/mobile/lib/features/continuity/personal_owner_depth_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import '../../app/runtime/app_runtime.dart'; +import '../../app/runtime/actor_context.dart'; +import '../../design/makolo_patterns.dart'; +import '../../presentation/contracts/space_presentation.dart'; /// Directly consults the canonical personal owner projection before display. class PersonalOwnerDepthScreen extends StatefulWidget { @@ -22,16 +25,38 @@ class PersonalOwnerDepthScreen extends StatefulWidget { class _PersonalOwnerDepthScreenState extends State { late Future> _detail; + bool get _actorValid => + widget.runtime.actorContext?.value is! SpaceActorContext; @override void initState() { super.initState(); + widget.runtime.actorContext?.addListener(_onActorChanged); _detail = _load(); } + void _onActorChanged() { + if (!mounted) return; + setState( + () => _detail = _actorValid + ? _load() + : Future.value(const {}), + ); + } + + @override + void dispose() { + widget.runtime.actorContext?.removeListener(_onActorChanged); + super.dispose(); + } + @override void didUpdateWidget(covariant PersonalOwnerDepthScreen oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.runtime.actorContext != widget.runtime.actorContext) { + oldWidget.runtime.actorContext?.removeListener(_onActorChanged); + widget.runtime.actorContext?.addListener(_onActorChanged); + } if (oldWidget.runtime != widget.runtime || oldWidget.id != widget.id || oldWidget.kind != widget.kind) { @@ -40,6 +65,8 @@ class _PersonalOwnerDepthScreenState extends State { } Future> _load() async { + if (!_actorValid) return const {}; + final runtime = widget.runtime; final api = widget.runtime.api; if (api == null) { throw StateError('Owner service unavailable'); @@ -51,6 +78,9 @@ class _PersonalOwnerDepthScreenState extends State { _ => throw const FormatException('Unknown owner kind'), }; final response = await api.get('api/v1/me/$segment'); + if (!_actorValid || runtime != widget.runtime) { + throw StateError('Owner context changed'); + } final envelope = response.jsonObject(); final raw = envelope['data']; if (raw is! Map) throw const FormatException('Invalid owner response'); @@ -75,70 +105,75 @@ class _PersonalOwnerDepthScreenState extends State { : 'Mon groupe', ), ), - body: FutureBuilder>( - future: _detail, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (!snapshot.hasData) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Cette information n’est pas disponible ' - 'avec votre accès actuel.', - ), - TextButton( - onPressed: () => setState(() => _detail = _load()), - child: const Text('Réessayer'), - ), - ], + body: !_actorValid + ? const Center( + child: Text( + 'Revenez à votre contexte personnel pour cette commande.', ), - ); - } - final data = snapshot.data!; - final title = document || order ? data['title'] : data['name']; - final details = [ - if (document) data['asset_kind_label']?.toString() ?? 'Document', - if (!document && data['description'] is String) - data['description'] as String, - if (data['status'] is String) data['status'] as String, - if (order && data['activity'] is Map) - (data['activity'] as Map)['title']?.toString() ?? '', - ]; - return ListView( - padding: const EdgeInsets.all(24), - children: [ - Text( - title?.toString() ?? 'Élément', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - for (final detail in details) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text(detail), - ), - if (document) ...[ - const SizedBox(height: 20), - const Text( - 'Un document conservé ne satisfait pas ' - 'automatiquement une exigence.', - ), - ], - if (!document && data['members'] is Map) ...[ - const SizedBox(height: 16), - Text( - 'Membres actifs : ' - '${(data['members'] as Map)['active_count'] ?? 'Non précisé'}', - ), - ], - ], - ); - }, - ), + ) + : FutureBuilder>( + future: _detail, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Cette information n’est pas disponible ' + 'avec votre accès actuel.', + ), + TextButton( + onPressed: () => setState(() => _detail = _load()), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + final data = snapshot.data!; + final title = document || order ? data['title'] : data['name']; + final details = [ + if (document) + data['asset_kind_label']?.toString() ?? 'Document', + if (!document && data['description'] is String) + data['description'] as String, + if (data['status'] is String) + spaceStateLabel(data['status'] as String), + if (order && data['activity'] is Map) + (data['activity'] as Map)['title']?.toString() ?? '', + ]; + return ListView( + padding: const EdgeInsets.all(24), + children: [ + MakoloDetailHeader(title: title?.toString() ?? 'Élément'), + const SizedBox(height: 16), + for (final detail in details) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(detail), + ), + if (document) ...[ + const SizedBox(height: 20), + const Text( + 'Un document conservé ne satisfait pas ' + 'automatiquement une exigence.', + ), + ], + if (!document && data['members'] is Map) ...[ + const SizedBox(height: 16), + Text( + 'Membres actifs : ' + '${(data['members'] as Map)['active_count'] ?? 'Non précisé'}', + ), + ], + ], + ); + }, + ), ); } } diff --git a/mobile/lib/features/continuity/space_retrieval_owner_screen.dart b/mobile/lib/features/continuity/space_retrieval_owner_screen.dart index eb3085c6a..01a8d3f2d 100644 --- a/mobile/lib/features/continuity/space_retrieval_owner_screen.dart +++ b/mobile/lib/features/continuity/space_retrieval_owner_screen.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../app/runtime/actor_context.dart'; import '../../app/runtime/app_runtime.dart'; +import '../../design/makolo_components.dart'; +import '../../design/makolo_patterns.dart'; +import '../../presentation/contracts/space_presentation.dart'; +import '../../presentation/mps/mps_models.dart'; +import '../../presentation/mps/mps_native_renderer.dart'; /// Owner-backed Space N3. A search result is never an authorization grant: /// every opening re-reads the source using the current account and actor. @@ -41,7 +47,9 @@ class _SpaceRetrievalOwnerScreenState extends State { if (!mounted) return; setState(() { // A return to this Space also forces owner revalidation. - _result = _readOwner(); + _result = _actorValid + ? _readOwner() + : Future.value(const {}); }); } @@ -77,7 +85,12 @@ class _SpaceRetrievalOwnerScreenState extends State { final kind = widget.kind; final id = Uri.encodeComponent(widget.id); final data = switch (kind) { - 'activity' || 'occurrence' => await _readActivityOwner(api, kind, id), + 'activity' || + 'occurrence' || + 'offer' || + 'journey' || + 'transport_route' || + 'vehicle' => await _readWorkOwner(api, kind), 'commerce_order' => await _readOrderOwner(api, id), 'team_member' || 'group' || @@ -90,59 +103,53 @@ class _SpaceRetrievalOwnerScreenState extends State { return data; } - Future> _readActivityOwner( - dynamic api, - String kind, - String id, - ) async { - final path = kind == 'activity' - ? 'api/v1/activities/$id/' - : 'api/v1/occurrences/$id/'; - final envelope = (await api.get(path)).jsonObject(); - final data = envelope['data']; - if (data is! Map) throw const FormatException('Missing owner data'); - final payload = Map.from(data); - final identity = payload['identity']; - if (identity is! Map || - identity['kind'] != kind || - identity['id'] != widget.id) { - throw const FormatException('Owner identity mismatch'); + Future> _readWorkOwner(dynamic api, String kind) async { + final perspective = widget.spaceActor.perspective; + final path = Uri( + path: '${_spacePath}work/', + queryParameters: { + 'kind': kind, + 'id': widget.id, + if (!perspective.isAll) 'responsibility': perspective.id!, + }, + ).toString(); + final payload = (await api.get(path)).jsonObject(); + final item = spaceMap(payload['selection']); + final source = spaceMap(item?['source']); + final space = spaceMap(payload['space']); + if (space?['id'] != widget.spaceActor.space.id || + source?['kind'] != kind || + source?['id'] != widget.id) { + throw const FormatException( + 'Owner unavailable in current responsibility', + ); } - Map ownerData = payload; - if (kind == 'occurrence') { - final activity = payload['activity']; - if (activity is! Map || activity['id'] == null) { - throw const FormatException('Occurrence activity is missing'); + MpsPresentationPackage? presentation; + var presentationUnavailable = false; + final mps = widget.runtime.mps; + if (kind == 'activity' && mps != null) { + try { + await mps.refreshActivity(widget.id, 'public_page'); + if (!_actorValid) throw StateError('Actor changed'); + presentation = await mps.readActivityPackage(widget.id, 'public_page'); + } on Object { + // Do not expose an older artifact after failed owner revalidation. + presentationUnavailable = true; } - final ownerEnvelope = (await api.get( - 'api/v1/activities/${Uri.encodeComponent(activity['id'].toString())}/', - )).jsonObject(); - if (ownerEnvelope['data'] is! Map) { - throw const FormatException('Activity owner unavailable'); - } - ownerData = ownerEnvelope['data'] as Map; - } - final owner = ownerData['owner']; - if (owner is! Map || - owner['kind'] != 'space' || - owner['id'] != widget.spaceActor.space.id) { - throw const FormatException('Result belongs to another actor'); } - final representation = payload['representation']; - final title = kind == 'activity' - ? (representation is Map ? representation['title'] : null) - : (payload['activity'] is Map - ? (payload['activity'] as Map)['title'] - : null); - final status = payload['state']; - final place = payload['place']; return { - 'title': title ?? 'Élément', - 'type': kind == 'activity' ? 'Activité' : 'Séance', - 'status': status is Map ? status['code'] : null, - 'summary': representation is Map ? representation['summary'] : null, - 'place': place is Map ? (place['name'] ?? place['locality']) : null, - 'timing': payload['timing'], + 'title': item!['title'], + 'type': payload['primary_business_label'], + 'status': item['state'], + 'summary': item['summary'], + 'timing': item['timing'], + 'context': item['context'], + 'presentation': presentation, + 'presentation_unavailable': presentationUnavailable, + 'continuity_facet': item['continuity_facet'], + 'day_of': + (item['capabilities'] is List && + (item['capabilities'] as List).contains('open_day_of')), }; } @@ -236,24 +243,118 @@ class _SpaceRetrievalOwnerScreenState extends State { ); } final data = snapshot.data!; + final itemContext = spaceMap(data['context']); + final activity = spaceMap(itemContext?['activity']); + final activityRoute = spaceOwnerRoute( + slug: widget.spaceActor.space.slug, + kind: 'activity', + id: spaceText(activity?['id']), + ); + final blockers = spaceMap( + data['continuity_facet'], + )?['blockers']; return ListView( padding: const EdgeInsets.all(24), children: [ - Text( - data['type']?.toString() ?? 'Élément', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 8), - Text( - data['title']?.toString() ?? 'Détail', - style: Theme.of(context).textTheme.headlineSmall, + MakoloDetailHeader( + title: spaceText(data['title']) ?? 'Détail', + eyebrow: spaceText(data['type']), + subtitle: spaceText(data['summary']), + status: spaceText(data['status']) == null + ? null + : MakoloStatus( + label: spaceStateLabel(data['status'] as String), + ), ), - for (final key in ['status', 'summary', 'place', 'timing']) + for (final key in ['place']) if (data[key] != null) Padding( padding: const EdgeInsets.only(top: 14), child: Text(data[key].toString()), ), + for (final line in spaceTimingLines(data['timing'])) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(line), + ), + if (itemContext?['passenger_capacity'] is num) + Text( + '${itemContext!['passenger_capacity']} places dans le véhicule', + ), + if (spaceMap(itemContext?['pricing']) + case final Map pricing) + if (pricing['unit_price'] is String && + pricing['currency'] is String) + ListTile( + leading: const Icon(Icons.payments_outlined), + title: Text( + 'Prix unitaire : ${pricing['unit_price']} ${pricing['currency']}', + ), + ), + if (spaceText(spaceMap(itemContext?['place'])?['name']) + case final String place) + ListTile( + leading: const Icon(Icons.place_outlined), + title: Text(place), + ), + if (itemContext?['stops'] case final List stops) + for (final stop in stops) + if (spaceMap(stop) + case final Map place) + ListTile( + leading: const Icon(Icons.place_outlined), + title: Text(spaceText(place['name']) ?? 'Arrêt'), + subtitle: Text( + [ + if (place['boarding_allowed'] == true) + 'Embarquement', + if (place['alighting_allowed'] == true) + 'Descente', + ].join(' · '), + ), + ), + if (blockers is List && blockers.isNotEmpty) + MakoloAttentionBlock( + title: 'Points à examiner', + body: + '${blockers.length} blocage(s) actif(s) dans cette démarche.', + ), + if (activityRoute != null) + ListTile( + title: Text( + spaceText(activity?['title']) ?? 'Activité liée', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(activityRoute), + ), + if (data['day_of'] == true && widget.kind == 'occurrence') + FilledButton.icon( + onPressed: () => context.push( + '/space/occurrences/${Uri.encodeComponent(widget.id)}/day-of', + ), + icon: const Icon(Icons.play_circle_outline), + label: const Text('Jour J'), + ), + if (data['presentation'] + case final MpsPresentationPackage package) + if (spaceMap(package.artifact.context['editorial']) + ?.isNotEmpty == + true) + ExpansionTile( + title: const Text('Présentation'), + children: [ + MpsNativeRenderer( + package: package, + mediaBaseUri: widget.runtime.api?.baseUri, + mediaScopeKey: + '${widget.runtime.session?.profileId}:${widget.spaceActor.space.id}:${widget.spaceActor.perspective.id ?? 'all'}', + ), + ], + ), + if (data['presentation_unavailable'] == true) + const Text( + 'La présentation associée est indisponible. Actualisez pour réessayer.', + ), ], ); }, diff --git a/mobile/lib/features/continuity/transverse_retrieval_screen.dart b/mobile/lib/features/continuity/transverse_retrieval_screen.dart index 9817e1f44..034cae9d8 100644 --- a/mobile/lib/features/continuity/transverse_retrieval_screen.dart +++ b/mobile/lib/features/continuity/transverse_retrieval_screen.dart @@ -813,6 +813,10 @@ class _TransverseRetrievalScreenState extends State { for (final index in group.value) ListTile( key: ValueKey('retrieval-$index'), + leading: Icon( + _retrievalIcon(rows[index]['kind']?.toString()), + color: Theme.of(context).colorScheme.primary, + ), title: Text( rows[index]['title']?.toString() ?? 'Élément', ), @@ -879,4 +883,17 @@ class _TransverseRetrievalScreenState extends State { ), ); } + + IconData _retrievalIcon(String? kind) { + return switch (kind) { + 'journey' => Icons.timeline_outlined, + 'access' => Icons.badge_outlined, + 'personal_asset' || 'document' => Icons.folder_outlined, + 'group' || 'team' => Icons.group_outlined, + 'commerce_order' || 'order' => Icons.shopping_bag_outlined, + 'activity' || 'occurrence' => Icons.event_outlined, + 'space' => Icons.business_outlined, + _ => Icons.search_rounded, + }; + } } diff --git a/mobile/lib/features/discovery/discovery_mature_view.dart b/mobile/lib/features/discovery/discovery_mature_view.dart index e2319907f..4a819a168 100644 --- a/mobile/lib/features/discovery/discovery_mature_view.dart +++ b/mobile/lib/features/discovery/discovery_mature_view.dart @@ -448,6 +448,11 @@ class _DiscoveryUnit extends StatelessWidget { MakoloMetadataItem(item.owner!, icon: Icons.business_outlined), if (item.place != null) MakoloMetadataItem(item.place!, icon: Icons.place_outlined), + if (item.distanceKm != null) + MakoloMetadataItem( + '${item.distanceKm! < 10 ? item.distanceKm!.toStringAsFixed(1) : item.distanceKm!.round()} km', + icon: Icons.near_me_outlined, + ), if (item.timing != null) MakoloMetadataItem(item.timing!, icon: Icons.schedule_outlined), if (item.price != null) @@ -507,9 +512,32 @@ class _DiscoveryUnit extends StatelessWidget { const SizedBox(height: MakoloSpacing.md), MakoloMetadata(items: metadata), ], - if (item.availability != null) ...[ + if (item.availability != null || + item.canSave || + item.canUnsave || + item.savedState == 'saved') ...[ const SizedBox(height: MakoloSpacing.md), - MakoloStatus(label: item.availability!), + Row( + children: [ + if (item.availability != null) + MakoloStatus(label: item.availability!), + const Spacer(), + if (item.canSave || + item.canUnsave || + item.savedState == 'saved') + Icon( + item.savedState == 'saved' + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + size: 20, + color: item.savedState == 'saved' + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ], + ), ], if (onSelect != null) ...[ const SizedBox(height: MakoloSpacing.md), @@ -620,6 +648,64 @@ class _DiscoveryFocusPane extends StatelessWidget { const SizedBox(height: MakoloSpacing.sm), Text(item.summary), ], + if (item.owner != null || + item.place != null || + item.timing != null || + item.price != null || + item.distanceKm != null) ...[ + const SizedBox(height: MakoloSpacing.md), + MakoloMetadata( + items: [ + if (item.owner != null) + MakoloMetadataItem( + item.owner!, + icon: Icons.business_outlined, + ), + if (item.place != null) + MakoloMetadataItem(item.place!, icon: Icons.place_outlined), + if (item.distanceKm != null) + MakoloMetadataItem( + '${item.distanceKm! < 10 ? item.distanceKm!.toStringAsFixed(1) : item.distanceKm!.round()} km', + icon: Icons.near_me_outlined, + ), + if (item.timing != null) + MakoloMetadataItem( + item.timing!, + icon: Icons.schedule_outlined, + ), + if (item.price != null) + MakoloMetadataItem( + item.price!, + icon: Icons.payments_outlined, + ), + ], + ), + ], + if (item.availability != null || + item.canSave || + item.canUnsave || + item.savedState == 'saved') ...[ + const SizedBox(height: MakoloSpacing.md), + Row( + children: [ + if (item.availability != null) + MakoloStatus(label: item.availability!), + const Spacer(), + if (item.canSave || + item.canUnsave || + item.savedState == 'saved') + Icon( + item.savedState == 'saved' + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + size: 22, + color: item.savedState == 'saved' + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ], const SizedBox(height: MakoloSpacing.lg), FilledButton.icon( onPressed: onOpen, diff --git a/mobile/lib/features/me/me_screen.dart b/mobile/lib/features/me/me_screen.dart index 27ac49035..d531b6e47 100644 --- a/mobile/lib/features/me/me_screen.dart +++ b/mobile/lib/features/me/me_screen.dart @@ -467,7 +467,14 @@ class _MeTerritory extends StatelessWidget { return _empty(context, 'Aucun Passeport disponible ici pour le moment.'); } + final firstItem = territory.items.isNotEmpty ? territory.items.first : null; + final title = firstItem?.title ?? 'Disponible'; + final subtitle = + firstItem?.subtitle ?? + 'Une vue de ce qui peut vous représenter selon le contexte.'; + return MakoloCard( + onTap: firstItem == null ? null : () => onSelectItem(firstItem), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -480,19 +487,13 @@ class _MeTerritory extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Disponible', - style: Theme.of(context).textTheme.titleMedium, - ), + Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: MakoloSpacing.xs), - Text( - 'Une vue de ce qui peut vous ' - 'représenter selon le contexte.', - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), ], ), ), + if (firstItem != null) const Icon(Icons.chevron_right_rounded), ], ), ); @@ -638,12 +639,21 @@ class _MeIdentityRow extends StatelessWidget { contentPadding: EdgeInsets.zero, minVerticalPadding: MakoloSpacing.sm, onTap: onTap, - leading: const Icon(Icons.group_outlined), + leading: Icon(_identityIcon(item.destination.kind)), title: Text(item.title), subtitle: item.subtitle == null ? null : Text(item.subtitle!), trailing: const Icon(Icons.chevron_right_rounded), ); } + + IconData _identityIcon(String kind) { + return switch (kind) { + 'space' => Icons.business_outlined, + 'team' => Icons.badge_outlined, + 'group' => Icons.groups_outlined, + _ => Icons.group_outlined, + }; + } } class _MeResourceRow extends StatelessWidget { @@ -669,6 +679,8 @@ class _MeResourceRow extends StatelessWidget { return switch (kind) { 'proof' => Icons.verified_outlined, 'credential' => Icons.workspace_premium_outlined, + 'personal_asset' => Icons.folder_outlined, + 'resource_collection' => Icons.inventory_2_outlined, _ => Icons.description_outlined, }; } diff --git a/mobile/lib/features/now/now_screen.dart b/mobile/lib/features/now/now_screen.dart index 7dce9b005..e94bab63e 100644 --- a/mobile/lib/features/now/now_screen.dart +++ b/mobile/lib/features/now/now_screen.dart @@ -13,6 +13,8 @@ import '../../navigation/deep_link_resolver.dart'; import '../../navigation/destination.dart'; import '../../navigation/refresh_boundary.dart'; import '../../presentation/contracts/now_presentation.dart'; +export '../../presentation/contracts/now_presentation.dart' + show NowTopology, topologyFor; import '../../repositories/personal_repository.dart'; import '../../sync/freshness.dart'; import '../../sync/sync_engine.dart'; @@ -414,37 +416,6 @@ String? _distinctPresentationText(String? value, Iterable others) { return text; } -enum NowTopology { meaning, media, action, waiting, composition } - -NowTopology topologyFor(NowSituationPresentation situation) { - if (situation.mediaBindings.any((media) => media.canDominate)) { - return NowTopology.media; - } - if (situation.businessActions.any((action) => action.canDominate)) { - return NowTopology.action; - } - // A relation alone is not a composed Now situation: require a present - // consequence and an explicit explanation from the owning projection. - final memberIds = situation.relationMembers.map((item) => item.id).toSet(); - final hasRealRelation = situation.relations.any((relation) { - final linked = relation.memberIds.toSet().intersection(memberIds); - return relation.kind.trim().isNotEmpty && linked.length >= 2; - }); - if (memberIds.length >= 2 && - hasRealRelation && - situation.whyNow?.trim().isNotEmpty == true && - situation.consequence?.trim().isNotEmpty == true && - situation.responseType?.trim().isNotEmpty == true) { - return NowTopology.composition; - } - // Waiting is a response owned by the projection, not an inferred status. - final response = situation.responseType?.toLowerCase(); - if (response == 'wait' || response == 'monitor' || response == 'waiting') { - return NowTopology.waiting; - } - return NowTopology.meaning; -} - class _NowSemanticContent extends StatelessWidget { const _NowSemanticContent({ required this.situation, @@ -632,11 +603,21 @@ class _NowPrimarySituation extends StatelessWidget { situation.meaning, situation.humanContext, ]); + final consequence = _distinctPresentationText(situation.consequence, [ + situation.meaning, + situation.humanContext, + situation.whyNow, + ]); return Semantics( container: true, button: true, selected: selected, - label: [?contextLabel, situation.meaning, ?whyNow].join('. '), + label: [ + ?contextLabel, + situation.meaning, + ?consequence, + ?whyNow, + ].join('. '), child: InkWell( onTap: onSelect, borderRadius: BorderRadius.circular(MakoloRadii.card), @@ -658,6 +639,16 @@ class _NowPrimarySituation extends StatelessWidget { situation.meaning, style: Theme.of(context).textTheme.headlineSmall, ), + if (consequence != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + Text( + consequence, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], if (whyNow != null) ...[ const SizedBox(height: MakoloSpacing.md), Text( @@ -712,11 +703,24 @@ class _NowSecondarySituation extends StatelessWidget { final contextLabel = _distinctPresentationText(situation.humanContext, [ situation.meaning, ]); + final whyNow = _distinctPresentationText(situation.whyNow, [ + situation.meaning, + situation.humanContext, + ]); + final topology = topologyFor(situation); + final showTurnCue = + topology == NowTopology.waiting && situation.turnLabel != null; + final showActionCue = + topology == NowTopology.action && + situation.businessActions.isNotEmpty && + situation.responseLabel != null; + return MakoloCard( onTap: onSelect, semanticLabel: [ ?contextLabel, situation.meaning, + ?whyNow, 'Ouvrir le détail', ].join('. '), child: Column( @@ -730,6 +734,48 @@ class _NowSecondarySituation extends StatelessWidget { situation.meaning, style: Theme.of(context).textTheme.titleLarge, ), + if (whyNow != null) ...[ + const SizedBox(height: MakoloSpacing.sm), + Text( + whyNow, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (showTurnCue) ...[ + const SizedBox(height: MakoloSpacing.sm), + Row( + children: [ + Icon( + Icons.hourglass_top_outlined, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: MakoloSpacing.xs), + Flexible( + child: Text( + situation.turnLabel!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + if (showActionCue) ...[ + const SizedBox(height: MakoloSpacing.sm), + MakoloStatus( + label: situation.responseLabel!, + tone: MakoloStatusTone.info, + icon: Icons.arrow_forward_rounded, + ), + ], if (situation.metadata.isNotEmpty) ...[ const SizedBox(height: MakoloSpacing.sm), MakoloMetadata( diff --git a/mobile/lib/features/ongoing/ongoing_screen.dart b/mobile/lib/features/ongoing/ongoing_screen.dart index 37e50eec3..ac0b8b88d 100644 --- a/mobile/lib/features/ongoing/ongoing_screen.dart +++ b/mobile/lib/features/ongoing/ongoing_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../data/local/profile_store.dart'; import '../../design/behavior_states.dart'; import '../../design/makolo_components.dart'; +import '../../design/makolo_patterns.dart'; import '../../design/makolo_theme.dart'; import '../../design/presentation_layout.dart'; import '../../design/surface_states.dart'; @@ -240,6 +241,8 @@ class _OngoingRow extends StatelessWidget { Widget build(BuildContext context) { final summary = _ongoingSummary(item.title, item.synthesis); final supporting = _supportingLine(item); + final hasBlocker = item.blocker != null; + final hasMySide = item.mySide.isNotEmpty; return Semantics( button: true, selected: selected, @@ -261,6 +264,18 @@ class _OngoingRow extends StatelessWidget { const SizedBox(height: MakoloSpacing.sm), Text(supporting, style: Theme.of(context).textTheme.bodyMedium), ], + if (hasBlocker || hasMySide) ...[ + const SizedBox(height: MakoloSpacing.sm), + MakoloStatus( + label: hasBlocker ? 'Bloqué' : 'Action de votre côté', + tone: hasBlocker + ? MakoloStatusTone.warning + : MakoloStatusTone.neutral, + icon: hasBlocker + ? Icons.block_outlined + : Icons.person_outline_rounded, + ), + ], if (stale) ...[ const SizedBox(height: MakoloSpacing.sm), Text( @@ -302,7 +317,7 @@ class _OngoingFocus extends StatelessWidget { item.blocker != null || item.unknown != null; - List _detailSections() { + List _detailSections(BuildContext context) { final seen = {}; List unique(Iterable values) => [ for (final value in values) @@ -316,20 +331,104 @@ class _OngoingFocus extends StatelessWidget { ..add(_ongoingKey(item.title)) ..add(_ongoingKey(item.synthesis)); final sections = []; - void add(String title, Iterable values) { - final visible = unique(values); - if (visible.isNotEmpty) { - sections.add(_DimensionSection(title: title, values: visible)); + + // Settled — what's already done + final settledValues = unique(item.settled); + if (settledValues.isNotEmpty) { + sections.add( + _DimensionSection(title: 'Déjà réglé', values: settledValues), + ); + } + + // My side — explicit action from me + final mySideValues = unique(item.mySide); + if (mySideValues.isNotEmpty) { + sections.add( + _DimensionSection( + title: 'De votre côté', + values: mySideValues, + emphasis: true, + ), + ); + } + + // Elsewhere — things moving on the other side + final elsewhereValues = unique(item.elsewhere); + if (elsewhereValues.isNotEmpty) { + sections.add( + _DimensionSection(title: 'Ailleurs', values: elsewhereValues), + ); + } + + // Next — what follows + final nextValues = unique(item.next); + if (nextValues.isNotEmpty) { + sections.add(_DimensionSection(title: 'Ensuite', values: nextValues)); + } + + // Waiting — hourglass row + if (item.waiting != null) { + final waitingText = unique([item.waiting!]); + if (waitingText.isNotEmpty) { + sections.add( + Padding( + padding: const EdgeInsets.only(top: MakoloSpacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.hourglass_top_outlined, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: MakoloSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'En attente', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: MakoloSpacing.xs), + Text(waitingText.first), + ], + ), + ), + ], + ), + ), + ); + } + } + + // Blocker — attention block with warning styling + if (item.blocker != null) { + final blockerText = unique([item.blocker!]); + if (blockerText.isNotEmpty) { + sections.add( + Padding( + padding: const EdgeInsets.only(top: MakoloSpacing.lg), + child: MakoloAttentionBlock( + title: 'Ce qui bloque', + body: blockerText.first, + icon: Icons.block_outlined, + ), + ), + ); + } + } + + // Unknown — to verify + if (item.unknown != null) { + final unknownText = unique([item.unknown!]); + if (unknownText.isNotEmpty) { + sections.add( + _DimensionSection(title: 'À vérifier', values: unknownText), + ); } } - add('Déjà réglé', item.settled); - add('De votre côté', item.mySide); - add('Ailleurs', item.elsewhere); - add('Ensuite', item.next); - if (item.waiting != null) add('En attente', [item.waiting!]); - if (item.blocker != null) add('Ce qui bloque', [item.blocker!]); - if (item.unknown != null) add('À vérifier', [item.unknown!]); return sections; } @@ -382,7 +481,7 @@ class _OngoingFocus extends StatelessWidget { style: Theme.of(context).textTheme.bodyLarge, ), ], - ..._detailSections(), + ..._detailSections(context), if (metadata.isNotEmpty) Padding( padding: const EdgeInsets.only(top: MakoloSpacing.lg), @@ -396,9 +495,14 @@ class _OngoingFocus extends StatelessWidget { } class _DimensionSection extends StatelessWidget { - const _DimensionSection({required this.title, required this.values}); + const _DimensionSection({ + required this.title, + required this.values, + this.emphasis = false, + }); final String title; final List values; + final bool emphasis; @override Widget build(BuildContext context) => Padding( @@ -406,7 +510,12 @@ class _DimensionSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.titleLarge), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: emphasis ? Theme.of(context).colorScheme.primary : null, + ), + ), const SizedBox(height: MakoloSpacing.sm), for (final value in values) ...[ Text(value, style: Theme.of(context).textTheme.bodyLarge), diff --git a/mobile/lib/features/space/space_insight_screen.dart b/mobile/lib/features/space/space_insight_screen.dart index 40d37bb96..a67de1bc2 100644 --- a/mobile/lib/features/space/space_insight_screen.dart +++ b/mobile/lib/features/space/space_insight_screen.dart @@ -1,11 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../app/runtime/actor_context.dart'; import '../../app/runtime/app_runtime.dart'; import '../../data/local/profile_store.dart'; +import '../../design/behavior_states.dart'; +import '../../design/surface_states.dart'; +import '../../presentation/contracts/space_presentation.dart'; +import '../../presentation/projection_surface_adapter.dart'; +import '../../sync/freshness.dart'; import '../../sync/owner_source_state.dart'; +import 'space_insight_surfaces.dart'; import 'space_repository.dart'; class SpaceInsightScreen extends StatefulWidget { @@ -27,6 +34,7 @@ class _SpaceInsightScreenState extends State { Map? results; bool searchFailed = false; int searchVersion = 0; + bool refreshing = false; SpaceActorContext? get actor { final current = widget.runtime.actorContext?.value; @@ -47,12 +55,32 @@ class _SpaceInsightScreenState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant SpaceInsightScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.runtime.actorContext != widget.runtime.actorContext) { + oldWidget.runtime.actorContext?.removeListener(onActorChanged); + widget.runtime.actorContext?.addListener(onActorChanged); + } + if (oldWidget.runtime != widget.runtime || + oldWidget.surface != widget.surface) { + searchVersion++; + results = null; + searchFailed = false; + searchController.clear(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(refresh()); + }); + } + } + void onActorChanged() { if (!mounted) return; searchVersion++; setState(() { results = null; searchFailed = false; + searchController.clear(); }); unawaited(refresh()); } @@ -61,6 +89,7 @@ class _SpaceInsightScreenState extends State { final space = actor?.space; final repository = widget.runtime.space; if (space == null || repository == null) return; + setState(() => refreshing = true); try { if (widget.surface == SpaceProjectionKind.relationships) { await repository.refreshRelationships(space); @@ -69,6 +98,8 @@ class _SpaceInsightScreenState extends State { } } on Object { // The source state decides whether cached data remains usable. + } finally { + if (mounted) setState(() => refreshing = false); } } @@ -126,6 +157,7 @@ class _SpaceInsightScreenState extends State { return Scaffold( appBar: AppBar(title: Text(title)), body: StreamBuilder( + key: ValueKey('${space.id}:${widget.surface.name}'), stream: repository.watchSource(source), initialData: OwnerSourceState.unknown, builder: (context, sourceSnapshot) { @@ -143,9 +175,54 @@ class _SpaceInsightScreenState extends State { builder: (context, snapshot) { final payload = snapshot.data?.payload; if (payload == null) { - return const Center(child: Text('Projection non disponible.')); + return refreshing + ? const Center(child: CircularProgressIndicator()) + : MakoloErrorState( + message: 'Projection non disponible.', + onRetry: refresh, + ); } - return relations ? relationsBody(payload) : pilotBody(payload); + final freshness = WorkspaceContextRepository.surfaceFreshness + .evaluate(snapshot.data!, now: DateTime.now()); + if (freshness == FreshnessState.expired) { + return MakoloErrorState( + message: 'Cette observation a expiré.', + onRetry: refresh, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (refreshing) const LinearProgressIndicator(), + MakoloFreshnessNotice( + freshness: const ProjectionSurfaceAdapter().freshnessCue( + freshness, + ), + ), + if (status?.lastErrorCode != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + 'Actualisation indisponible. Observation conservée du ${snapshot.data!.receivedAt.toLocal()}', + ), + ), + TextButton( + onPressed: refresh, + child: const Text('Réessayer'), + ), + ], + ), + ), + Expanded( + child: relations + ? relationsBody(payload) + : SpacePilotSurface(payload: payload), + ), + ], + ); }, ); }, @@ -156,20 +233,25 @@ class _SpaceInsightScreenState extends State { Widget relationsBody(Map payload) { final sections = payload['sections']; if (sections is! Map || sections.isEmpty) { - return const Center( - child: Text('Aucune relation visible dans ce contexte.'), + return const MakoloEmptyState( + title: 'Relations non autorisées ou indisponibles dans ce contexte.', + icon: Icons.lock_outline, ); } final query = searchController.text.trim(); final matches = results?['items']; - final rows = >[]; + final groups = >>{}; if (query.isNotEmpty && matches is List) { + final rows = >[]; for (final item in matches) { if (item is Map) rows.add(Map.from(item)); } + groups['Résultats'] = rows; } else { - for (final section in sections.values) { + for (final entry in sections.entries) { + final section = entry.value; if (section is! Map || section['items'] is! List) continue; + final rows = >[]; for (final item in section['items'] as List) { if (item is Map) { final row = Map.from(item); @@ -179,19 +261,27 @@ class _SpaceInsightScreenState extends State { } } } + groups[spaceRelationshipSectionLabels[entry.key] ?? 'Relations'] = rows; } } return ListView( key: const Key('space-relationships-secondary'), padding: const EdgeInsets.all(20), children: [ - const Text('Relations conservées dans leurs domaines propriétaires.'), + Text( + spaceText(payload['label']) ?? 'Personnes & relations', + style: Theme.of(context).textTheme.headlineSmall, + ), const SizedBox(height: 12), TextField( controller: searchController, + maxLength: 120, onChanged: (_) { searchVersion++; - setState(() => results = null); + setState(() { + results = null; + searchFailed = false; + }); }, onSubmitted: (_) => search(), decoration: InputDecoration( @@ -207,14 +297,37 @@ class _SpaceInsightScreenState extends State { const Text( 'Recherche distante indisponible. Résultats locaux partiels.', ), - if (rows.isEmpty) + if (query.isNotEmpty && results == null) + const Text( + 'Aperçu local partiel. Lancez la recherche pour consulter toutes les relations autorisées.', + ), + if (groups.values.every((rows) => rows.isEmpty)) const Text( 'Aucune relation correspondante dans les données visibles.', ), - for (final row in rows) - ListTile( - title: Text(labelOf(row)), - subtitle: Text(row['relation_type']?.toString() ?? 'Relation'), + for (final entry in groups.entries) ...[ + if (entry.value.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + entry.key, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + for (final row in entry.value) + SpaceRelationshipCard( + row: row, + onOpen: _relationRoute(row) == null + ? null + : () => context.push(_relationRoute(row)!), + ), + ], + if (query.isEmpty && + sections.values.any( + (section) => section is Map && section['has_more'] == true, + )) + const Text( + 'Aperçu partiel. Recherchez pour retrouver les autres relations.', ), if (results?['has_more'] == true) const Text('Autres résultats possibles : affinez la recherche.'), @@ -222,6 +335,12 @@ class _SpaceInsightScreenState extends State { ); } + String? _relationRoute(Map row) => spaceOwnerRoute( + slug: actor?.space.slug, + kind: spaceText(row['kind']), + id: spaceText(row['id']), + ); + String labelOf(Map row) { final profile = row['profile']; if (row['identity'] is String) return row['identity'] as String; @@ -230,53 +349,4 @@ class _SpaceInsightScreenState extends State { } return row['name']?.toString() ?? row['label']?.toString() ?? 'Relation'; } - - Widget pilotBody(Map payload) { - final sections = payload['sections']; - final analytics = sections is Map ? sections['analytics'] : null; - if (analytics is! Map) { - return const Center( - child: Text('Pilotage non autorisé ou indisponible.'), - ); - } - final signals = analytics['signals'] is List - ? analytics['signals'] as List - : const []; - final metrics = analytics['metrics'] is List - ? analytics['metrics'] as List - : const []; - return ListView( - key: const Key('space-pilot-secondary'), - padding: const EdgeInsets.all(20), - children: [ - Text('Piloter', style: Theme.of(context).textTheme.headlineSmall), - const Text('Que savons-nous ? Que faut-il examiner ?'), - const SizedBox(height: 20), - if (signals.isEmpty) - const Text('Aucun signal interprétable à ce stade.'), - for (final signal in signals) - if (signal is Map) - ListTile( - title: Text(signal['title']?.toString() ?? 'Observation'), - subtitle: Text(signal['summary']?.toString() ?? ''), - ), - const SizedBox(height: 12), - ExpansionTile( - title: const Text('Mesures de soutien'), - children: [ - for (final metric in metrics) - if (metric is Map) - ListTile( - title: Text(metric['key']?.toString() ?? 'Mesure'), - subtitle: Text( - metric['state'] == 'known' - ? '${metric['value']}' - : metric['state']?.toString() ?? 'Inconnu', - ), - ), - ], - ), - ], - ); - } } diff --git a/mobile/lib/features/space/space_insight_surfaces.dart b/mobile/lib/features/space/space_insight_surfaces.dart new file mode 100644 index 000000000..7703f24c9 --- /dev/null +++ b/mobile/lib/features/space/space_insight_surfaces.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; + +import '../../design/behavior_states.dart'; +import '../../design/makolo_components.dart'; +import '../../design/makolo_patterns.dart'; +import '../../design/makolo_theme.dart'; +import '../../presentation/contracts/space_presentation.dart'; + +class SpacePilotSurface extends StatelessWidget { + const SpacePilotSurface({super.key, required this.payload}); + final Map payload; + + @override + Widget build(BuildContext context) { + final analytics = spaceMap(spaceMap(payload['sections'])?['analytics']); + if (analytics == null) { + return const MakoloEmptyState( + title: 'Pilotage non autorisé ou indisponible.', + icon: Icons.lock_outline, + ); + } + final signals = analytics['signals'] is List + ? analytics['signals'] as List + : const []; + final metrics = analytics['metrics'] is List + ? analytics['metrics'] as List + : const []; + final coverage = spaceMap(analytics['coverage']); + final generated = spaceText(analytics['generated_at']); + final period = spaceText(analytics['period']); + final money = + spaceMap(payload['capabilities'])?['view_financials'] == true && + analytics['money'] is List + ? analytics['money'] as List + : const []; + return ListView( + key: const PageStorageKey('space-pilot-secondary'), + padding: const EdgeInsets.all(MakoloSpacing.lg), + children: [ + const MakoloDetailHeader( + title: 'Piloter', + subtitle: 'Que faut-il examiner ?', + ), + Text( + 'Source : Analytics', + style: Theme.of(context).textTheme.labelLarge, + ), + if (coverage?['kind'] == 'latest_visible_events' && + coverage?['limit'] is num) + Text( + 'Couverture : les ${coverage!['limit']} derniers événements visibles au maximum.', + ), + if (generated != null) Text('Observation : $generated'), + Text( + period == null + ? 'Période de comparaison non établie.' + : 'Période : $period', + ), + const SizedBox(height: MakoloSpacing.md), + if (analytics['state'] == 'insufficient_data') + const MakoloEmptyState( + title: 'Données insuffisantes', + body: 'Les observations disponibles ne permettent pas encore d’interpréter le fonctionnement.', + ), + if (signals.isEmpty && analytics['state'] != 'insufficient_data') + const Text( + 'Aucun signal interprétable dans les observations disponibles.', + ), + for (final value in signals) + if (spaceMap(value) case final Map signal) + Padding( + padding: const EdgeInsets.only(bottom: MakoloSpacing.md), + child: MakoloCard( + child: ExpansionTile( + title: Text(spaceText(signal['title']) ?? 'Observation'), + subtitle: spaceText(signal['summary']) == null + ? null + : Text(spaceText(signal['summary'])!), + childrenPadding: const EdgeInsets.all(MakoloSpacing.md), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (spaceText(signal['why']) case final String why) + Text(why), + const SizedBox(height: MakoloSpacing.sm), + if (spaceMap(signal['evidence']) + case final Map evidence) + Text( + '${spaceMetricLabels[evidence['metric_key']] ?? 'Mesure observée'} : ${evidence['value'] ?? 'Inconnu'}', + ), + if (spaceText(signal['owner']) case final String owner) + Text('Source : $owner'), + if (spaceText(signal['uncertainty']) + case final String uncertainty) + Padding( + padding: const EdgeInsets.only(top: MakoloSpacing.sm), + child: MakoloAttentionBlock( + title: 'Limites de cette observation', + body: uncertainty, + icon: Icons.info_outline, + ), + ), + ], + ), + ), + ), + ExpansionTile( + title: const Text('Mesures de soutien'), + children: [ + for (final value in metrics) + if (spaceMap(value) case final Map metric) + ListTile( + title: Text(spaceMetricLabels[metric['key']] ?? 'Mesure'), + subtitle: Text(spaceMetricValue(metric)), + ), + ], + ), + if (money.isNotEmpty) + ExpansionTile( + title: const Text('Montants par devise'), + children: [ + for (final value in money) + if (spaceMap(value) case final Map row) + ListTile( + title: Text( + spaceText(row['currency']) ?? 'Devise non précisée', + ), + subtitle: Text( + 'Brut : ${row['gross'] ?? 'Inconnu'} · Remboursements : ${row['refunds'] ?? 'Inconnu'} · Net : ${row['net'] ?? 'Inconnu'}', + ), + ), + ], + ), + ], + ); + } +} + +class SpaceRelationshipCard extends StatelessWidget { + const SpaceRelationshipCard({super.key, required this.row, this.onOpen}); + final Map row; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + final profile = spaceMap(row['profile']); + final title = + spaceText(row['identity']) ?? + spaceText(profile?['name']) ?? + spaceText(row['name']) ?? + spaceText(row['label']) ?? + 'Relation'; + final relation = spaceText(row['relation_type']) ?? 'Relation'; + final owner = spaceText(row['owner_identity']); + final state = spaceText(row['status']); + final icon = switch (row['kind']) { + 'team_member' => Icons.badge_outlined, + 'group' || 'audience' => Icons.groups_outlined, + 'partner' => Icons.handshake_outlined, + _ => Icons.person_outline, + }; + return Padding( + padding: const EdgeInsets.only(bottom: MakoloSpacing.sm), + child: MakoloCard( + onTap: onOpen, + semanticLabel: '$title · $relation', + child: Padding( + padding: const EdgeInsets.all(MakoloSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon), + const SizedBox(width: MakoloSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + Text(relation), + if (owner != null && owner != title) Text(owner), + if (state != null) + MakoloStatus(label: spaceStateLabel(state)), + ], + ), + ), + if (onOpen != null) const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} + +const spaceRelationshipSectionLabels = { + 'team': 'Équipe', + 'groups': 'Groupes', + 'crm_contacts': 'Contacts', + 'audiences': 'Audiences', + 'partners': 'Partenaires', +}; diff --git a/mobile/lib/features/space/space_shell_routes.dart b/mobile/lib/features/space/space_shell_routes.dart index 0b68d2148..9407a91b8 100644 --- a/mobile/lib/features/space/space_shell_routes.dart +++ b/mobile/lib/features/space/space_shell_routes.dart @@ -11,6 +11,9 @@ import '../../design/makolo_components.dart'; import '../../design/makolo_theme.dart'; import '../../navigation/secondary_screen.dart'; import '../../sync/owner_source_state.dart'; +import '../../design/surface_states.dart'; +import '../../presentation/projection_surface_adapter.dart'; +import '../../sync/freshness.dart'; import 'space_repository.dart'; import 'space_work_surface.dart'; @@ -147,17 +150,87 @@ class _SpaceShellRootScreenState extends State { return const SizedBox.shrink(); } - return StreamBuilder( - key: ValueKey( - '${widget.surface.name}:${actor.space.id}:' - '${SpaceSyncKeys.perspectiveKey(actor.perspective)}', + final source = switch (widget.surface) { + SpaceShellSurface.now => repository.nowSource( + actor.space, + actor.perspective, ), - stream: _stream(repository, actor), - builder: (context, snapshot) => _SpaceProjectionBody( - surface: widget.surface, - projection: snapshot.data, - fallbackSpaceName: actor.space.slug, + SpaceShellSurface.discover => repository.discoverSource(actor.space), + SpaceShellSurface.work => repository.workSource( + actor.space, + actor.perspective, ), + SpaceShellSurface.us => repository.usSource(actor.space), + }; + return StreamBuilder( + key: ValueKey(source.sourceKey), + stream: repository.watchSource(source), + builder: (context, status) { + if (status.data?.invalidated == true) { + return const MakoloEmptyState( + title: 'Autorité Space révoquée ou indisponible.', + icon: Icons.lock_outline, + ); + } + return StreamBuilder( + key: ValueKey( + '${widget.surface.name}:${actor.space.id}:' + '${SpaceSyncKeys.perspectiveKey(actor.perspective)}', + ), + stream: _stream(repository, actor), + builder: (context, snapshot) { + final projection = snapshot.data; + final freshness = projection == null + ? null + : WorkspaceContextRepository.surfaceFreshness.evaluate( + projection, + now: DateTime.now(), + ); + if (freshness == FreshnessState.expired) { + return MakoloErrorState( + message: 'Cette observation a expiré.', + onRetry: _refresh, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MakoloFreshnessNotice( + freshness: const ProjectionSurfaceAdapter().freshnessCue( + freshness, + ), + ), + if (status.data?.lastErrorCode != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + projection == null + ? 'Actualisation indisponible.' + : 'Actualisation indisponible. Observation conservée du ${projection.receivedAt.toLocal()}', + ), + ), + TextButton( + onPressed: _refresh, + child: const Text('Réessayer'), + ), + ], + ), + ), + Expanded( + child: _SpaceProjectionBody( + surface: widget.surface, + projection: snapshot.data, + fallbackSpaceName: actor.space.slug, + ), + ), + ], + ); + }, + ); + }, ); } } @@ -200,11 +273,15 @@ class _SpaceProjectionBody extends StatelessWidget { }) { final selection = payload['selection']; final state = selection is Map ? selection['state'] : null; - if (state == 'unavailable') { + if (state != 'established' && state != 'available' && state != 'partial') { return _message('Cette vue n’est pas disponible pour le moment.'); } final items = payload['items']; - if (items is! List || items.isEmpty) return _message(emptyMessage); + if (items is! List || items.isEmpty) { + return _message( + state == 'established' || state == 'available' ? emptyMessage : 'La sélection est partielle. Aucun élément connu dans cet aperçu.', + ); + } return _message( '${items.length} élément${items.length > 1 ? 's' : ''} à consulter.', ); diff --git a/mobile/lib/features/space/space_work_collection_screen.dart b/mobile/lib/features/space/space_work_collection_screen.dart new file mode 100644 index 000000000..633af8000 --- /dev/null +++ b/mobile/lib/features/space/space_work_collection_screen.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../app/runtime/actor_context.dart'; +import '../../app/runtime/app_runtime.dart'; +import '../../design/behavior_states.dart'; +import '../../network/api_error.dart'; +import '../../presentation/contracts/space_presentation.dart'; +import 'space_work_surface.dart'; + +/// An ephemeral continuation of the existing work projection. Every page is +/// authorized again; it never replaces the synced root snapshot. +class SpaceWorkCollectionScreen extends StatefulWidget { + const SpaceWorkCollectionScreen({ + super.key, + required this.runtime, + required this.actor, + required this.section, + }); + final AppRuntime runtime; + final SpaceActorContext actor; + final String section; + @override + State createState() => + _SpaceWorkCollectionScreenState(); +} + +class _SpaceWorkCollectionScreenState extends State { + Map? payload; + bool loading = false; + bool failed = false; + bool denied = false; + int version = 0; + int nextOffset = 0; + bool get actorValid => widget.runtime.actorContext?.value == widget.actor; + + @override + void initState() { + super.initState(); + widget.runtime.actorContext?.addListener(onActorChanged); + unawaited(loadPage()); + } + + @override + void dispose() { + version++; + widget.runtime.actorContext?.removeListener(onActorChanged); + super.dispose(); + } + + @override + void didUpdateWidget(covariant SpaceWorkCollectionScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.runtime.actorContext != widget.runtime.actorContext) { + oldWidget.runtime.actorContext?.removeListener(onActorChanged); + widget.runtime.actorContext?.addListener(onActorChanged); + } + if (oldWidget.runtime != widget.runtime || + oldWidget.actor != widget.actor || + oldWidget.section != widget.section) { + onActorChanged(); + } + } + + void onActorChanged() { + version++; + if (!mounted) return; + setState(() { + payload = null; + nextOffset = 0; + loading = false; + failed = false; + denied = false; + }); + if (actorValid) unawaited(loadPage()); + } + + Future loadPage() async { + if (loading || !actorValid) return; + final currentVersion = ++version; + final offset = nextOffset; + setState(() { + loading = true; + failed = false; + }); + try { + final api = widget.runtime.api; + if (api == null) throw StateError('Owner API unavailable'); + final actor = widget.actor; + final path = Uri( + path: + 'api/v1/organizations/workspaces/${Uri.encodeComponent(actor.space.slug)}/work/', + queryParameters: { + 'section': widget.section, + 'offset': '$offset', + if (!actor.perspective.isAll) 'responsibility': actor.perspective.id!, + }, + ).toString(); + final data = (await api.get(path)).jsonObject(); + if (!mounted || currentVersion != version || !actorValid) return; + final sections = spaceMap(data['sections']); + final section = spaceMap(sections?[widget.section]); + final page = spaceMap(data['page']); + if (spaceMap(data['space'])?['id'] != actor.space.id || + data['responsibility'] != (actor.perspective.id ?? 'all') || + section == null || + section['items'] is! List || + page?['offset'] != offset || + page?['limit'] is! int) { + throw const FormatException( + 'Work page does not match actor or section', + ); + } + final previous = spaceMap( + spaceMap(payload?['sections'])?[widget.section], + ); + final items = >{}; + for (final row in [ + ...?previous?['items'] as List?, + ...section['items'] as List, + ]) { + final item = spaceMap(row); + final source = spaceMap(item?['source']); + if (item != null && + source?['kind'] is String && + source?['id'] is String) { + items['${source!['kind']}:${source['id']}'] = item; + } + } + setState(() { + payload = { + ...data, + 'sections': { + widget.section: {...section, 'items': items.values.toList()}, + }, + }; + nextOffset = offset + (page!['limit'] as int); + denied = false; + }); + } on Object catch (error) { + if (!mounted || currentVersion != version) return; + setState(() { + failed = true; + denied = + error is MakoloApiError && + const {401, 403, 404}.contains(error.statusCode); + if (denied) payload = null; + }); + } finally { + if (mounted && currentVersion == version) setState(() => loading = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(spaceText(payload?['primary_business_label']) ?? 'Métier'), + ), + body: !actorValid || denied + ? const MakoloEmptyState( + title: 'Cette collection n’est plus accessible dans le contexte actuel.', + icon: Icons.lock_outline, + ) + : Column( + children: [ + if (loading) const LinearProgressIndicator(), + if (failed) + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Expanded( + child: Text( + 'La suite est indisponible. Les éléments déjà consultés sont conservés.', + ), + ), + TextButton( + onPressed: loadPage, + child: const Text('Réessayer'), + ), + ], + ), + ), + Expanded( + child: payload == null + ? loading + ? const Center(child: CircularProgressIndicator()) + : MakoloErrorState( + message: 'Collection indisponible.', + onRetry: loadPage, + ) + : SpaceWorkSurface( + payload: payload!, + onLoadMore: (_) { + if (!loading) unawaited(loadPage()); + }, + ), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/space/space_work_surface.dart b/mobile/lib/features/space/space_work_surface.dart index 6641d88e3..38e384d17 100644 --- a/mobile/lib/features/space/space_work_surface.dart +++ b/mobile/lib/features/space/space_work_surface.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../design/behavior_states.dart'; +import '../../design/makolo_components.dart'; import '../../design/makolo_theme.dart'; +import '../../presentation/contracts/space_presentation.dart'; class SpaceWorkSurface extends StatelessWidget { - const SpaceWorkSurface({super.key, required this.payload}); + const SpaceWorkSurface({super.key, required this.payload, this.onLoadMore}); final Map payload; + final void Function(String section)? onLoadMore; @override Widget build(BuildContext context) { @@ -28,8 +31,10 @@ class SpaceWorkSurface extends StatelessWidget { if (section == null || items is! List || items.isEmpty) continue; visible.add( _WorkSection( + key: entry.key, label: _string(section['representation']) ?? entry.key, items: items.whereType().map(_copyMap).toList(growable: false), + hasMore: section['has_more'] == true, ), ); } @@ -66,6 +71,24 @@ class SpaceWorkSurface extends StatelessWidget { ), const SizedBox(height: MakoloSpacing.sm), for (final item in section.items) _item(context, item), + if (section.hasMore) ...[ + const Text('Aperçu partiel : d’autres éléments existent.'), + if (onLoadMore != null || + _string(_map(payload['space'])?['slug']) != null) + TextButton( + onPressed: () { + if (onLoadMore != null) { + onLoadMore!(section.key); + } else { + final slug = _string(_map(payload['space'])?['slug'])!; + context.push( + '/space/${Uri.encodeComponent(slug)}/work/${Uri.encodeComponent(section.key)}', + ); + } + }, + child: const Text('Afficher la suite'), + ), + ], const SizedBox(height: MakoloSpacing.lg), ], ], @@ -74,43 +97,157 @@ class SpaceWorkSurface extends StatelessWidget { Widget _item(BuildContext context, Map item) { final title = _string(item['title']) ?? 'Élément'; - final subtitle = _subtitle(item); + final summary = _string(item['summary']); final source = _map(item['source']); + final kind = _string(item['kind']) ?? _string(source?['kind']); final capabilities = item['capabilities']; final canOpenDayOf = source?['kind'] == 'occurrence' && source?['id'] is String && capabilities is List && capabilities.contains('open_day_of'); - return ListTile( - contentPadding: EdgeInsets.zero, - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle), - trailing: canOpenDayOf ? const Icon(Icons.chevron_right_rounded) : null, - onTap: canOpenDayOf - ? () => context.push('/space/occurrences/${source!['id']}/day-of') - : null, - ); - } - String? _subtitle(Map item) { - final parts = []; - final summary = _string(item['summary']); - if (summary != null) parts.add(summary); + final metadataItems = []; final timing = _map(item['timing']); final day = _string(timing?['start_date']); final clock = _string(timing?['start_time']); if (day != null) { - parts.add(clock == null ? day : '$day · $clock'); + metadataItems.add( + MakoloMetadataItem( + clock == null ? day : '$day · $clock', + icon: Icons.schedule_outlined, + ), + ); } final until = _string(timing?['available_until']); - if (until != null) parts.add('Jusqu’au $until'); + if (until != null) { + metadataItems.add( + MakoloMetadataItem( + 'Jusqu’au $until', + icon: Icons.event_available_outlined, + ), + ); + } final itemContext = _map(item['context']); final passengerCapacity = itemContext?['passenger_capacity']; if (passengerCapacity is num) { - parts.add('${passengerCapacity.toInt()} places'); + metadataItems.add( + MakoloMetadataItem( + '${passengerCapacity.toInt()} places', + icon: Icons.airline_seat_recline_normal_outlined, + ), + ); + } + + final state = _string(item['state']); + final activity = _map(itemContext?['activity']); + final activityTitle = _string(activity?['title']); + final place = _string(_map(itemContext?['place'])?['name']); + if (place != null) { + metadataItems.add(MakoloMetadataItem(place, icon: Icons.place_outlined)); + } + final pricing = _map(itemContext?['pricing']); + final amount = _string(pricing?['unit_price']); + final currency = _string(pricing?['currency']); + if (amount != null && currency != null) { + metadataItems.add( + MakoloMetadataItem( + 'Prix unitaire : $amount $currency', + icon: Icons.payments_outlined, + ), + ); } - return parts.isEmpty ? null : parts.join('\n'); + final route = capabilities is List && capabilities.contains('view') + ? spaceOwnerRoute( + slug: _string(_map(payload['space'])?['slug']), + kind: _string(source?['kind']), + id: _string(source?['id']), + ) + : null; + + return Padding( + padding: const EdgeInsets.only(bottom: MakoloSpacing.sm), + child: MakoloCard( + onTap: route != null + ? () => context.push(route) + : canOpenDayOf + ? () => context.push('/space/occurrences/${source!['id']}/day-of') + : null, + semanticLabel: title, + padding: const EdgeInsets.all(MakoloSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2, right: MakoloSpacing.md), + child: Icon( + _archetypeIcon(kind), + color: Theme.of(context).colorScheme.primary, + size: 22, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + if (summary != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + Text( + summary, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + if (metadataItems.isNotEmpty) ...[ + const SizedBox(height: MakoloSpacing.sm), + MakoloMetadata(items: metadataItems), + ], + if (state != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + MakoloStatus(label: spaceStateLabel(state)), + ], + if (activityTitle != null && activityTitle != title) + Text(activityTitle), + if (canOpenDayOf) + TextButton.icon( + onPressed: () => context.push( + '/space/occurrences/${Uri.encodeComponent(source!['id'] as String)}/day-of', + ), + icon: const Icon(Icons.play_circle_outline), + label: const Text('Jour J'), + ), + ], + ), + ), + if (route != null || canOpenDayOf) + Padding( + padding: const EdgeInsets.only(left: MakoloSpacing.sm, top: 2), + child: Icon( + Icons.chevron_right_rounded, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ); + } + + IconData _archetypeIcon(String? kind) { + return switch (kind) { + 'departure' || 'transport_route' => Icons.directions_bus_outlined, + 'occurrence' => Icons.schedule_outlined, + 'activity' => Icons.local_activity_outlined, + 'offer' || 'order' => Icons.shopping_bag_outlined, + 'journey' => Icons.timeline_outlined, + _ => Icons.work_outline_rounded, + }; } Widget _message(String message) => ListView( @@ -120,9 +257,16 @@ class SpaceWorkSurface extends StatelessWidget { } class _WorkSection { - const _WorkSection({required this.label, required this.items}); + const _WorkSection({ + required this.key, + required this.label, + required this.items, + required this.hasMore, + }); final String label; + final String key; final List> items; + final bool hasMore; } Map? _map(Object? value) { diff --git a/mobile/lib/presentation/contracts/now_presentation.dart b/mobile/lib/presentation/contracts/now_presentation.dart index 0d5ab3109..d12966657 100644 --- a/mobile/lib/presentation/contracts/now_presentation.dart +++ b/mobile/lib/presentation/contracts/now_presentation.dart @@ -180,4 +180,37 @@ class NowSituationPresentation { final String? knowledgeState; final MakoloFreshnessCue freshness; final StructuredDestination? ownerDestination; + + NowTopology get topology => topologyFor(this); +} + +enum NowTopology { meaning, media, action, waiting, composition } + +NowTopology topologyFor(NowSituationPresentation situation) { + if (situation.mediaBindings.any((media) => media.canDominate)) { + return NowTopology.media; + } + if (situation.businessActions.any((action) => action.canDominate)) { + return NowTopology.action; + } + // A relation alone is not a composed Now situation: require a present + // consequence and an explicit explanation from the owning projection. + final memberIds = situation.relationMembers.map((item) => item.id).toSet(); + final hasRealRelation = situation.relations.any((relation) { + final linked = relation.memberIds.toSet().intersection(memberIds); + return relation.kind.trim().isNotEmpty && linked.length >= 2; + }); + if (memberIds.length >= 2 && + hasRealRelation && + situation.whyNow?.trim().isNotEmpty == true && + situation.consequence?.trim().isNotEmpty == true && + situation.responseType?.trim().isNotEmpty == true) { + return NowTopology.composition; + } + // Waiting is a response owned by the projection, not an inferred status. + final response = situation.responseType?.toLowerCase(); + if (response == 'wait' || response == 'monitor' || response == 'waiting') { + return NowTopology.waiting; + } + return NowTopology.meaning; } diff --git a/mobile/lib/presentation/contracts/space_presentation.dart b/mobile/lib/presentation/contracts/space_presentation.dart new file mode 100644 index 000000000..6bce71144 --- /dev/null +++ b/mobile/lib/presentation/contracts/space_presentation.dart @@ -0,0 +1,105 @@ +/// Ephemeral language and navigation assembled from authorized owner data. +/// No business state or permission is inferred here. +String? spaceText(Object? value) => + value is String && value.trim().isNotEmpty ? value.trim() : null; + +Map? spaceMap(Object? value) => + value is Map ? Map.from(value) : null; + +String spaceStateLabel(String state) => + const { + 'draft': 'À préparer', + 'published': 'Publié', + 'scheduled': 'Prévu', + 'active': 'Actif', + 'inactive': 'Inactif', + 'completed': 'Terminé', + 'archived': 'Archivé', + 'cancelled': 'Annulé', + 'expired': 'Expiré', + 'submitted': 'Soumis', + 'pending': 'En attente', + 'pending_approval': 'En attente de validation', + 'pending_payment': 'En attente de paiement', + 'approved': 'Validé', + 'confirmed': 'Confirmé', + 'in_progress': 'En cours', + 'fulfilled': 'Accompli', + 'rejected': 'Refusé', + 'refunded': 'Remboursé', + 'unknown': 'Inconnu', + 'unavailable': 'Indisponible', + 'insufficient_data': 'Données insuffisantes', + }[state] ?? + state; + +const spaceMetricLabels = { + 'events_count': 'Événements couverts', + 'published_count': 'Événements publiés', + 'upcoming_count': 'Événements à venir', + 'active_tickets': 'Accès actifs', + 'used_tickets': 'Accès utilisés', + 'confirmed_orders': 'Commandes confirmées', + 'waitlist_waiting': 'Demandes en liste d’attente', + 'attendance_percent': 'Présence observée', +}; + +String spaceMetricValue(Map metric) { + final state = spaceText(metric['state']) ?? 'unknown'; + if (state != 'known' || metric['value'] == null) { + return spaceStateLabel(state == 'known' ? 'unknown' : state); + } + return '${metric['value']}${metric['unit'] == 'percent' ? ' %' : ''}'; +} + +String? spaceOwnerRoute({ + required String? slug, + required String? kind, + required String? id, +}) { + if (slug == null || + id == null || + !const { + 'activity', + 'occurrence', + 'offer', + 'journey', + 'commerce_order', + 'transport_route', + 'vehicle', + 'team_member', + 'group', + 'crm_contact', + 'audience', + 'partner', + }.contains(kind)) { + return null; + } + return '/space/${Uri.encodeComponent(slug)}/retrieval/' + '${Uri.encodeComponent(kind!)}/${Uri.encodeComponent(id)}'; +} + +/// Keep planned dates, availability and lifecycle instants distinct. +List spaceTimingLines(Object? value) { + final timing = spaceMap(value); + if (timing == null) return const []; + final day = spaceText(timing['start_date']); + final clock = spaceText(timing['start_time']); + final zone = spaceText(timing['timezone']); + return [ + if (day != null) + 'Prévu le $day${clock == null ? '' : ' à $clock'}' + '${zone == null ? '' : ' · $zone'}', + for (final entry in const { + 'end_date': 'Fin prévue', + 'available_from': 'Disponible à partir du', + 'available_until': 'Disponible jusqu’au', + 'expires_at': 'Expire le', + 'started_at': 'Commencé le', + 'fulfilled_at': 'Accompli le', + 'confirmed_at': 'Confirmé le', + }.entries) + if (spaceText(timing[entry.key]) case final String instant) + '${entry.value} $instant', + ]; +} diff --git a/mobile/lib/presentation/mps/mps_native_renderer.dart b/mobile/lib/presentation/mps/mps_native_renderer.dart index cab02d9f5..679db61a2 100644 --- a/mobile/lib/presentation/mps/mps_native_renderer.dart +++ b/mobile/lib/presentation/mps/mps_native_renderer.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../design/makolo_mark.dart'; import '../../design/makolo_theme.dart'; +import '../../design/presentation_media.dart'; import 'mps_models.dart'; class MpsNativeRenderer extends StatelessWidget { @@ -9,12 +10,16 @@ class MpsNativeRenderer extends StatelessWidget { super.key, required this.package, this.onOpenCredential, + this.mediaBaseUri, + this.mediaScopeKey, }); static const rendererVersion = 1; final MpsPresentationPackage package; final VoidCallback? onOpenCredential; + final Uri? mediaBaseUri; + final String? mediaScopeKey; @override Widget build(BuildContext context) { @@ -168,6 +173,21 @@ class MpsNativeRenderer extends StatelessWidget { case 'Hero': case 'Image': case 'OrganizerMark': + final raw = _value(props['image'] ?? props['src']); + if (raw.isEmpty) return const SizedBox.shrink(); + final uri = mpsContextImageUri(raw, mediaBaseUri); + final alt = mpsString(props['alt']) ?? 'Image de présentation'; + if (uri != null && mediaScopeKey != null) { + return MakoloMediaFrame( + aspect: MakoloMediaAspect.landscape, + semanticLabel: alt, + child: _MpsContextImage( + key: ValueKey('$mediaScopeKey:$uri'), + uri: uri, + alt: alt, + ), + ); + } return AspectRatio( aspectRatio: 16 / 9, child: DecoratedBox( @@ -213,6 +233,79 @@ class MpsNativeRenderer extends StatelessWidget { } } +/// MPS supplies the authorized binding. Only same-origin HTTP representations +/// are requested here, without bearer headers or a persistent media cache. +Uri? mpsContextImageUri(String raw, Uri? base) { + if (raw.isEmpty || + base == null || + !const {'http', 'https'}.contains(base.scheme)) { + return null; + } + final candidate = Uri.tryParse(raw); + if (candidate == null || + candidate.userInfo.isNotEmpty || + candidate.pathSegments.any((part) => part == '..' || part == '.')) { + return null; + } + final uri = base.resolveUri(candidate); + if (!const {'http', 'https'}.contains(uri.scheme) || + uri.origin != base.origin) { + return null; + } + return uri; +} + +class _MpsContextImage extends StatefulWidget { + const _MpsContextImage({super.key, required this.uri, required this.alt}); + final Uri uri; + final String alt; + @override + State<_MpsContextImage> createState() => _MpsContextImageState(); +} + +class _MpsContextImageState extends State<_MpsContextImage> { + late final NetworkImage image; + int attempt = 0; + Future retry() async { + await image.evict(); + if (mounted) setState(() => attempt++); + } + + @override + void initState() { + super.initState(); + image = NetworkImage(widget.uri.toString()); + } + + @override + void dispose() { + // The owner screen unmounts this representation on actor/source changes. + image.evict(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Image( + key: ValueKey(attempt), + image: image, + fit: BoxFit.cover, + excludeFromSemantics: true, + loadingBuilder: (context, child, progress) => progress == null + ? child + : const MakoloMediaPlaceholder(label: 'Chargement du média…'), + errorBuilder: (context, error, trace) => Stack( + fit: StackFit.expand, + children: [ + MakoloMediaPlaceholder(label: '${widget.alt} · Média indisponible'), + Align( + alignment: Alignment.bottomCenter, + child: TextButton(onPressed: retry, child: const Text('Réessayer')), + ), + ], + ), + ); +} + class _InfoBlock extends StatelessWidget { const _InfoBlock({ required this.icon, diff --git a/mobile/test/goldens/now/s5-min-100.png b/mobile/test/goldens/now/s5-min-100.png index 30877b1eb..90c4b0145 100644 Binary files a/mobile/test/goldens/now/s5-min-100.png and b/mobile/test/goldens/now/s5-min-100.png differ diff --git a/mobile/test/goldens/now/s5-min-130.png b/mobile/test/goldens/now/s5-min-130.png index 0d4763d4e..45a223bce 100644 Binary files a/mobile/test/goldens/now/s5-min-130.png and b/mobile/test/goldens/now/s5-min-130.png differ diff --git a/mobile/test/goldens/now/s5-min-160.png b/mobile/test/goldens/now/s5-min-160.png index 458512c1a..ca7cf40fe 100644 Binary files a/mobile/test/goldens/now/s5-min-160.png and b/mobile/test/goldens/now/s5-min-160.png differ diff --git a/mobile/test/goldens/now/s5-rich-100.png b/mobile/test/goldens/now/s5-rich-100.png index c96fa8e07..20c950a85 100644 Binary files a/mobile/test/goldens/now/s5-rich-100.png and b/mobile/test/goldens/now/s5-rich-100.png differ diff --git a/mobile/test/mps_native_renderer_test.dart b/mobile/test/mps_native_renderer_test.dart index 0d91e65eb..93070f601 100644 --- a/mobile/test/mps_native_renderer_test.dart +++ b/mobile/test/mps_native_renderer_test.dart @@ -1,9 +1,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:makolo_mobile/design/makolo_theme.dart'; import 'package:makolo_mobile/presentation/mps/mps_models.dart'; import 'package:makolo_mobile/presentation/mps/mps_native_renderer.dart'; void main() { + test('MPS context images resolve same-origin representations without credentials', () { + final base = Uri.parse('https://makolo.invalid/'); + expect( + mpsContextImageUri( + '/media/presentations/assets/cover.png', + base, + )?.toString(), + 'https://makolo.invalid/media/presentations/assets/cover.png', + ); + expect( + mpsContextImageUri('https://tracker.invalid/pixel.png', base), + isNull, + ); + expect( + mpsContextImageUri( + 'https://user:password@makolo.invalid/image.png', + base, + ), + isNull, + ); + expect(mpsContextImageUri('../private.png', base), isNull); + expect(mpsContextImageUri('file:///private.png', base), isNull); + expect(mpsContextImageUri('', base), isNull); + }); MpsArtifact artifact({ Set capabilities = const {}, Map links = const {}, @@ -60,6 +85,68 @@ void main() { expect(find.text('Valide'), findsOneWidget); }); + testWidgets( + 'authorized MPS image is requested and failures remain recoverable', + (tester) async { + final original = artifact(); + final value = MpsPresentationPackage( + artifact: MpsArtifact( + resourceKey: original.resourceKey, + purpose: original.purpose, + template: original.template, + theme: original.theme, + context: { + ...original.context, + 'editorial': {'hero_image': '/media/cover.png'}, + }, + capabilities: const {}, + links: const {}, + rendererContract: 'mps.native.v1', + minimumRendererVersion: 1, + ), + manifest: const { + 'layout': { + 'component': 'Page', + 'children': [ + { + 'component': 'Hero', + 'props': { + 'image': {'binding': 'editorial.hero_image'}, + 'alt': 'Couverture du programme', + }, + }, + ], + }, + }, + themeTokens: mpsEssentialTheme, + usedFallback: false, + ); + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: Scaffold( + body: MpsNativeRenderer( + package: value, + mediaBaseUri: Uri.parse('https://makolo.invalid/'), + mediaScopeKey: 'viewer:space-a', + ), + ), + ), + ); + final image = tester.widget(find.byType(Image)); + expect( + (image.image as NetworkImage).url, + 'https://makolo.invalid/media/cover.png', + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Média indisponible'), findsOneWidget); + expect(find.text('Réessayer'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('QRCode remains a revalidated owner action', (tester) async { var opened = false; final value = MpsPresentationPackage( diff --git a/mobile/test/space_mature_composition_test.dart b/mobile/test/space_mature_composition_test.dart new file mode 100644 index 000000000..231477046 --- /dev/null +++ b/mobile/test/space_mature_composition_test.dart @@ -0,0 +1,394 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:makolo_mobile/app/router.dart'; +import 'package:makolo_mobile/app/runtime/actor_context.dart'; +import 'package:makolo_mobile/app/runtime/actor_context_controller.dart'; +import 'package:makolo_mobile/app/runtime/app_runtime.dart'; +import 'package:makolo_mobile/app/session_recovery.dart'; +import 'package:makolo_mobile/auth/token_store.dart'; +import 'package:makolo_mobile/design/makolo_theme.dart'; +import 'package:makolo_mobile/features/space/space_insight_surfaces.dart'; +import 'package:makolo_mobile/features/space/space_work_surface.dart'; +import 'package:makolo_mobile/features/space/space_work_collection_screen.dart'; +import 'package:makolo_mobile/network/makolo_api_client.dart'; +import 'package:makolo_mobile/presentation/contracts/space_presentation.dart'; + +import 'dio_testing.dart'; +import 'fakes.dart'; + +class _ActorStore implements ActorContextStore { + ActorContext value = const PersonalActorContext(); + @override + Future readActorContext(String profileId) async => value; + @override + Future writeActorContext(String profileId, ActorContext context) async { + value = context; + } + + @override + Future removeActorContext(String profileId) async { + value = const PersonalActorContext(); + } +} + +const _pilot = { + 'capabilities': {'view_financials': false}, + 'sections': { + 'analytics': { + 'state': 'known', + 'generated_at': '2026-10-12T08:00:00Z', + 'coverage': {'kind': 'latest_visible_events', 'limit': 40}, + 'signals': [ + { + 'title': 'Des personnes attendent une place disponible.', + 'summary': '3 demandes dans le portefeuille couvert.', + 'why': 'Vérifier les possibilités de délivrance.', + 'owner': 'Analytics', + 'evidence': {'metric_key': 'waitlist_waiting', 'value': 3}, + 'uncertainty': 'Ce constat ne prouve pas une saturation globale.', + }, + ], + 'metrics': [ + { + 'key': 'active_tickets', + 'state': 'known', + 'value': 0, + 'unit': 'count', + }, + { + 'key': 'attendance_percent', + 'state': 'insufficient_data', + 'value': null, + }, + ], + 'money': [ + {'currency': 'USD', 'gross': '10', 'refunds': '0', 'net': '10'}, + ], + }, + }, +}; + +void main() { + testWidgets( + 'work continuation retries the same page and drops revoked content', + (tester) async { + final actors = await ActorContextController.restore( + profileId: 'viewer', + store: _ActorStore(), + ); + addTearDown(actors.dispose); + final space = SpaceActorIdentity(id: 'space-a', slug: 'atelier'); + await actors.selectSpace( + space, + perspective: ActorPerspective.opaque('mandate:viewer'), + ); + var attempts = 0; + var revoked = false; + final client = MockClient((request) async { + expect(request.url.queryParameters['responsibility'], 'mandate:viewer'); + final offset = int.parse(request.url.queryParameters['offset']!); + if (offset == 20 && ++attempts == 1) { + return const MockResponse('{"detail":"Unavailable"}', 503); + } + if (revoked) return const MockResponse('{"detail":"Not found"}', 404); + return MockResponse( + jsonEncode({ + 'space': {'id': 'space-a', 'slug': 'atelier'}, + 'responsibility': 'mandate:viewer', + 'primary_business_label': 'Créations', + 'page': {'offset': offset, 'limit': 20}, + 'sections': { + 'preparation': { + 'representation': 'À préparer', + 'has_more': true, + 'items': [ + { + 'title': 'Œuvre A', + 'source': {'kind': 'activity', 'id': 'a'}, + 'capabilities': ['view'], + }, + if (offset > 0) + { + 'title': 'Œuvre B', + 'source': {'kind': 'activity', 'id': 'b'}, + 'capabilities': ['view'], + }, + ], + }, + }, + }), + 200, + ); + }); + final tokens = MemoryTokenStore( + session: const AuthSession( + accessToken: 'test', + refreshToken: 'test', + profileId: 'viewer', + ), + ); + final runtime = AppRuntime( + tokens: tokens, + session: null, + recovery: SessionRecoveryController(), + actorContext: actors, + api: MakoloApiClient( + baseUri: Uri.parse('https://makolo.invalid/'), + dio: client.dio, + tokenStore: tokens, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: SpaceWorkCollectionScreen( + runtime: runtime, + actor: actors.value as SpaceActorContext, + section: 'preparation', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Œuvre A'), findsOneWidget); + await tester.tap(find.text('Afficher la suite')); + await tester.pumpAndSettle(); + expect(find.textContaining('La suite est indisponible'), findsOneWidget); + expect(find.text('Œuvre A'), findsOneWidget); + await tester.tap(find.text('Réessayer')); + await tester.pumpAndSettle(); + expect(find.text('Œuvre A'), findsOneWidget); + expect(find.text('Œuvre B'), findsOneWidget); + revoked = true; + await tester.tap(find.text('Afficher la suite')); + await tester.pumpAndSettle(); + expect(find.text('Œuvre A'), findsNothing); + expect(find.textContaining('n’est plus accessible'), findsOneWidget); + }, + ); + test( + 'knowledge labels preserve zero and distinguish unknown from insufficient', + () { + expect(spaceMetricValue({'state': 'known', 'value': 0}), '0'); + expect(spaceMetricValue({'state': 'known', 'value': null}), 'Inconnu'); + expect( + spaceMetricValue({'state': 'insufficient_data'}), + 'Données insuffisantes', + ); + expect( + spaceTimingLines({ + 'start_date': '2026-10-12', + 'start_time': '14:00', + 'timezone': 'Africa/Lubumbashi', + }).single, + contains('Africa/Lubumbashi'), + ); + expect( + spaceOwnerRoute(slug: 'a', kind: 'private_secret', id: '1'), + isNull, + ); + }, + ); + + for (final width in [360.0, 800.0, 1280.0]) { + testWidgets('Pilot evidence remains readable at $width with large text', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 1000), + textScaler: const TextScaler.linear(1.6), + ), + child: const Scaffold(body: SpacePilotSurface(payload: _pilot)), + ), + ), + ); + expect(find.textContaining('40 derniers événements'), findsOneWidget); + expect(find.text('Période de comparaison non établie.'), findsOneWidget); + await tester.tap( + find.text('Des personnes attendent une place disponible.'), + ); + await tester.pumpAndSettle(); + expect(find.text('Demandes en liste d’attente : 3'), findsOneWidget); + expect( + find.text('Ce constat ne prouve pas une saturation globale.'), + findsOneWidget, + ); + expect(find.text('Montants par devise'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'two owner relations to one Profile remain independently recognizable', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: Scaffold( + body: ListView( + children: [ + SpaceRelationshipCard( + row: const { + 'kind': 'team_member', + 'profile': {'id': 'same', 'name': 'Alex'}, + 'relation_type': 'Collaborateur · Équipe', + 'owner_identity': 'Équipe terrain', + }, + onOpen: () {}, + ), + SpaceRelationshipCard( + row: const { + 'kind': 'crm_contact', + 'profile': {'id': 'same', 'name': 'Alex'}, + 'relation_type': 'Contact CRM', + 'owner_identity': 'Contact terrain', + }, + onOpen: () {}, + ), + ], + ), + ), + ), + ); + expect(find.text('Alex'), findsNWidgets(2)); + expect(find.text('Collaborateur · Équipe'), findsOneWidget); + expect(find.text('Contact CRM'), findsOneWidget); + }, + ); + + for (final label in [ + 'Activités', + 'Commerce', + 'Créations', + 'Initiatives', + 'Prestations', + 'Productions', + 'Programmes', + 'Transport', + ]) { + testWidgets('$label work opens the existing owner destination', ( + tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + body: SpaceWorkSurface( + payload: { + 'space': {'slug': 'atelier'}, + 'primary_business_label': label, + 'sections': { + 'preparation': { + 'representation': 'À préparer', + 'has_more': true, + 'items': [ + { + 'title': 'Travail concret', + 'source': {'kind': 'activity', 'id': 'a-1'}, + 'capabilities': ['view'], + }, + ], + }, + }, + }, + ), + ), + ), + GoRoute( + path: '/space/:slug/retrieval/:kind/:id', + builder: (_, state) => + Scaffold(body: Text('Owner ${state.pathParameters['id']}')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router(theme: buildMakoloTheme(), routerConfig: router), + ); + expect(find.textContaining('Aperçu partiel'), findsOneWidget); + await tester.tap(find.text('Travail concret')); + await tester.pumpAndSettle(); + expect(find.text('Owner a-1'), findsOneWidget); + }); + } + + testWidgets( + 'production owner route revalidates responsibility and clears data on actor switch', + (tester) async { + final actors = await ActorContextController.restore( + profileId: 'viewer', + store: _ActorStore(), + ); + addTearDown(actors.dispose); + final space = SpaceActorIdentity(id: 'space-a', slug: 'atelier'); + await actors.selectSpace(space); + final client = MockClient((request) async { + expect( + request.url.path, + '/api/v1/organizations/workspaces/atelier/work/', + ); + expect(request.url.queryParameters['kind'], 'activity'); + expect(request.url.queryParameters['id'], 'a-1'); + return MockResponse( + jsonEncode({ + 'space': {'id': 'space-a'}, + 'primary_business_label': 'Créations', + 'selection': { + 'source': {'kind': 'activity', 'id': 'a-1'}, + 'title': 'Œuvre autorisée', + 'state': 'draft', + 'timing': {}, + 'context': {}, + }, + }), + 200, + ); + }); + final tokens = MemoryTokenStore( + session: const AuthSession( + accessToken: 'test', + refreshToken: 'test', + profileId: 'viewer', + ), + ); + final runtime = AppRuntime( + tokens: tokens, + session: tokens.session, + recovery: SessionRecoveryController() + ..restoreLaunchLocation('/space/atelier/retrieval/activity/a-1'), + actorContext: actors, + api: MakoloApiClient( + baseUri: Uri.parse('https://makolo.invalid/'), + dio: client.dio, + tokenStore: tokens, + ), + ); + final router = createMakoloRouter( + runtime, + onAuthenticationChanged: () {}, + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router(theme: buildMakoloTheme(), routerConfig: router), + ); + await tester.pumpAndSettle(); + expect(find.text('Œuvre autorisée'), findsOneWidget); + expect(find.text('À préparer'), findsOneWidget); + await actors.selectPersonal(); + await tester.pumpAndSettle(); + expect(find.text('Œuvre autorisée'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/organizations/api/space_work_projection.py b/organizations/api/space_work_projection.py index 7734bc260..ec87769eb 100644 --- a/organizations/api/space_work_projection.py +++ b/organizations/api/space_work_projection.py @@ -5,6 +5,7 @@ from django.utils import timezone from activities.models import Activity, ActivityStatus, Occurrence, OccurrenceStatus +from activities.selectors import primary_place_for_occurrence from authorization.constants import PermissionCode from authorization.models import AuthorityScope from authorization.selectors import ( @@ -106,6 +107,7 @@ def _occurrence_item(occurrence, caps): is_departure = hasattr(occurrence, "transport_departure") operation_visible = occurrence.activity_id in caps["operations"] current_day_of = operation_visible and occurrence.is_ongoing + place = primary_place_for_occurrence(occurrence) links = { "detail": f"/api/v1/occurrences/{occurrence.pk}/", "activity": f"/api/v1/activities/{occurrence.activity_id}/", @@ -135,6 +137,7 @@ def _occurrence_item(occurrence, caps): }, "state": occurrence.status, "context": { + "place": {"id": str(place.pk), "name": place.name} if place else None, "activity": { "id": str(occurrence.activity_id), "title": occurrence.activity.title, @@ -158,6 +161,10 @@ def _offer_item(offer, caps): }, "state": offer.status, "context": { + "pricing": { + "unit_price": str(offer.unit_price), + "currency": offer.currency, + }, "activity": { "id": str(offer.activity_id), "title": offer.activity.title, @@ -242,7 +249,14 @@ def _route_item(route): "summary": None, "timing": {}, "state": "active" if route.active else "inactive", - "context": {}, + "context": { + "stops": [ + {"id": str(stop.place_id), "name": stop.place.name, + "position": stop.position, "boarding_allowed": stop.boarding_allowed, + "alighting_allowed": stop.alighting_allowed} + for stop in route.stops.all() + ], + }, "links": {}, "capabilities": ["view"], } @@ -323,6 +337,12 @@ def _empty_sections(*, space, include_offers=False, include_transport=False, inc def _append(section, item): + if not section.get("_enabled", True): + return + seen = section.get("_seen", 0) + section["_seen"] = seen + 1 + if seen < section.get("_offset", 0): + return if len(section["items"]) < PREVIEW_LIMIT: section["items"].append(item) else: @@ -381,7 +401,22 @@ def _journey_section(journey): return None -def build_space_work_projection(*, profile, space, responsibility_key=None): +def _selected(queryset, kind, source_kind, source_id): + """Restrict an owner read to one identity before applying preview limits.""" + if not source_kind and not source_id: + return queryset + if source_kind != kind or not source_id: + return queryset.none() + try: + return queryset.filter(pk=source_id) + except (ValueError, DjangoValidationError): + return queryset.none() + + +def build_space_work_projection( + *, profile, space, responsibility_key=None, source_kind=None, source_id=None, + section_key=None, offset=0 +): visible_ids = _visible_activity_ids(profile, space) lens_ids = _activity_scope_from_responsibility( profile, space, responsibility_key @@ -396,6 +431,7 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): direct_portfolio = _space_has_activity_portfolio_access(profile, space) transport_visible = ( direct_portfolio + and lens_ids is None and "transport" in operational_footprint_for_space(space).signals ) commerce_visible = bool(visible_ids & caps["commerce"]) @@ -409,10 +445,16 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): include_transport=transport_visible, include_requests=education_visible or service_visible, ) + if section_key and section_key not in sections: + return None + for key, section in sections.items(): + section["_enabled"] = not section_key or key == section_key + section["_offset"] = offset if section_key else 0 now = timezone.now() today = timezone.localdate() activities = Activity.objects.filter(space=space, pk__in=visible_ids).order_by("title", "pk") + activities = _selected(activities, "activity", source_kind, source_id) for activity in activities: if activity.status == ActivityStatus.DRAFT: target = "preparation" @@ -427,8 +469,10 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): occurrences = ( Occurrence.objects.filter(activity__space=space, activity_id__in=visible_ids) .select_related("activity", "transport_departure") + .prefetch_related("place_links__place") .order_by("start_date", "start_time", "pk") ) + occurrences = _selected(occurrences, "occurrence", source_kind, source_id) for occurrence in occurrences: if occurrence.status == OccurrenceStatus.DRAFT: target = "preparation" @@ -448,11 +492,12 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _occurrence_item(occurrence, caps)) commerce_activity_ids = visible_ids & caps["commerce"] - for offer in ( + offers = ( Offer.objects.filter(activity_id__in=commerce_activity_ids) .select_related("activity") .order_by("activity_id", "name", "pk") - ): + ) + for offer in _selected(offers, "offer", source_kind, source_id): if offer.status == OfferStatus.DRAFT: target = "preparation" elif offer.status == OfferStatus.ACTIVE: @@ -472,6 +517,7 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): .select_related("journey__activity") .order_by("-created_at", "pk") ) + orders = _selected(orders, "commerce_order", source_kind, source_id) for order in orders: if order.status == CommerceOrderStatus.DRAFT: target = "preparation" @@ -488,7 +534,7 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _order_item(order, caps)) if education_ids: - for journey in ( + journeys = ( Journey.objects.filter( activity_id__in=education_ids, workflow=WorkflowKind.REGISTRATION, @@ -496,7 +542,8 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): .select_related("activity") .prefetch_related("blockers") .order_by("-created_at", "pk") - ): + ) + for journey in _selected(journeys, "journey", source_kind, source_id): if ( education_visible and journey.status in { @@ -511,12 +558,13 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _journey_item(journey, caps)) if service_ids: - for journey in ( + journeys = ( service_journeys_visible_to(profile) .filter(activity_id__in=service_ids) .select_related("activity") .prefetch_related("blockers") - ): + ) + for journey in _selected(journeys, "journey", source_kind, source_id): if ( service_visible and journey.status in { @@ -531,9 +579,11 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _journey_item(journey, caps)) if transport_visible: - for route in TransportRoute.objects.filter(space=space).order_by("name", "pk"): + routes = TransportRoute.objects.filter(space=space).prefetch_related("stops__place").order_by("name", "pk") + for route in _selected(routes, "transport_route", source_kind, source_id): _append(sections["routes"], _route_item(route)) - for vehicle in Vehicle.objects.filter(space=space).order_by("label", "pk"): + vehicles = Vehicle.objects.filter(space=space).order_by("label", "pk") + for vehicle in _selected(vehicles, "vehicle", source_kind, source_id): _append(sections["vehicles"], _vehicle_item(vehicle)) footprint = ( @@ -541,6 +591,12 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): if direct_portfolio else [] ) + for section in sections.values(): + section.pop("_enabled", None) + section.pop("_offset", None) + section.pop("_seen", None) + if section_key: + sections = {section_key: sections[section_key]} return { "space": { "id": str(space.pk), @@ -562,6 +618,11 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): "composition_state": "established", "coverage_state": "established", "sections": sections, + "page": {"offset": offset if section_key else 0, "limit": PREVIEW_LIMIT}, + "selection": next( + (item for section in sections.values() for item in section["items"]), + None, + ) if source_kind or source_id else None, "links": { "workspace": f"/api/v1/organizations/workspaces/{space.slug}/", }, diff --git a/organizations/api/space_work_views.py b/organizations/api/space_work_views.py index 861ef1523..9f1ccf91d 100644 --- a/organizations/api/space_work_views.py +++ b/organizations/api/space_work_views.py @@ -1,5 +1,5 @@ from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -19,10 +19,20 @@ def _space(self, request, slug): def get(self, request, slug): space = self._space(request, slug) + try: + offset = int(request.query_params.get("offset", "0")) + except ValueError: + raise ValidationError({"offset": "Décalage invalide."}) + if not 0 <= offset <= 10000: + raise ValidationError({"offset": "Décalage hors limites."}) payload = build_space_work_projection( profile=request.user, space=space, responsibility_key=(request.query_params.get("responsibility") or "").strip() or None, + source_kind=(request.query_params.get("kind") or "").strip()[:40] or None, + source_id=(request.query_params.get("id") or "").strip()[:80] or None, + section_key=(request.query_params.get("section") or "").strip()[:40] or None, + offset=offset, ) if payload is None: raise NotFound() diff --git a/organizations/test_zs3_space_work_api.py b/organizations/test_zs3_space_work_api.py index 28b770636..55e631c04 100644 --- a/organizations/test_zs3_space_work_api.py +++ b/organizations/test_zs3_space_work_api.py @@ -69,6 +69,56 @@ def _activity(self, title="Activité ZS3", **kwargs): **kwargs, ) + def test_owner_selection_revalidates_scope_and_works_beyond_preview(self): + self.client.force_authenticate(self.owner) + for index in range(21): + self._activity(f"A {index:02}") + selected = self._activity("Z selected") + response = self.client.get(self.url, {"kind": "activity", "id": str(selected.pk)}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["selection"]["source"]["id"], str(selected.pk)) + foreign = Organization.objects.create(name="Foreign", slug="foreign-work", created_by=self.owner) + hidden = Activity.objects.create(title="Hidden", space=foreign, created_by=self.owner) + for kind, identity in (("activity", hidden.pk), ("activity", "bad-id"), ("unknown", selected.pk)): + denied = self.client.get(self.url, {"kind": kind, "id": str(identity)}) + self.assertIsNone(denied.data["selection"]) + self.assertNotIn("Hidden", str(denied.data)) + + def test_work_section_continuation_does_not_repeat_preview_or_cross_owner(self): + self.client.force_authenticate(self.owner) + expected = [self._activity(f"A {index:02}") for index in range(23)] + first = self.client.get(self.url, {"section": "preparation", "offset": "0"}) + second = self.client.get(self.url, {"section": "preparation", "offset": "20"}) + self.assertEqual(set(second.data["sections"]), {"preparation"}) + preview = first.data["sections"]["preparation"] + continuation = second.data["sections"]["preparation"] + self.assertTrue(preview["has_more"]) + self.assertFalse(continuation["has_more"]) + identities = [item["source"]["id"] for item in preview["items"] + continuation["items"]] + self.assertEqual(identities, [str(activity.pk) for activity in expected]) + self.assertNotIn("_offset", str(second.data)) + self.assertEqual(self.client.get(self.url, {"offset": "-1"}).status_code, 400) + self.assertEqual(self.client.get(self.url, {"offset": "bad"}).status_code, 400) + self.assertEqual(self.client.get(self.url, {"section": "not-an-owner"}).status_code, 404) + + def test_activity_lens_does_not_expose_transport_structure_or_foreign_activity(self): + self.client.force_authenticate(self.owner) + self.space.archetype = SpaceArchetype.TRANSPORT_OPERATOR + self.space.save(update_fields=["archetype", "updated_at"]) + activity = self._activity("Scoped activity") + hidden = self._activity("Other activity") + mandate = grant_activity_role( + profile=self.owner, activity=activity, + role_code=SystemRoleCode.ACTIVITY_MANAGER, granted_by=self.owner, + ) + route = TransportRoute.objects.create(space=self.space, name="Route privée") + params = {"responsibility": f"mandate:{mandate.pk}"} + response = self.client.get(self.url, params) + self.assertNotIn("routes", response.data["sections"]) + for kind, identity in (("transport_route", route.pk), ("activity", hidden.pk)): + selected = self.client.get(self.url, {**params, "kind": kind, "id": str(identity)}) + self.assertIsNone(selected.data["selection"]) + def test_auth_and_minimal_disclosure_boundaries(self): self.assertEqual(self.client.get(self.url).status_code, 401) @@ -293,6 +343,8 @@ def test_commerce_offer_requires_activity_commerce_authority(self): ] self.assertEqual(len(offers), 1) self.assertEqual(offers[0]["title"], "Offre ZS3") + self.assertEqual(offers[0]["context"]["pricing"]["unit_price"], "0.00") + self.assertEqual(offers[0]["context"]["pricing"]["currency"], "USD") self.assertEqual(response.data["sections"]["preparation"]["representation"], "À traiter") self.assertEqual(response.data["sections"]["active"]["representation"], "Commandes actives") self.assertEqual(response.data["sections"]["offers"]["representation"], "Offres")