SPB Git

spb/focale Public

Swift 100%

Initial release: manual camera + queryable photo library (0.1.0 build 4)

Full foundation per CLAUDE.md build order: manual capture (ISO, shutter,
focus, WB, ProRAW, flash, timer, grid), capture context written to EXIF,
recipes with .focalerecipe export/import, two-stage indexer (Vision on
everything, Foundation Models on candidates only), natural-language
search with deterministic fallback, living albums, full-screen viewer
with favorites and feature-print similarity. Swift 6 strict concurrency;
UI regression test for the PhotoKit isolation crash family. Shipped to
TestFlight as 0.1.0 (1) through (4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 12, 2026)

Showing 52 changed files with +5,310 and −0

added .gitignore +17 −0
@@ -0,0 +1,17 @@
1 +# .gitignore — Focale
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +
5 +# Xcode project is generated from project.yml (xcodegen generate)
6 +Focale.xcodeproj/
7 +
8 +# Build artifacts
9 +build/
10 +DerivedData/
11 +*.xcarchive
12 +*.ipa
13 +
14 +# User state
15 +xcuserdata/
16 +*.xcuserstate
17 +.DS_Store
added CLAUDE.md +270 −0
@@ -0,0 +1,270 @@
1 +# CLAUDE.md — Focale
2 +
3 +Appareil photo manuel + photothèque interrogeable. SwiftUI + AVFoundation + PhotoKit + Vision + Foundation Models.
4 +Ce fichier est la source de vérité du projet. Le lire avant toute modification.
5 +
6 +---
7 +
8 +## 1. Thèse produit
9 +
10 +**Deux produits qui se sauvent mutuellement.**
11 +
12 +Un appareil photo pro seul, c'est un marché saturé (Halide, Kino, Obscura, Moment). Une photothèque IA seule, c'est 40 000 photos à rattraper — des dizaines d'heures d'inférence avant la première valeur.
13 +
14 +Ensemble, ils règlent le problème de l'autre : **une photo prise dans Focale arrive déjà comprise.** Le contexte est capturé au moment du déclenchement — intention, sujet, projet, réglages, lieu — gratuitement et sans ambiguïté. Pas de devinette, pas d'inférence coûteuse. La vieille bibliothèque se rattrape tranquillement en arrière-plan pendant que le produit est déjà utile.
15 +
16 +La promesse : *tout ce que tu photographies à partir d'aujourd'hui est parfaitement retrouvable.*
17 +
18 +Trois règles non négociables :
19 +
20 +1. **Rien ne quitte l'appareil. Jamais.** La photothèque de quelqu'un est ce qu'il possède de plus intime. Aucune escalade cloud, même optionnelle, même sur les métadonnées. C'est l'argument de vente central — le renier une fois tue le produit.
21 +2. **Aucune photo n'est dupliquée, déplacée ou modifiée.** Focale indexe la photothèque système par `localIdentifier`. L'utilisateur peut désinstaller demain sans rien perdre.
22 +3. **Utile avant que l'index soit complet.** Les résultats arrivent progressivement, du plus récent au plus ancien.
23 +
24 +### Positionnement de l'appareil photo
25 +
26 +**Contrôle et intention, jamais « meilleures photos ».** Le mode Nuit n'est pas accessible aux apps tierces (voir §2) — en basse lumière, l'app Camera d'Apple gagnera toujours. Ne jamais promettre le contraire. Focale s'adresse à qui veut décider, pas à qui veut que ça marche tout seul.
27 +
28 +---
29 +
30 +## 2. Contraintes — lire avant de promettre quoi que ce soit
31 +
32 +### Ce qui est accessible
33 +
34 +| Capacité | API | Note |
35 +|---|---|---|
36 +| ISO, obturation, focus, WB manuels | `AVCaptureDevice` | verrouillage complet, contrôle continu |
37 +| Choix d'objectif, zoom | `AVCaptureDevice.DiscoverySession` | incl. ultra grand-angle, télé |
38 +| ProRAW | `isAppleProRAWSupported``isAppleProRAWEnabled` | iPhone 12 Pro+, DNG linéaire |
39 +| RAW Bayer | `availableRawPhotoPixelFormatTypes` | vérifier le format, pas garanti partout |
40 +| Apple Log, ProRes | `AVCaptureDevice.activeFormat` | Pro seulement |
41 +| Bouton Camera Control | iPhone 16+ | lancement + réglage assignable |
42 +| Bouton Action | iPhone 15 Pro+ | lancement direct |
43 +| Interface de capture | 100 % personnalisable | aucune contrainte |
44 +
45 +### Ce qui n'est PAS accessible
46 +
47 +- **Mode Nuit.** Jamais exposé aux apps tierces. Aucun contournement. Assumer ce déficit dans le positionnement.
48 +- **Mode Portrait complet** d'Apple. La profondeur est disponible (`AVCaptureDepthDataOutput`), le rendu d'Apple ne l'est pas.
49 +- **Deep Fusion / Smart HDR** s'appliquent dans le pipeline standard, mais sans contrôle fin. Le contourner est un *choix* de positionnement, comme Halide — pas un défaut à cacher.
50 +- **Remplacer Photos comme app par défaut.** Impossible.
51 +
52 +### Foundation Models
53 +
54 +- **A17 Pro minimum.** Sur appareil non compatible, Focale reste un excellent appareil photo manuel avec une recherche Vision-only. **Ce mode dégradé doit être bon, pas frustrant.**
55 +- Contexte 4096 / 8192 tokens. On ne passe jamais de longues listes de photos au modèle. Voir §5.
56 +- **Inférence sérialisée sur le Neural Engine.** Les sessions parallèles s'exécutent en série. C'est LA contrainte du projet.
57 +- Vision on-device (iOS 27) : image en entrée directe dans le prompt.
58 +
59 +### PhotoKit — le risque produit le plus sous-estimé
60 +
61 +Focale exige `.authorized` (**Accès complet**). En accès limité, le produit ne fonctionne pas du tout.
62 +
63 +iOS relance périodiquement l'utilisateur pour l'inciter à réduire l'accès. Conséquences obligatoires :
64 +
65 +- L'onboarding explique **pourquoi** avant d'afficher le dialogue système. Un dialogue nu = permission refusée = installation perdue.
66 +- Détecter `.limited` et afficher un écran explicatif dédié, pas un message d'erreur.
67 +- Le fait que rien ne sorte de l'appareil est l'argument qui débloque cette permission. Le dire à cet endroit précis.
68 +
69 +---
70 +
71 +## 3. Architecture
72 +
73 +```
74 +Focale/
75 +├─ App/
76 +├─ Capture/
77 +│ ├─ Session/ AVCaptureSession, configuration, cycle de vie
78 +│ ├─ Controls/ ISO, obturation, focus, WB — modèle + gestes
79 +│ ├─ Output/ ProRAW, HEIC, écriture PhotoKit
80 +│ ├─ Recipes/ presets utilisateur ⚠️ voir §6
81 +│ └─ Layout/ dispositions d'interface personnalisables ⚠️ voir §6
82 +├─ Intent/ contexte capturé au déclenchement ⚠️ cœur du projet
83 +├─ Index/
84 +│ ├─ Vision/ étage 1 — OCR, visages, feature prints
85 +│ ├─ Semantic/ étage 2 — Foundation Models, sur candidats seulement
86 +│ ├─ Store/ SwiftData + vecteurs
87 +│ └─ Scheduler/ BGProcessingTask, budget d'énergie
88 +├─ Search/ requête naturelle → filtre → classement
89 +├─ Library/ navigation, albums vivants, timeline
90 +└─ Design/ tokens, typographie, thèmes
91 +```
92 +
93 +---
94 +
95 +## 4. L'appareil photo
96 +
97 +### Contexte de capture — la pièce qui différencie tout le projet
98 +
99 +Au déclenchement, Focale enregistre bien plus que les EXIF, et **sans coût d'inférence** :
100 +
101 +```swift
102 +struct CaptureContext: Codable {
103 + let recipe: Recipe.ID? // preset actif
104 + let project: Project.ID? // projet déclaré par l'utilisateur
105 + let subjectHint: String? // dicté ou tapé avant/après la prise
106 + let settings: ManualSettings // ISO, vitesse, objectif, focus
107 + let scene: SceneSignal // luminosité, mouvement, distance (LiDAR si dispo)
108 + let burstRole: BurstRole? // sélection dans une rafale
109 + let place: PlaceRef? // lieu, pas coordonnées brutes
110 +}
111 +```
112 +
113 +Règles :
114 +
115 +- **Le contexte est écrit dans les métadonnées de l'actif** en plus de la base locale. Si l'utilisateur désinstalle, l'information reste dans sa photo.
116 +- **Le mode Projet** : l'utilisateur déclare « chantier cuisine », « voyage Gaspésie », « inventaire assurance ». Tout ce qui est pris ensuite est marqué. Un geste, une fois — puis zéro friction.
117 +- **Jamais de saisie obligatoire avant une photo.** Le déclencheur doit toujours répondre instantanément. Le contexte s'ajoute autour, jamais devant.
118 +
119 +### Règles de fer de la capture
120 +
121 +- **Le déclencheur est sacré.** Aucune inférence, aucun disque, aucune allocation dans le chemin du déclenchement. Tout est différé.
122 +- `AVCaptureSession` configurée hors du main thread, une seule fois. Reconfiguration en `beginConfiguration`/`commitConfiguration`.
123 +- Toujours interroger les capacités avant d'exposer un contrôle : `isAppleProRAWSupported`, `activeFormat.maxISO`, plages d'exposition. **Aucun contrôle affiché s'il n'est pas supporté par l'appareil** — pas de bouton mort.
124 +- Écriture PhotoKit via `PHAssetCreationRequest`, dans la photothèque système. Pas de bibliothèque parallèle, pas de sandbox privé.
125 +
126 +---
127 +
128 +## 5. L'indexeur — deux étages, obligatoirement
129 +
130 +**Le calcul à ne jamais oublier : 40 000 photos × 1 à 3 s d'inférence = 11 à 33 heures, en série.** Une passe Foundation Models sur toute la bibliothèque est impossible. L'architecture entière découle de ça.
131 +
132 +### Étage 1 — Vision framework, sur tout
133 +
134 +Rapide, mature, faible coût énergétique. Couvre environ 80 % des besoins de recherche réels.
135 +
136 +- `VNRecognizeTextRequest` — OCR. **Le gagnant silencieux** : reçus, factures, tableaux blancs, captures d'écran, panneaux, cartes d'affaires. C'est ce que les gens cherchent le plus et que Photos trouve le plus mal.
137 +- `VNGenerateImageFeaturePrintRequest` — empreinte visuelle, gratuite. Donne la similarité (« des photos comme celle-ci ») et le regroupement de quasi-doublons sans aucun LLM.
138 +- `VNClassifyImageRequest` — classification grossière.
139 +- Détection de visages — **regroupement local uniquement, jamais d'identification nommée sans action explicite de l'utilisateur.**
140 +
141 +### Étage 2 — Foundation Models, sur candidats seulement
142 +
143 +Déclenché uniquement quand :
144 +- la photo est prise dans Focale (contexte déjà présent → coût minime, valeur maximale) ;
145 +- l'utilisateur ouvre une photo et demande explicitement ;
146 +- une recherche ne trouve rien à l'étage 1 et il faut approfondir un sous-ensemble ;
147 +- la photo est marquée importante (favori, partagée, souvent ouverte).
148 +
149 +```swift
150 +@Generable
151 +struct PhotoSemantics {
152 + @Guide(description: "Ce que montre l'image, une phrase, factuelle")
153 + let gist: String
154 +
155 + @Guide(description: "Type de contenu")
156 + let kind: PhotoKind // document, reçu, personne, lieu, objet, écran, nourriture, animal, autre
157 +
158 + @Guide(description: "Éléments cherchables présents dans l'image", .count(0...8))
159 + let entities: [String]
160 +
161 + @Guide(description: "Vrai si l'image contient une information à conserver (montant, date, code, adresse)")
162 + let hasActionableInfo: Bool
163 +}
164 +```
165 +
166 +### Ordonnancement
167 +
168 +- **Du plus récent au plus ancien.** Toujours. Les gens cherchent surtout dans les 2 dernières années.
169 +- `BGProcessingTask` : en charge, Wi-Fi, écran éteint. Jamais sur batterie.
170 +- Reprise incrémentale après interruption, sans jamais repartir de zéro.
171 +- **Honnêteté dans l'interface** : « 12 400 / 41 000 photos indexées — les plus récentes sont déjà cherchables. Prêt dans environ 2 nuits. » Avec des résultats utilisables tout de suite. Un écran de chargement opaque de 20 heures est une désinstallation garantie.
172 +
173 +---
174 +
175 +## 6. Personnalisation — la promesse « ultra personnalisable »
176 +
177 +C'est la raison d'être de l'appareil photo. Elle doit aller plus loin que des sliders.
178 +
179 +### Recettes
180 +
181 +Un preset complet, nommé, rappelable en un geste : réglages manuels, format (ProRAW/HEIC), objectif, look, comportement du déclencheur, contexte à appliquer. Exemples : « Documents » (correction de perspective, OCR prioritaire), « Nuit longue pose », « Chantier » (projet auto, grand-angle, horodatage visible).
182 +
183 +- Recettes exportables et importables en fichier. Elles se partagent — c'est un canal d'acquisition gratuit.
184 +- Une recette peut se déclencher automatiquement sur signal de scène (basse lumière, document détecté), **avec une proposition, jamais un basculement silencieux.**
185 +
186 +### Disposition d'interface
187 +
188 +L'utilisateur place ses propres contrôles : quels réglages sont visibles, où, à quelle taille, quels gestes les pilotent. Trois profils fournis (Simple / Photographe / Expert), tous modifiables.
189 +
190 +- **Mapping des gestes** : glissement vertical gauche = ISO, horizontal = exposition, molette = focus… entièrement réassignable.
191 +- **Bouton Camera Control et bouton Action** assignables à une recette précise.
192 +- Retour haptique paramétrable par contrôle (crans à chaque tiers d'IL, par exemple).
193 +
194 +### Looks
195 +
196 +Rendus appliqués **en non-destructif**, jamais cuits dans l'original. Courbes, teinte, grain, virage. Toujours réversible, toujours avec l'original intact.
197 +
198 +### Règle qui encadre tout ça
199 +
200 +**Les valeurs par défaut doivent être excellentes.** La personnalisation est une profondeur pour ceux qui la cherchent, pas un devoir à l'installation. Le premier lancement doit donner une bonne photo en trois secondes, sans réglage. Une app qui exige d'être configurée avant d'être bonne perd la majorité de ses utilisateurs le premier jour.
201 +
202 +---
203 +
204 +## 7. Recherche
205 +
206 +- Entrée en langage naturel. Le modèle **analyse la requête** (petit prompt, coût dérisoire), il ne parcourt pas les photos.
207 +- La requête est convertie en filtre structuré : période, lieu, type, entités, texte OCR, similarité visuelle. Le filtre s'exécute sur la base locale — instantané, sur 40 000 photos comme sur 400.
208 +- Le classement combine : correspondance OCR, distance de feature print, contexte de capture, récence.
209 +- **Foundation Models n'intervient qu'au bout**, pour départager un petit ensemble de candidats si nécessaire.
210 +- Requêtes cibles à faire fonctionner parfaitement (à tester en continu) : « le reçu du garage l'automne passé », « la photo du tableau blanc de la réunion budget », « où j'ai stationné la semaine passée », « les photos comme celle-ci », « le numéro de série de la laveuse ».
211 +
212 +### Albums vivants
213 +
214 +Un album défini par une requête, pas par une sélection. « Tous mes reçus 2026 », « chantier cuisine », « documents avec une date d'expiration ». Il se remplit tout seul à mesure que tu photographies.
215 +
216 +---
217 +
218 +## 8. Honnêteté du modèle
219 +
220 +- **Une description générée est visuellement distincte d'une donnée réelle** (EXIF, OCR, lieu). Toujours.
221 +- **L'OCR prime sur le modèle** pour tout ce qui est chiffre, date, montant, code. Le modèle 3B peut halluciner un montant ; l'OCR non. Ne jamais afficher un chiffre venu du LLM comme un fait.
222 +- Sur incertitude, ne rien dire. Une photo mal comprise vaut mieux qu'une photo faussement étiquetée — l'utilisateur perdrait confiance dans tout l'index.
223 +- Aucune identification de personne sans action explicite. Aucun regroupement de visages nommé automatiquement.
224 +- Les garde-fous du modèle peuvent refuser des images légitimes. Traiter le refus comme un état normal : la photo reste indexée par l'étage 1, sans message d'erreur.
225 +
226 +---
227 +
228 +## 9. Performance & énergie
229 +
230 +- **Le déclencheur répond en moins de 50 ms, toujours.** C'est la métrique numéro un du produit. Mesurée à chaque build.
231 +- Aperçu à 60 fps sans chute pendant l'indexation. Si l'indexeur tourne, il est suspendu dès l'ouverture de la caméra.
232 +- File d'inférence à priorité : geste utilisateur > photo qu'on regarde > arrière-plan. Annulation immédiate au changement d'écran.
233 +- Budget thermique : surveiller `ProcessInfo.thermalState`, suspendre l'étage 2 dès `.fair`.
234 +- Taille de l'index : viser moins de 500 Mo pour 50 000 photos. Vignettes via PhotoKit, jamais stockées en double.
235 +
236 +---
237 +
238 +## 10. Conventions de code
239 +
240 +- SwiftUI, iOS 27+, `@Observable`. Swift 6, concurrence stricte.
241 +- `AVCaptureSession` sur sa propre file série, jamais sur le main actor. L'UI de capture est `@MainActor`.
242 +- **Jamais de `String` libre en sortie de modèle.** Toujours `@Generable`.
243 +- Toute capacité matérielle est vérifiée avant d'être exposée. Aucune supposition sur le modèle d'iPhone.
244 +- Chaque fonctionnalité IA a un chemin de repli Vision-only testé. La suite de tests tourne avec Foundation Models désactivé.
245 +- Nommage utilisateur en français ; code, commentaires et commits en anglais.
246 +
247 +---
248 +
249 +## 11. Ordre de construction
250 +
251 +1. **Appareil photo manuel excellent.** Contrôles, ProRAW, recettes, écriture PhotoKit. Zéro IA. Vendable seul dès cette étape — et c'est le test : si personne ne le veut ici, l'IA ne le sauvera pas.
252 +2. **Contexte de capture + projets.** Invisible, mais c'est la fondation de la recherche.
253 +3. **Étage 1 Vision sur les nouvelles photos**, puis rétroactif. L'OCR d'abord : c'est le gain le plus spectaculaire pour l'effort le plus faible.
254 +4. **Recherche naturelle + albums vivants.** Le moment « je ne peux plus revenir à Photos ».
255 +5. **Étage 2 Foundation Models** sur candidats.
256 +6. **Personnalisation avancée** : dispositions, mapping de gestes, looks, partage de recettes.
257 +
258 +---
259 +
260 +## 12. Risques connus
261 +
262 +| Risque | Réalité |
263 +|---|---|
264 +| Permission Accès complet refusée | Le risque numéro un. L'onboarding est un chantier produit à part entière, pas un écran |
265 +| Mode Nuit inaccessible | Ne jamais se positionner sur la basse lumière. Contrôle et intention |
266 +| 20-30 h d'indexation initiale | Transparence + valeur immédiate sur les photos récentes. Jamais d'attente opaque |
267 +| Marché des apps photo saturé | La photothèque est la différence. L'appareil photo seul ne suffit pas à se démarquer |
268 +| A17 Pro requis | Le mode Vision-only doit être un bon produit à part entière |
269 +| Batterie et chaleur | Une app photo qui chauffe pendant qu'on shoote est désinstallée le jour même |
270 +| Attentes sur la reconnaissance de personnes | Terrain sensible. Rester en retrait volontairement, et le dire |
added Design/AppIcon.svg +83 −0
@@ -0,0 +1,83 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!--
3 + AppIcon.svg — Focale
4 + Author: Simon-Pierre Boucher
5 + Contact: contact@spboucher.ai
6 +
7 + A six-blade aperture iris in warm amber over a deep charcoal lens.
8 + Control and intention — the aperture is the product.
9 +-->
10 +<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
11 + <defs>
12 + <!-- Background: deep charcoal with a subtle vertical lift -->
13 + <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
14 + <stop offset="0" stop-color="#20262e"/>
15 + <stop offset="0.55" stop-color="#14181d"/>
16 + <stop offset="1" stop-color="#0c0f12"/>
17 + </linearGradient>
18 + <!-- Soft glow behind the iris -->
19 + <radialGradient id="glow" cx="0.5" cy="0.46" r="0.55">
20 + <stop offset="0" stop-color="#ff8a1e" stop-opacity="0.22"/>
21 + <stop offset="0.55" stop-color="#ff8a1e" stop-opacity="0.06"/>
22 + <stop offset="1" stop-color="#ff8a1e" stop-opacity="0"/>
23 + </radialGradient>
24 + <!-- Aperture blade stroke. userSpaceOnUse: a plain <line> has a
25 + zero-area bounding box, and objectBoundingBox gradients on it
26 + are not rendered per the SVG spec. -->
27 + <linearGradient id="blade" gradientUnits="userSpaceOnUse"
28 + x1="212" y1="212" x2="812" y2="812">
29 + <stop offset="0" stop-color="#ffc36b"/>
30 + <stop offset="0.5" stop-color="#ff9e2e"/>
31 + <stop offset="1" stop-color="#f26d0c"/>
32 + </linearGradient>
33 + <!-- Lens ring -->
34 + <linearGradient id="ring" x1="0" y1="0" x2="0" y2="1">
35 + <stop offset="0" stop-color="#5a636e"/>
36 + <stop offset="0.5" stop-color="#343c45"/>
37 + <stop offset="1" stop-color="#232930"/>
38 + </linearGradient>
39 + <!-- Specular arc on the glass -->
40 + <linearGradient id="spec" x1="0" y1="0" x2="1" y2="1">
41 + <stop offset="0" stop-color="#ffffff" stop-opacity="0.35"/>
42 + <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
43 + </linearGradient>
44 + </defs>
45 +
46 + <!-- iOS squircle background (masked by the system; drawn for previews) -->
47 + <rect width="1024" height="1024" rx="232" fill="url(#bg)"/>
48 + <rect width="1024" height="1024" rx="232" fill="url(#glow)"/>
49 +
50 + <!-- Outer lens ring -->
51 + <circle cx="512" cy="512" r="396" fill="none" stroke="url(#ring)" stroke-width="26"/>
52 + <circle cx="512" cy="512" r="380" fill="none" stroke="#0a0c0e" stroke-width="7" opacity="0.8"/>
53 + <!-- Focus scale ticks on the ring -->
54 + <g stroke="#7d8894" stroke-width="6" stroke-linecap="round" opacity="0.55">
55 + <line x1="512" y1="100" x2="512" y2="128"/>
56 + <line x1="512" y1="896" x2="512" y2="924"/>
57 + <line x1="100" y1="512" x2="128" y2="512"/>
58 + <line x1="896" y1="512" x2="924" y2="512"/>
59 + <g transform="rotate(45 512 512)">
60 + <line x1="512" y1="100" x2="512" y2="128"/>
61 + <line x1="512" y1="896" x2="512" y2="924"/>
62 + <line x1="100" y1="512" x2="128" y2="512"/>
63 + <line x1="896" y1="512" x2="924" y2="512"/>
64 + </g>
65 + </g>
66 +
67 + <!-- Six-blade aperture iris. Each blade runs from a vertex of the
68 + inner hexagon (r=190) out to x=726. Tip clearance to the
69 + neighbor blade accounts for stroke width and the round cap, so
70 + blades nest without crossing. -->
71 + <g stroke="url(#blade)" stroke-width="54" stroke-linecap="round" fill="none">
72 + <line x1="482" y1="676.5" x2="726" y2="676.5"/>
73 + <line x1="482" y1="676.5" x2="726" y2="676.5" transform="rotate(60 512 512)"/>
74 + <line x1="482" y1="676.5" x2="726" y2="676.5" transform="rotate(120 512 512)"/>
75 + <line x1="482" y1="676.5" x2="726" y2="676.5" transform="rotate(180 512 512)"/>
76 + <line x1="482" y1="676.5" x2="726" y2="676.5" transform="rotate(240 512 512)"/>
77 + <line x1="482" y1="676.5" x2="726" y2="676.5" transform="rotate(300 512 512)"/>
78 + </g>
79 +
80 + <!-- Glass specular highlight -->
81 + <path d="M 220 330 A 350 350 0 0 1 500 165" fill="none"
82 + stroke="url(#spec)" stroke-width="30" stroke-linecap="round"/>
83 +</svg>
added ExportOptions.plist +16 −0
@@ -0,0 +1,16 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<plist version="1.0">
4 +<dict>
5 + <key>destination</key>
6 + <string>export</string>
7 + <key>manageAppVersionAndBuildNumber</key>
8 + <true/>
9 + <key>method</key>
10 + <string>app-store-connect</string>
11 + <key>teamID</key>
12 + <string>3YM54G49SN</string>
13 + <key>uploadSymbols</key>
14 + <true/>
15 +</dict>
16 +</plist>
added Focale/App/AppModel.swift +47 −0
@@ -0,0 +1,47 @@
1 +//
2 +// AppModel.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import Observation
11 +import Photos
12 +
13 +@MainActor
14 +@Observable
15 +final class AppModel {
16 +
17 + let camera = CameraModel()
18 + let recipes = RecipeStore()
19 + let projects = ProjectStore()
20 + let albums = LivingAlbumStore()
21 + let placeProvider = PlaceProvider()
22 + let indexPipeline = IndexPipeline(container: IndexStore.container)
23 + let searchEngine = SearchEngine(container: IndexStore.container)
24 +
25 + private(set) var photoAccess: PHAuthorizationStatus
26 +
27 + init() {
28 + photoAccess = PHPhotoLibrary.authorizationStatus(for: .readWrite)
29 + camera.recipes = recipes
30 + camera.projects = projects
31 + camera.indexPipeline = indexPipeline
32 + camera.placeProvider = placeProvider
33 + }
34 +
35 + /// Full access or nothing — in `.limited` the product cannot work
36 + /// (CLAUDE.md §2). `.limited` gets its own explanatory screen.
37 + var hasFullPhotoAccess: Bool { photoAccess == .authorized }
38 + var hasLimitedPhotoAccess: Bool { photoAccess == .limited }
39 +
40 + func requestPhotoAccess() async {
41 + photoAccess = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
42 + }
43 +
44 + func refreshPhotoAccess() {
45 + photoAccess = PHPhotoLibrary.authorizationStatus(for: .readWrite)
46 + }
47 +}
added Focale/App/FocaleApp.swift +32 −0
@@ -0,0 +1,32 @@
1 +//
2 +// FocaleApp.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +
9 +import SwiftData
10 +import SwiftUI
11 +
12 +@main
13 +struct FocaleApp: App {
14 + @State private var app: AppModel
15 +
16 + init() {
17 + let model = AppModel()
18 + _app = State(initialValue: model)
19 + // BGTask registration must happen before the app finishes launching.
20 + IndexScheduler.shared.register(pipeline: model.indexPipeline)
21 + }
22 +
23 + var body: some Scene {
24 + WindowGroup {
25 + RootView()
26 + .environment(app)
27 + .preferredColorScheme(.dark)
28 + .tint(DesignTokens.accent)
29 + }
30 + .modelContainer(IndexStore.container)
31 + }
32 +}
added Focale/App/OnboardingView.swift +122 −0
@@ -0,0 +1,122 @@
1 +//
2 +// OnboardingView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// The #1 product risk (CLAUDE.md §12): full photo access refused.
9 +// We explain *why* before showing the system dialog — a naked dialog
10 +// is a refused permission is a lost install. The fact that nothing
11 +// leaves the device is the argument, said at exactly this spot.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct OnboardingView: View {
17 + @Environment(AppModel.self) private var app
18 + @State private var isRequesting = false
19 +
20 + var body: some View {
21 + VStack(spacing: 28) {
22 + Spacer()
23 +
24 + Image(systemName: "camera.aperture")
25 + .font(.system(size: 64))
26 + .foregroundStyle(DesignTokens.accent)
27 +
28 + Text("Tout ce que tu photographies\ndevient retrouvable")
29 + .font(.title2.bold())
30 + .multilineTextAlignment(.center)
31 +
32 + VStack(alignment: .leading, spacing: 18) {
33 + explanationRow(
34 + symbol: "iphone",
35 + title: "Rien ne quitte ton iPhone. Jamais.",
36 + detail: "L'analyse se fait entièrement sur l'appareil. Aucun serveur, aucun compte, aucune option cloud."
37 + )
38 + explanationRow(
39 + symbol: "photo.stack",
40 + title: "Tes photos restent exactement où elles sont",
41 + detail: "Focale lit ta photothèque pour l'indexer. Aucune photo n'est copiée, déplacée ou modifiée."
42 + )
43 + explanationRow(
44 + symbol: "magnifyingglass",
45 + title: "L'accès complet, c'est ce qui rend la recherche possible",
46 + detail: "Reçus, tableaux blancs, numéros de série : pour les retrouver, Focale doit pouvoir voir toute ta photothèque."
47 + )
48 + }
49 + .padding(.horizontal, 8)
50 +
51 + Spacer()
52 +
53 + Button {
54 + isRequesting = true
55 + Task {
56 + await app.requestPhotoAccess()
57 + isRequesting = false
58 + }
59 + } label: {
60 + Text("Autoriser l'accès à mes photos")
61 + .font(.headline)
62 + .frame(maxWidth: .infinity)
63 + .padding(.vertical, 6)
64 + }
65 + .buttonStyle(.borderedProminent)
66 + .disabled(isRequesting)
67 + }
68 + .padding(24)
69 + .background(DesignTokens.surface)
70 + }
71 +
72 + private func explanationRow(symbol: String, title: String, detail: String) -> some View {
73 + HStack(alignment: .top, spacing: 14) {
74 + Image(systemName: symbol)
75 + .font(.title3)
76 + .foregroundStyle(DesignTokens.accent)
77 + .frame(width: 30)
78 + VStack(alignment: .leading, spacing: 3) {
79 + Text(title).font(.subheadline.weight(.semibold))
80 + Text(detail)
81 + .font(.footnote)
82 + .foregroundStyle(DesignTokens.textSecondary)
83 + }
84 + }
85 + }
86 +}
87 +
88 +/// Dedicated screen for `.limited` — an explanation, not an error message
89 +/// (CLAUDE.md §2).
90 +struct LimitedAccessView: View {
91 + var body: some View {
92 + VStack(spacing: 20) {
93 + Image(systemName: "photo.badge.exclamationmark")
94 + .font(.system(size: 52))
95 + .foregroundStyle(DesignTokens.accent)
96 +
97 + Text("Focale a besoin de l'accès complet")
98 + .font(.title3.bold())
99 +
100 + Text("""
101 + Avec un accès limité, Focale ne peut ni indexer ta photothèque \
102 + ni retrouver tes photos. Rien ne quitte ton iPhone — l'analyse \
103 + se fait entièrement sur l'appareil.
104 +
105 + Dans Réglages → Confidentialité → Photos, choisis « Accès complet » \
106 + pour Focale.
107 + """)
108 + .font(.callout)
109 + .foregroundStyle(DesignTokens.textSecondary)
110 + .multilineTextAlignment(.center)
111 +
112 + Button("Ouvrir les Réglages") {
113 + if let url = URL(string: UIApplication.openSettingsURLString) {
114 + UIApplication.shared.open(url)
115 + }
116 + }
117 + .buttonStyle(.borderedProminent)
118 + }
119 + .padding(28)
120 + .background(DesignTokens.surface)
121 + }
122 +}
added Focale/App/RootView.swift +45 −0
@@ -0,0 +1,45 @@
1 +//
2 +// RootView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +struct RootView: View {
12 + @Environment(AppModel.self) private var app
13 +
14 + var body: some View {
15 + Group {
16 + if app.hasFullPhotoAccess {
17 + mainTabs
18 + } else if app.hasLimitedPhotoAccess {
19 + LimitedAccessView()
20 + } else {
21 + OnboardingView()
22 + }
23 + }
24 + .onAppear { app.refreshPhotoAccess() }
25 + }
26 +
27 + private var mainTabs: some View {
28 + TabView {
29 + Tab("Appareil", systemImage: "camera.fill") {
30 + CaptureView()
31 + }
32 + Tab("Bibliothèque", systemImage: "photo.on.rectangle") {
33 + LibraryView()
34 + }
35 + Tab("Recherche", systemImage: "magnifyingglass") {
36 + SearchView()
37 + }
38 + }
39 + .task(priority: .utility) {
40 + // Recent photos become searchable right away; the deep
41 + // backfill still runs at night on the charger.
42 + await app.indexPipeline.indexRecentInForeground()
43 + }
44 + }
45 +}
added Focale/Capture/CameraModel.swift +193 −0
@@ -0,0 +1,193 @@
1 +//
2 +// CameraModel.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Main-actor model behind the capture screen. The shutter path here does
9 +// the minimum: snapshot context, enqueue the capture, return. Everything
10 +// else (saving, indexing) happens in detached work afterwards.
11 +//
12 +
13 +import AVFoundation
14 +import Foundation
15 +import Observation
16 +
17 +@MainActor
18 +@Observable
19 +final class CameraModel {
20 +
21 + let service = CaptureService()
22 + let controls = ManualControls()
23 + private let writer = PhotoLibraryWriter()
24 +
25 + private(set) var capabilities: DeviceCapabilities = .none
26 + private(set) var isConfigured = false
27 + private(set) var cameraAccessDenied = false
28 + private(set) var configurationError: Error?
29 + private(set) var lastCapturedIdentifier: String?
30 + /// Recipe proposed by a scene trigger — a proposal, never a silent switch.
31 + private(set) var proposedRecipe: Recipe?
32 +
33 + var layout: ControlLayout = .photographe
34 + var subjectHint: String = ""
35 +
36 + // Injected by AppModel.
37 + weak var recipes: RecipeStore?
38 + weak var projects: ProjectStore?
39 + var indexPipeline: IndexPipeline?
40 + var placeProvider: PlaceProvider?
41 +
42 + /// Controls that are both in the layout and supported by the hardware.
43 + /// No dead buttons (CLAUDE.md §4).
44 + var visibleControls: [CaptureControl] {
45 + layout.visibleControls.filter { capabilities.supports($0) }
46 + }
47 +
48 + func configureIfNeeded() async {
49 + // Coming back to the tab: the session was stopped on disappear,
50 + // restart it — otherwise the viewfinder stays black forever.
51 + guard !isConfigured else {
52 + service.start()
53 + await indexPipeline?.suspendForCapture()
54 + return
55 + }
56 +
57 + switch AVCaptureDevice.authorizationStatus(for: .video) {
58 + case .notDetermined:
59 + guard await AVCaptureDevice.requestAccess(for: .video) else {
60 + cameraAccessDenied = true
61 + return
62 + }
63 + case .denied, .restricted:
64 + cameraAccessDenied = true
65 + return
66 + default:
67 + break
68 + }
69 + cameraAccessDenied = false
70 +
71 + do {
72 + capabilities = try await service.configure(lens: controls.lens)
73 + controls.capabilities = capabilities
74 + isConfigured = true
75 + service.start()
76 + // The indexer never competes with the viewfinder (CLAUDE.md §9).
77 + await indexPipeline?.suspendForCapture()
78 + } catch {
79 + configurationError = error
80 + }
81 + }
82 +
83 + func viewfinderDidDisappear() {
84 + service.stop()
85 + Task { await indexPipeline?.resumeAfterCapture() }
86 + }
87 +
88 + func applyControls() {
89 + service.apply(controls.snapshot)
90 + }
91 +
92 + func selectLens(_ lens: LensKind) async {
93 + guard capabilities.availableLenses.contains(lens) else { return }
94 + controls.lens = lens
95 + do {
96 + capabilities = try await service.switchLens(lens)
97 + controls.capabilities = capabilities
98 + applyControls()
99 + } catch {
100 + configurationError = error
101 + }
102 + }
103 +
104 + func applyRecipe(_ recipe: Recipe) {
105 + recipes?.activeRecipeID = recipe.id
106 + controls.load(recipe.settings)
107 + if let projectName = recipe.autoProjectName, let projects {
108 + if let existing = projects.projects.first(where: { $0.name == projectName && $0.isActive }) {
109 + projects.activate(existing.id)
110 + } else {
111 + projects.declare(projectName)
112 + }
113 + }
114 + applyControls()
115 + proposedRecipe = nil
116 + }
117 +
118 + func dismissProposedRecipe() {
119 + proposedRecipe = nil
120 + }
121 +
122 + /// Scene triggers propose a recipe; the user decides (CLAUDE.md §6).
123 + func evaluateSceneTriggers(_ scene: SceneSignal) {
124 + guard proposedRecipe == nil, let recipes else { return }
125 + guard scene.isLowLight else { return }
126 + proposedRecipe = recipes.recipes.first {
127 + $0.sceneTrigger == .lowLight && $0.id != recipes.activeRecipeID
128 + }
129 + }
130 +
131 + // MARK: - Shutter (sacred: enqueue and return, < 50 ms)
132 +
133 + func capture() {
134 + let settings = controls.snapshot
135 + let context = CaptureContext(
136 + recipe: recipes?.activeRecipeID,
137 + project: projects?.activeProjectID,
138 + projectName: projects?.activeProject?.name,
139 + subjectHint: subjectHint.isEmpty ? nil : subjectHint,
140 + settings: settings,
141 + scene: .unknown, // refined asynchronously below
142 + burstRole: nil,
143 + place: placeProvider?.currentPlace
144 + )
145 + let writer = writer
146 + let pipeline = indexPipeline
147 + let sceneTask = Task { await service.currentSceneSignal() }
148 +
149 + service.capturePhoto(
150 + format: settings.format,
151 + flashMode: settings.flashMode ?? .auto,
152 + context: context
153 + ) { result in
154 + Task {
155 + guard case .success(var photo) = result else { return }
156 + photo.context.scene = await sceneTask.value
157 + guard let identifier = try? await writer.save(photo) else { return }
158 + // A photo taken in Focale arrives already understood:
159 + // the context enters the index immediately, no inference.
160 + await pipeline?.ingestCapturedPhoto(
161 + localIdentifier: identifier,
162 + context: photo.context
163 + )
164 + await MainActor.run { [weak self] in
165 + self?.lastCapturedIdentifier = identifier
166 + }
167 + }
168 + }
169 + }
170 +
171 + /// Freezes the current manual settings into a named, reusable recipe.
172 + @discardableResult
173 + func saveCurrentSettingsAsRecipe(named name: String) -> Recipe? {
174 + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
175 + guard !trimmed.isEmpty, let recipes else { return nil }
176 + let recipe = Recipe(name: trimmed, settings: controls.snapshot)
177 + recipes.add(recipe)
178 + recipes.activeRecipeID = recipe.id
179 + return recipe
180 + }
181 +
182 + /// The hint can be added *after* the shot (never blocking before it).
183 + func applySubjectHintToLastCapture() {
184 + guard let lastCapturedIdentifier, !subjectHint.isEmpty else { return }
185 + let hint = subjectHint
186 + Task {
187 + await indexPipeline?.updateSubjectHint(
188 + localIdentifier: lastCapturedIdentifier,
189 + hint: hint
190 + )
191 + }
192 + }
193 +}
added Focale/Capture/CameraPreviewView.swift +45 −0
@@ -0,0 +1,45 @@
1 +//
2 +// CameraPreviewView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +
9 +@preconcurrency import AVFoundation
10 +import SwiftUI
11 +
12 +/// AVCaptureVideoPreviewLayer host. The session runs on its own queue;
13 +/// attaching it to the layer here is safe and cheap.
14 +struct CameraPreviewView: UIViewRepresentable {
15 + let session: AVCaptureSession
16 + /// Changing this re-runs updateUIView once the session has its inputs,
17 + /// so the rotation lands on a connection that actually exists.
18 + var isSessionConfigured: Bool = false
19 +
20 + func makeUIView(context: Context) -> PreviewLayerView {
21 + let view = PreviewLayerView()
22 + view.previewLayer.session = session
23 + view.previewLayer.videoGravity = .resizeAspectFill
24 + applyPortraitRotation(to: view)
25 + return view
26 + }
27 +
28 + func updateUIView(_ uiView: PreviewLayerView, context: Context) {
29 + applyPortraitRotation(to: uiView)
30 + }
31 +
32 + private func applyPortraitRotation(to view: PreviewLayerView) {
33 + if let connection = view.previewLayer.connection,
34 + connection.isVideoRotationAngleSupported(90) {
35 + connection.videoRotationAngle = 90
36 + }
37 + }
38 +
39 + final class PreviewLayerView: UIView {
40 + override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
41 + var previewLayer: AVCaptureVideoPreviewLayer {
42 + layer as! AVCaptureVideoPreviewLayer
43 + }
44 + }
45 +}
added Focale/Capture/CaptureView.swift +548 −0
@@ -0,0 +1,548 @@
1 +//
2 +// CaptureView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// The capture screen. First launch must give a good photo in three
9 +// seconds with zero configuration (CLAUDE.md §6); depth is there for
10 +// whoever reaches for it. The shutter button does one thing: capture.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct CaptureView: View {
16 + @Environment(AppModel.self) private var app
17 + @Environment(\.scenePhase) private var scenePhase
18 +
19 + @State private var showsNewProjectAlert = false
20 + @State private var newProjectName = ""
21 + @State private var showsSaveRecipeAlert = false
22 + @State private var newRecipeName = ""
23 + @State private var showsRecipeManager = false
24 + @State private var showsNoteAlert = false
25 + @State private var pinchStartZoom: Double?
26 + @State private var viewer: PhotoViewerContext?
27 + @AppStorage("compositionGrid") private var showsGrid = false
28 + @AppStorage("shutterTimerSeconds") private var timerSeconds = 0
29 + @State private var countdown: Int?
30 + @State private var countdownTask: Task<Void, Never>?
31 + @State private var shutterPulse = 0
32 + @State private var shutterFlashOpacity = 0.0
33 +
34 + private var camera: CameraModel { app.camera }
35 +
36 + var body: some View {
37 + ZStack {
38 + DesignTokens.surface.ignoresSafeArea()
39 +
40 + if camera.cameraAccessDenied {
41 + cameraDeniedView
42 + } else {
43 + GeometryReader { proxy in
44 + CameraPreviewView(
45 + session: camera.service.session,
46 + isSessionConfigured: camera.isConfigured
47 + )
48 + .gesture(mappedDrag(midX: proxy.size.width / 2))
49 + .simultaneousGesture(pinchGesture)
50 + }
51 + .ignoresSafeArea()
52 +
53 + if showsGrid {
54 + CompositionGridOverlay()
55 + .ignoresSafeArea()
56 + .allowsHitTesting(false)
57 + }
58 +
59 + // Shutter feedback: a quick screen blink so a capture is
60 + // unmistakable, plus the haptic below. The system shutter
61 + // sound plays on the device itself.
62 + Color.black
63 + .opacity(shutterFlashOpacity)
64 + .ignoresSafeArea()
65 + .allowsHitTesting(false)
66 +
67 + if let countdown {
68 + Text("\(countdown)")
69 + .font(.system(size: 110, weight: .bold, design: .rounded))
70 + .foregroundStyle(.white)
71 + .shadow(radius: 12)
72 + .contentTransition(.numericText(countsDown: true))
73 + .allowsHitTesting(false)
74 + }
75 +
76 + VStack {
77 + topBar
78 + Spacer()
79 + recipeProposalBanner
80 + lastCaptureRow
81 + controlStrip
82 + bottomBar
83 + }
84 + .padding(.horizontal, 16)
85 + .padding(.bottom, 12)
86 + }
87 + }
88 + .alert("Nouveau projet", isPresented: $showsNewProjectAlert) {
89 + TextField("chantier cuisine, voyage Gaspésie…", text: $newProjectName)
90 + Button("Créer") {
91 + let name = newProjectName.trimmingCharacters(in: .whitespacesAndNewlines)
92 + if !name.isEmpty { app.projects.declare(name) }
93 + newProjectName = ""
94 + }
95 + Button("Annuler", role: .cancel) { newProjectName = "" }
96 + } message: {
97 + Text("Tout ce que tu photographies ensuite sera marqué automatiquement.")
98 + }
99 + .alert("Nouvelle recette", isPresented: $showsSaveRecipeAlert) {
100 + TextField("Nom de la recette", text: $newRecipeName)
101 + Button("Enregistrer") {
102 + camera.saveCurrentSettingsAsRecipe(named: newRecipeName)
103 + newRecipeName = ""
104 + }
105 + Button("Annuler", role: .cancel) { newRecipeName = "" }
106 + } message: {
107 + Text("Les réglages actuels (ISO, vitesse, objectif, format…) seront rappelables en un geste.")
108 + }
109 + .alert("Note sur la dernière photo", isPresented: $showsNoteAlert) {
110 + TextField("« reçu du garage », « compteur d'eau »…", text: Binding(
111 + get: { camera.subjectHint },
112 + set: { camera.subjectHint = $0 }
113 + ))
114 + Button("Ajouter") {
115 + camera.applySubjectHintToLastCapture()
116 + camera.subjectHint = ""
117 + }
118 + Button("Annuler", role: .cancel) { camera.subjectHint = "" }
119 + }
120 + .sheet(isPresented: $showsRecipeManager) {
121 + RecipeManagerView()
122 + }
123 + .fullScreenCover(item: $viewer) { context in
124 + PhotoDetailView(context: context)
125 + }
126 + .task {
127 + await camera.configureIfNeeded()
128 + camera.applyControls()
129 + app.placeProvider.startIfAuthorized()
130 + }
131 + .task {
132 + // Scene watcher: proposes a recipe on low light — a proposal,
133 + // never a silent switch (CLAUDE.md §6).
134 + while !Task.isCancelled {
135 + try? await Task.sleep(for: .seconds(3))
136 + guard camera.isConfigured, scenePhase == .active else { continue }
137 + let scene = await camera.service.currentSceneSignal()
138 + camera.evaluateSceneTriggers(scene)
139 + }
140 + }
141 + .onDisappear {
142 + camera.viewfinderDidDisappear()
143 + app.placeProvider.stop()
144 + }
145 + .onChange(of: scenePhase) { _, phase in
146 + if phase == .active {
147 + camera.service.start()
148 + } else {
149 + camera.service.stop()
150 + }
151 + }
152 + }
153 +
154 + /// Explanation, not a dead black screen, when camera access is refused.
155 + private var cameraDeniedView: some View {
156 + VStack(spacing: 16) {
157 + Image(systemName: "video.slash")
158 + .font(.system(size: 44))
159 + .foregroundStyle(DesignTokens.accent)
160 + Text("Focale n'a pas accès à la caméra")
161 + .font(.title3.bold())
162 + Text("Dans Réglages → Confidentialité → Caméra, active Focale. La caméra sert uniquement à prendre tes photos — rien ne quitte ton appareil.")
163 + .font(.callout)
164 + .foregroundStyle(DesignTokens.textSecondary)
165 + .multilineTextAlignment(.center)
166 + Button("Ouvrir les Réglages") {
167 + if let url = URL(string: UIApplication.openSettingsURLString) {
168 + UIApplication.shared.open(url)
169 + }
170 + }
171 + .buttonStyle(.borderedProminent)
172 + }
173 + .padding(28)
174 + }
175 +
176 + // MARK: - Top bar: recipe, project, format
177 +
178 + private var topBar: some View {
179 + HStack(spacing: 10) {
180 + Menu {
181 + ForEach(app.recipes.recipes) { recipe in
182 + Button(recipe.name) { camera.applyRecipe(recipe) }
183 + }
184 + Divider()
185 + Button("Enregistrer les réglages…", systemImage: "plus.circle") {
186 + showsSaveRecipeAlert = true
187 + }
188 + Button("Gérer les recettes…", systemImage: "folder.badge.gearshape") {
189 + showsRecipeManager = true
190 + }
191 + Button("Aucune recette", role: .destructive) {
192 + app.recipes.activeRecipeID = nil
193 + camera.controls.resetToAuto()
194 + camera.applyControls()
195 + }
196 + } label: {
197 + chip(
198 + text: app.recipes.activeRecipe?.name ?? "Recette",
199 + symbol: "wand.and.stars",
200 + highlighted: app.recipes.activeRecipe != nil
201 + )
202 + }
203 +
204 + Menu {
205 + ForEach(app.projects.projects.filter(\.isActive)) { project in
206 + Button(project.name) { app.projects.activate(project.id) }
207 + }
208 + Divider()
209 + Button("Nouveau projet…", systemImage: "plus.circle") {
210 + showsNewProjectAlert = true
211 + }
212 + if app.projects.activeProjectID != nil {
213 + Button("Aucun projet", role: .destructive) {
214 + app.projects.activate(nil)
215 + }
216 + }
217 + } label: {
218 + chip(
219 + text: app.projects.activeProject?.name ?? "Projet",
220 + symbol: "folder",
221 + highlighted: app.projects.activeProject != nil
222 + )
223 + }
224 +
225 + Spacer()
226 +
227 + // Only shown when the hardware supports it — no dead buttons.
228 + if camera.capabilities.hasFlash {
229 + Button {
230 + camera.controls.flashMode = camera.controls.flashMode.next
231 + } label: {
232 + iconChip(
233 + symbol: camera.controls.flashMode.symbolName,
234 + highlighted: camera.controls.flashMode != .off
235 + )
236 + }
237 + .accessibilityLabel(camera.controls.flashMode.displayName)
238 + }
239 +
240 + Button {
241 + timerSeconds = switch timerSeconds { case 0: 3; case 3: 10; default: 0 }
242 + } label: {
243 + iconChip(
244 + symbol: timerSeconds == 0 ? "timer" : "timer.circle.fill",
245 + highlighted: timerSeconds > 0,
246 + caption: timerSeconds > 0 ? "\(timerSeconds)s" : nil
247 + )
248 + }
249 + .accessibilityLabel("Retardateur")
250 +
251 + Button {
252 + showsGrid.toggle()
253 + } label: {
254 + iconChip(symbol: "grid", highlighted: showsGrid)
255 + }
256 + .accessibilityLabel("Grille de composition")
257 +
258 + if camera.capabilities.supportsProRAW {
259 + Button {
260 + camera.controls.format = camera.controls.format == .heic ? .proRAW : .heic
261 + } label: {
262 + chip(
263 + text: camera.controls.format.displayName,
264 + symbol: "doc.badge.gearshape",
265 + highlighted: camera.controls.format == .proRAW
266 + )
267 + }
268 + }
269 + }
270 + .padding(.top, 8)
271 + }
272 +
273 + // MARK: - Scene trigger proposal (never a silent switch)
274 +
275 + @ViewBuilder
276 + private var recipeProposalBanner: some View {
277 + if let proposed = camera.proposedRecipe {
278 + HStack {
279 + Text("Basse lumière — passer à « \(proposed.name) » ?")
280 + .font(.footnote)
281 + Spacer()
282 + Button("Oui") { camera.applyRecipe(proposed) }
283 + .font(.footnote.bold())
284 + Button {
285 + camera.dismissProposedRecipe()
286 + } label: {
287 + Image(systemName: "xmark")
288 + .font(.footnote)
289 + }
290 + }
291 + .padding(10)
292 + .background(.ultraThinMaterial, in: RoundedRectangle(
293 + cornerRadius: DesignTokens.cornerRadius
294 + ))
295 + .padding(.bottom, 8)
296 + }
297 + }
298 +
299 + /// After a capture: tappable thumbnail of the last photo + note button.
300 + /// The context is added around the shot, never in front of it.
301 + @ViewBuilder
302 + private var lastCaptureRow: some View {
303 + if let identifier = camera.lastCapturedIdentifier {
304 + HStack(spacing: 10) {
305 + Button {
306 + viewer = PhotoViewerContext(id: identifier, identifiers: [identifier])
307 + } label: {
308 + AssetThumbnailView(localIdentifier: identifier)
309 + .frame(width: 44, height: 44)
310 + .clipShape(RoundedRectangle(cornerRadius: 8))
311 + .overlay(RoundedRectangle(cornerRadius: 8)
312 + .strokeBorder(.white.opacity(0.35), lineWidth: 1))
313 + }
314 + .buttonStyle(.plain)
315 +
316 + Button {
317 + showsNoteAlert = true
318 + } label: {
319 + Label("Ajouter une note", systemImage: "square.and.pencil")
320 + .font(.footnote.weight(.medium))
321 + .padding(.horizontal, 10)
322 + .padding(.vertical, 6)
323 + .background(.ultraThinMaterial, in: Capsule())
324 + .foregroundStyle(.white)
325 + }
326 +
327 + Spacer()
328 + }
329 + .padding(.bottom, 8)
330 + .transition(.opacity)
331 + }
332 + }
333 +
334 + /// Pinch drives whatever the gesture map assigns to it (zoom by default).
335 + private var pinchGesture: some Gesture {
336 + MagnifyGesture()
337 + .onChanged { value in
338 + guard let control = camera.layout.gestureMap.control(for: .pinch),
339 + camera.capabilities.supports(control) else { return }
340 + let start = pinchStartZoom ?? camera.controls.zoomFactor
341 + if pinchStartZoom == nil { pinchStartZoom = start }
342 + if control == .zoom {
343 + camera.controls.zoomFactor = (start * value.magnification)
344 + .clamped(to: 1...camera.capabilities.maxZoomFactor)
345 + } else {
346 + let current = camera.controls.normalizedValue(for: control)
347 + camera.controls.setNormalizedValue(
348 + current + (value.magnification - 1) / 50.0, for: control
349 + )
350 + }
351 + camera.applyControls()
352 + }
353 + .onEnded { _ in pinchStartZoom = nil }
354 + }
355 +
356 + // MARK: - Control strip (layout-driven, capability-filtered)
357 +
358 + private var controlStrip: some View {
359 + ScrollView(.horizontal, showsIndicators: false) {
360 + HStack(spacing: DesignTokens.controlSpacing) {
361 + ForEach(camera.visibleControls) { control in
362 + ControlDial(
363 + control: control,
364 + haptics: camera.layout.hapticProfile(for: control),
365 + controls: camera.controls
366 + ) {
367 + camera.applyControls()
368 + }
369 + }
370 + }
371 + }
372 + .padding(.bottom, 10)
373 + }
374 +
375 + // MARK: - Bottom bar: lens switcher + shutter
376 +
377 + private var bottomBar: some View {
378 + ZStack {
379 + HStack {
380 + lensSwitcher
381 + Spacer()
382 + Menu {
383 + ForEach(ControlLayout.provided) { layout in
384 + Button(layout.name) { camera.layout = layout }
385 + }
386 + } label: {
387 + Image(systemName: "slider.horizontal.3")
388 + .font(.title3)
389 + .foregroundStyle(.white)
390 + .frame(width: 44, height: 44)
391 + }
392 + }
393 + shutterButton
394 + }
395 + }
396 +
397 + private var lensSwitcher: some View {
398 + HStack(spacing: 6) {
399 + ForEach(camera.capabilities.availableLenses, id: \.self) { lens in
400 + Button {
401 + Task { await camera.selectLens(lens) }
402 + } label: {
403 + Text(lensLabel(lens))
404 + .font(.footnote.weight(.semibold))
405 + .foregroundStyle(camera.controls.lens == lens ? DesignTokens.accent : .white)
406 + .frame(width: 36, height: 36)
407 + .background(.ultraThinMaterial, in: Circle())
408 + }
409 + }
410 + }
411 + }
412 +
413 + private func lensLabel(_ lens: LensKind) -> String {
414 + switch lens {
415 + case .ultraWide: "0,5×"
416 + case .wide: "1×"
417 + case .telephoto: "T"
418 + }
419 + }
420 +
421 + /// The shutter is sacred: the capture call goes first, feedback after.
422 + private var shutterButton: some View {
423 + Button {
424 + handleShutterTap()
425 + } label: {
426 + ZStack {
427 + Circle()
428 + .strokeBorder(.white, lineWidth: 4)
429 + .frame(width: DesignTokens.shutterDiameter,
430 + height: DesignTokens.shutterDiameter)
431 + Circle()
432 + .fill(countdownTask == nil ? .white : DesignTokens.accent)
433 + .frame(width: DesignTokens.shutterDiameter - 14,
434 + height: DesignTokens.shutterDiameter - 14)
435 + }
436 + }
437 + .buttonStyle(.plain)
438 + .sensoryFeedback(.impact(weight: .medium), trigger: shutterPulse)
439 + .accessibilityLabel(countdownTask == nil ? "Déclencheur" : "Annuler le retardateur")
440 + }
441 +
442 + private func handleShutterTap() {
443 + // A second tap during the countdown cancels it.
444 + if let task = countdownTask {
445 + task.cancel()
446 + countdownTask = nil
447 + countdown = nil
448 + return
449 + }
450 + guard timerSeconds > 0 else {
451 + fireCapture()
452 + return
453 + }
454 + countdownTask = Task {
455 + for remaining in stride(from: timerSeconds, through: 1, by: -1) {
456 + withAnimation(.snappy) { countdown = remaining }
457 + shutterPulse += 1 // haptic tick each second
458 + try? await Task.sleep(for: .seconds(1))
459 + if Task.isCancelled { return }
460 + }
461 + countdown = nil
462 + countdownTask = nil
463 + fireCapture()
464 + }
465 + }
466 +
467 + private func fireCapture() {
468 + camera.capture() // first, always — nothing before the shutter
469 + shutterPulse += 1 // immediate haptic
470 + shutterFlashOpacity = 1 // screen blink: capture is unmistakable
471 + withAnimation(.easeOut(duration: 0.25)) {
472 + shutterFlashOpacity = 0
473 + }
474 + }
475 +
476 + // MARK: - Mapped gestures (fully reassignable, CLAUDE.md §6)
477 +
478 + private func mappedDrag(midX: CGFloat) -> some Gesture {
479 + DragGesture(minimumDistance: 12)
480 + .onChanged { gesture in
481 + let horizontal = abs(gesture.translation.width) > abs(gesture.translation.height)
482 + let slot: GestureSlot = if horizontal {
483 + .horizontalDrag
484 + } else if gesture.startLocation.x < midX {
485 + .leftVerticalDrag
486 + } else {
487 + .rightVerticalDrag
488 + }
489 + guard let control = camera.layout.gestureMap.control(for: slot),
490 + camera.capabilities.supports(control) else { return }
491 +
492 + let delta = horizontal
493 + ? gesture.translation.width
494 + : -gesture.translation.height
495 + let current = camera.controls.normalizedValue(for: control)
496 + camera.controls.setNormalizedValue(
497 + current + delta / 3000.0, // smooth continuous drive
498 + for: control
499 + )
500 + camera.applyControls()
501 + }
502 + }
503 +
504 + // MARK: - Helpers
505 +
506 + private func chip(text: String, symbol: String, highlighted: Bool) -> some View {
507 + Label(text, systemImage: symbol)
508 + .font(.footnote.weight(.medium))
509 + .lineLimit(1)
510 + .padding(.horizontal, 10)
511 + .padding(.vertical, 6)
512 + .background(.ultraThinMaterial, in: Capsule())
513 + .foregroundStyle(highlighted ? DesignTokens.accent : .white)
514 + }
515 +
516 + private func iconChip(symbol: String, highlighted: Bool, caption: String? = nil) -> some View {
517 + HStack(spacing: 3) {
518 + Image(systemName: symbol)
519 + if let caption {
520 + Text(caption).font(.caption2.weight(.semibold))
521 + }
522 + }
523 + .font(.footnote.weight(.medium))
524 + .padding(.horizontal, 9)
525 + .padding(.vertical, 6)
526 + .background(.ultraThinMaterial, in: Capsule())
527 + .foregroundStyle(highlighted ? DesignTokens.accent : .white)
528 + }
529 +}
530 +
531 +/// Rule-of-thirds guide. Drawn, never captured into the photo.
532 +struct CompositionGridOverlay: View {
533 + var body: some View {
534 + GeometryReader { proxy in
535 + Path { path in
536 + let w = proxy.size.width
537 + let h = proxy.size.height
538 + for fraction in [1.0 / 3.0, 2.0 / 3.0] {
539 + path.move(to: CGPoint(x: w * fraction, y: 0))
540 + path.addLine(to: CGPoint(x: w * fraction, y: h))
541 + path.move(to: CGPoint(x: 0, y: h * fraction))
542 + path.addLine(to: CGPoint(x: w, y: h * fraction))
543 + }
544 + }
545 + .stroke(.white.opacity(0.35), lineWidth: 0.75)
546 + }
547 + }
548 +}
added Focale/Capture/Controls/ControlDial.swift +65 −0
@@ -0,0 +1,65 @@
1 +//
2 +// ControlDial.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Horizontal scrub dial for one manual control, with configurable
9 +// haptic detents (e.g. a tick every third of a stop — CLAUDE.md §6).
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct ControlDial: View {
15 + let control: CaptureControl
16 + let haptics: HapticProfile
17 + @Bindable var controls: ManualControls
18 + var onChanged: () -> Void
19 +
20 + @State private var dragStartValue: Double?
21 + @State private var lastDetent: Int = -1
22 +
23 + var body: some View {
24 + VStack(spacing: 4) {
25 + Text(controls.displayValue(for: control))
26 + .font(DesignTokens.controlValueFont)
27 + .foregroundStyle(.white)
28 + .contentTransition(.numericText())
29 +
30 + Text(control.displayName)
31 + .font(DesignTokens.controlLabelFont)
32 + .foregroundStyle(DesignTokens.textSecondary)
33 + }
34 + .frame(minWidth: 74)
35 + .padding(.vertical, 8)
36 + .padding(.horizontal, 10)
37 + .background(DesignTokens.surfaceRaised, in: RoundedRectangle(
38 + cornerRadius: DesignTokens.cornerRadius
39 + ))
40 + .sensoryFeedback(.selection, trigger: lastDetent)
41 + .gesture(dial)
42 + .accessibilityLabel(control.displayName)
43 + .accessibilityValue(controls.displayValue(for: control))
44 + }
45 +
46 + private var dial: some Gesture {
47 + DragGesture(minimumDistance: 2)
48 + .onChanged { gesture in
49 + let start = dragStartValue ?? controls.normalizedValue(for: control)
50 + if dragStartValue == nil { dragStartValue = start }
51 +
52 + let sensitivity = 1.0 / 220.0 // full range over ~220 pt
53 + let value = (start + gesture.translation.width * sensitivity)
54 + .clamped(to: 0...1)
55 + controls.setNormalizedValue(value, for: control)
56 +
57 + if haptics.enabled, haptics.detentCount > 0 {
58 + let detent = Int(value * Double(haptics.detentCount))
59 + if detent != lastDetent { lastDetent = detent }
60 + }
61 + onChanged()
62 + }
63 + .onEnded { _ in dragStartValue = nil }
64 + }
65 +}
added Focale/Capture/Controls/GestureMap.swift +57 −0
@@ -0,0 +1,57 @@
1 +//
2 +// GestureMap.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Fully reassignable gesture → control mapping (CLAUDE.md §6):
9 +// left vertical drag = ISO, horizontal = exposure, etc.
10 +//
11 +
12 +import Foundation
13 +
14 +/// A gesture slot on the capture screen that can drive a control.
15 +enum GestureSlot: String, Codable, Sendable, CaseIterable, Identifiable {
16 + case leftVerticalDrag
17 + case rightVerticalDrag
18 + case horizontalDrag
19 + case pinch
20 +
21 + var id: String { rawValue }
22 +
23 + /// User-facing label, in French.
24 + var displayName: String {
25 + switch self {
26 + case .leftVerticalDrag: "Glissement vertical gauche"
27 + case .rightVerticalDrag: "Glissement vertical droit"
28 + case .horizontalDrag: "Glissement horizontal"
29 + case .pinch: "Pincement"
30 + }
31 + }
32 +}
33 +
34 +struct GestureMap: Codable, Sendable {
35 + private var assignments: [GestureSlot.RawValue: CaptureControl.RawValue]
36 +
37 + init(_ assignments: [GestureSlot: CaptureControl]) {
38 + self.assignments = Dictionary(
39 + uniqueKeysWithValues: assignments.map { ($0.key.rawValue, $0.value.rawValue) }
40 + )
41 + }
42 +
43 + func control(for slot: GestureSlot) -> CaptureControl? {
44 + assignments[slot.rawValue].flatMap(CaptureControl.init(rawValue:))
45 + }
46 +
47 + mutating func assign(_ control: CaptureControl?, to slot: GestureSlot) {
48 + assignments[slot.rawValue] = control?.rawValue
49 + }
50 +
51 + static let standard = GestureMap([
52 + .leftVerticalDrag: .iso,
53 + .rightVerticalDrag: .shutterSpeed,
54 + .horizontalDrag: .exposureBias,
55 + .pinch: .zoom,
56 + ])
57 +}
added Focale/Capture/Controls/ManualControls.swift +161 −0
@@ -0,0 +1,161 @@
1 +//
2 +// ManualControls.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// UI-side model of the manual controls. Each control is either auto or
9 +// holds a manual value, clamped to what the device actually supports.
10 +//
11 +
12 +import Foundation
13 +import Observation
14 +
15 +@MainActor
16 +@Observable
17 +final class ManualControls {
18 +
19 + var capabilities: DeviceCapabilities = .none {
20 + didSet { clampAll() }
21 + }
22 +
23 + // nil = auto for each of these.
24 + var iso: Float?
25 + var shutterSeconds: Double?
26 + var lensPosition: Float?
27 + var whiteBalanceKelvin: Float?
28 + var exposureBias: Float = 0
29 + var lens: LensKind = .wide
30 + var zoomFactor: Double = 1.0
31 + var format: CaptureFormat = .heic
32 + var flashMode: FlashMode = .auto
33 +
34 + /// Immutable snapshot for the capture context and the session queue.
35 + var snapshot: ManualSettings {
36 + ManualSettings(
37 + iso: iso,
38 + shutterSeconds: shutterSeconds,
39 + lensPosition: lensPosition,
40 + whiteBalanceKelvin: whiteBalanceKelvin,
41 + exposureBias: exposureBias,
42 + lens: lens,
43 + zoomFactor: zoomFactor,
44 + format: format,
45 + flashMode: flashMode
46 + )
47 + }
48 +
49 + func load(_ settings: ManualSettings) {
50 + iso = settings.iso
51 + shutterSeconds = settings.shutterSeconds
52 + lensPosition = settings.lensPosition
53 + whiteBalanceKelvin = settings.whiteBalanceKelvin
54 + exposureBias = settings.exposureBias
55 + lens = settings.lens
56 + zoomFactor = settings.zoomFactor
57 + format = settings.format
58 + flashMode = settings.flashMode ?? .auto
59 + clampAll()
60 + }
61 +
62 + func resetToAuto() {
63 + iso = nil
64 + shutterSeconds = nil
65 + lensPosition = nil
66 + whiteBalanceKelvin = nil
67 + exposureBias = 0
68 + }
69 +
70 + /// Normalized (0...1) accessor used by dials and mapped gestures.
71 + func normalizedValue(for control: CaptureControl) -> Double {
72 + switch control {
73 + case .iso:
74 + let range = capabilities.isoRange
75 + let value = iso ?? range.lowerBound
76 + return normalize(Double(value), in: Double(range.lowerBound)...Double(range.upperBound))
77 + case .shutterSpeed:
78 + // Log scale: shutter perception is logarithmic.
79 + let range = capabilities.shutterRange
80 + let value = shutterSeconds ?? range.lowerBound
81 + return normalize(log2(value), in: log2(range.lowerBound)...log2(range.upperBound))
82 + case .exposureBias:
83 + let range = capabilities.exposureBiasRange
84 + return normalize(Double(exposureBias), in: Double(range.lowerBound)...Double(range.upperBound))
85 + case .focus:
86 + return Double(lensPosition ?? 0.5)
87 + case .whiteBalance:
88 + return normalize(Double(whiteBalanceKelvin ?? 5500), in: 2500...8000)
89 + case .zoom:
90 + return normalize(zoomFactor, in: 1...capabilities.maxZoomFactor)
91 + }
92 + }
93 +
94 + func setNormalizedValue(_ normalized: Double, for control: CaptureControl) {
95 + let t = normalized.clamped(to: 0...1)
96 + switch control {
97 + case .iso:
98 + let range = capabilities.isoRange
99 + iso = Float(lerp(t, Double(range.lowerBound)...Double(range.upperBound)))
100 + case .shutterSpeed:
101 + let range = capabilities.shutterRange
102 + shutterSeconds = pow(2, lerp(t, log2(range.lowerBound)...log2(range.upperBound)))
103 + case .exposureBias:
104 + let range = capabilities.exposureBiasRange
105 + exposureBias = Float(lerp(t, Double(range.lowerBound)...Double(range.upperBound)))
106 + case .focus:
107 + lensPosition = Float(t)
108 + case .whiteBalance:
109 + whiteBalanceKelvin = Float(lerp(t, 2500...8000))
110 + case .zoom:
111 + zoomFactor = lerp(t, 1...max(capabilities.maxZoomFactor, 1.01))
112 + }
113 + }
114 +
115 + /// User-facing formatted value for a control (French display conventions).
116 + func displayValue(for control: CaptureControl) -> String {
117 + switch control {
118 + case .iso:
119 + if let iso { return "ISO \(Int(iso))" }
120 + return "ISO auto"
121 + case .shutterSpeed:
122 + guard let shutterSeconds else { return "Auto" }
123 + if shutterSeconds >= 0.25 {
124 + return String(format: "%.1f s", shutterSeconds)
125 + }
126 + return "1/\(Int((1.0 / shutterSeconds).rounded()))"
127 + case .exposureBias:
128 + return String(format: "%+.1f IL", exposureBias)
129 + case .focus:
130 + if let lensPosition { return String(format: "MAP %.2f", lensPosition) }
131 + return "AF"
132 + case .whiteBalance:
133 + if let whiteBalanceKelvin { return "\(Int(whiteBalanceKelvin)) K" }
134 + return "BB auto"
135 + case .zoom:
136 + return String(format: "%.1f×", zoomFactor)
137 + }
138 + }
139 +
140 + // MARK: - Helpers
141 +
142 + private func clampAll() {
143 + if let value = iso { iso = value.clamped(to: capabilities.isoRange) }
144 + if let value = shutterSeconds { shutterSeconds = value.clamped(to: capabilities.shutterRange) }
145 + if let value = lensPosition { lensPosition = value.clamped(to: 0...1) }
146 + exposureBias = exposureBias.clamped(to: capabilities.exposureBiasRange)
147 + zoomFactor = zoomFactor.clamped(to: 1...max(capabilities.maxZoomFactor, 1))
148 + if !capabilities.availableLenses.contains(lens) { lens = .wide }
149 + if format == .proRAW && !capabilities.supportsProRAW { format = .heic }
150 + }
151 +
152 + private func normalize(_ value: Double, in range: ClosedRange<Double>) -> Double {
153 + guard range.upperBound > range.lowerBound else { return 0 }
154 + return ((value - range.lowerBound) / (range.upperBound - range.lowerBound))
155 + .clamped(to: 0...1)
156 + }
157 +
158 + private func lerp(_ t: Double, _ range: ClosedRange<Double>) -> Double {
159 + range.lowerBound + t * (range.upperBound - range.lowerBound)
160 + }
161 +}
added Focale/Capture/Layout/ControlLayout.swift +110 −0
@@ -0,0 +1,110 @@
1 +//
2 +// ControlLayout.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// User-placeable capture interface (CLAUDE.md §6): which controls are
9 +// visible, in what order, and how haptics behave. Three provided
10 +// profiles — Simple / Photographe / Expert — all editable.
11 +//
12 +
13 +import Foundation
14 +
15 +/// A manual control the user can place in the capture interface.
16 +enum CaptureControl: String, Codable, Sendable, CaseIterable, Identifiable {
17 + case iso
18 + case shutterSpeed
19 + case exposureBias
20 + case focus
21 + case whiteBalance
22 + case zoom
23 +
24 + var id: String { rawValue }
25 +
26 + /// User-facing label, in French.
27 + var displayName: String {
28 + switch self {
29 + case .iso: "ISO"
30 + case .shutterSpeed: "Vitesse"
31 + case .exposureBias: "Expo"
32 + case .focus: "Mise au point"
33 + case .whiteBalance: "Balance"
34 + case .zoom: "Zoom"
35 + }
36 + }
37 +
38 + var symbolName: String {
39 + switch self {
40 + case .iso: "camera.aperture"
41 + case .shutterSpeed: "timer"
42 + case .exposureBias: "plusminus.circle"
43 + case .focus: "scope"
44 + case .whiteBalance: "thermometer.sun"
45 + case .zoom: "plus.magnifyingglass"
46 + }
47 + }
48 +}
49 +
50 +/// Per-control haptic configuration (e.g. a detent every third of a stop).
51 +struct HapticProfile: Codable, Sendable {
52 + var enabled: Bool
53 + /// Number of detents across the control's full range. 0 = continuous.
54 + var detentCount: Int
55 +
56 + static let subtle = HapticProfile(enabled: true, detentCount: 0)
57 + static let thirdStops = HapticProfile(enabled: true, detentCount: 18)
58 + static let off = HapticProfile(enabled: false, detentCount: 0)
59 +}
60 +
61 +struct ControlLayout: Codable, Sendable, Identifiable {
62 + var id: UUID
63 + var name: String
64 + /// Controls shown in the dial strip, in order.
65 + var visibleControls: [CaptureControl]
66 + var haptics: [CaptureControl.RawValue: HapticProfile]
67 + var gestureMap: GestureMap
68 +
69 + init(
70 + id: UUID = UUID(),
71 + name: String,
72 + visibleControls: [CaptureControl],
73 + haptics: [CaptureControl.RawValue: HapticProfile] = [:],
74 + gestureMap: GestureMap = .standard
75 + ) {
76 + self.id = id
77 + self.name = name
78 + self.visibleControls = visibleControls
79 + self.haptics = haptics
80 + self.gestureMap = gestureMap
81 + }
82 +
83 + func hapticProfile(for control: CaptureControl) -> HapticProfile {
84 + haptics[control.rawValue] ?? .subtle
85 + }
86 +
87 + // MARK: - Provided profiles (all editable)
88 +
89 + static let simple = ControlLayout(
90 + name: "Simple",
91 + visibleControls: [.exposureBias, .zoom]
92 + )
93 +
94 + static let photographe = ControlLayout(
95 + name: "Photographe",
96 + visibleControls: [.iso, .shutterSpeed, .exposureBias, .zoom],
97 + haptics: [CaptureControl.iso.rawValue: .thirdStops,
98 + CaptureControl.shutterSpeed.rawValue: .thirdStops]
99 + )
100 +
101 + static let expert = ControlLayout(
102 + name: "Expert",
103 + visibleControls: CaptureControl.allCases,
104 + haptics: [CaptureControl.iso.rawValue: .thirdStops,
105 + CaptureControl.shutterSpeed.rawValue: .thirdStops,
106 + CaptureControl.focus.rawValue: .subtle]
107 + )
108 +
109 + static let provided: [ControlLayout] = [.simple, .photographe, .expert]
110 +}
added Focale/Capture/Output/PhotoCaptureProcessor.swift +92 −0
@@ -0,0 +1,92 @@
1 +//
2 +// PhotoCaptureProcessor.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Per-capture AVCapturePhotoCaptureDelegate. Collects the processed and
9 +// RAW representations, embeds the capture context into the file's own
10 +// metadata (so the information survives an uninstall), and hands the
11 +// result off. No work happens in the shutter path itself.
12 +//
13 +
14 +@preconcurrency import AVFoundation
15 +import Foundation
16 +import ImageIO
17 +
18 +/// Everything produced by one shutter press, ready for PhotoKit.
19 +struct CapturedPhoto: Sendable {
20 + var processedData: Data? // HEIC, context embedded in EXIF UserComment
21 + var rawData: Data? // ProRAW DNG, untouched
22 + var context: CaptureContext
23 +}
24 +
25 +final class PhotoCaptureProcessor: NSObject, AVCapturePhotoCaptureDelegate {
26 +
27 + private let context: CaptureContext
28 + private let completion: @Sendable (Result<CapturedPhoto, Error>) -> Void
29 + private var processedData: Data?
30 + private var rawData: Data?
31 + private var captureError: Error?
32 +
33 + init(
34 + context: CaptureContext,
35 + completion: @escaping @Sendable (Result<CapturedPhoto, Error>) -> Void
36 + ) {
37 + self.context = context
38 + self.completion = completion
39 + }
40 +
41 + func photoOutput(
42 + _ output: AVCapturePhotoOutput,
43 + didFinishProcessingPhoto photo: AVCapturePhoto,
44 + error: Error?
45 + ) {
46 + if let error {
47 + captureError = error
48 + return
49 + }
50 + if photo.isRawPhoto {
51 + // RAW stays byte-for-byte untouched; context lives in the index
52 + // and in the processed companion.
53 + rawData = photo.fileDataRepresentation()
54 + } else {
55 + processedData = photo.fileDataRepresentation(with: self)
56 + ?? photo.fileDataRepresentation()
57 + }
58 + }
59 +
60 + func photoOutput(
61 + _ output: AVCapturePhotoOutput,
62 + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
63 + error: Error?
64 + ) {
65 + if let error = captureError ?? error {
66 + completion(.failure(error))
67 + return
68 + }
69 + guard processedData != nil || rawData != nil else {
70 + completion(.failure(CaptureError.captureFailed))
71 + return
72 + }
73 + completion(.success(CapturedPhoto(
74 + processedData: processedData,
75 + rawData: rawData,
76 + context: context
77 + )))
78 + }
79 +}
80 +
81 +// MARK: - Metadata embedding (no re-encode — the customizer swaps only metadata)
82 +
83 +extension PhotoCaptureProcessor: AVCapturePhotoFileDataRepresentationCustomizer {
84 + func replacementMetadata(for photo: AVCapturePhoto) -> [String: Any]? {
85 + guard let json = context.metadataJSON() else { return photo.metadata }
86 + var metadata = photo.metadata
87 + var exif = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any] ?? [:]
88 + exif[kCGImagePropertyExifUserComment as String] = "focale:" + json
89 + metadata[kCGImagePropertyExifDictionary as String] = exif
90 + return metadata
91 + }
92 +}
added Focale/Capture/Output/PhotoLibraryWriter.swift +47 −0
@@ -0,0 +1,47 @@
1 +//
2 +// PhotoLibraryWriter.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Writes captures into the system photo library via PHAssetCreationRequest.
9 +// No parallel library, no private sandbox (CLAUDE.md §4). The user can
10 +// uninstall tomorrow and lose nothing.
11 +//
12 +
13 +import Foundation
14 +import Photos
15 +
16 +actor PhotoLibraryWriter {
17 +
18 + /// PhotoKit runs change blocks on its own queue; this box carries the
19 + /// created identifier out of the @Sendable block without giving the
20 + /// block any actor isolation (a Swift 6 runtime crash otherwise).
21 + private final class IdentifierBox: @unchecked Sendable {
22 + var value: String?
23 + }
24 +
25 + /// Saves a capture and returns the new asset's localIdentifier —
26 + /// the key the whole index is built on.
27 + func save(_ photo: CapturedPhoto) async throws -> String {
28 + let box = IdentifierBox()
29 + let change: @Sendable () -> Void = {
30 + let request = PHAssetCreationRequest.forAsset()
31 + if let processed = photo.processedData {
32 + request.addResource(with: .photo, data: processed, options: nil)
33 + if let raw = photo.rawData {
34 + let options = PHAssetResourceCreationOptions()
35 + request.addResource(with: .alternatePhoto, data: raw, options: options)
36 + }
37 + } else if let raw = photo.rawData {
38 + request.addResource(with: .photo, data: raw, options: nil)
39 + }
40 + box.value = request.placeholderForCreatedAsset?.localIdentifier
41 + }
42 + try await PHPhotoLibrary.shared().performChanges(change)
43 +
44 + guard let localIdentifier = box.value else { throw CaptureError.captureFailed }
45 + return localIdentifier
46 + }
47 +}
added Focale/Capture/Recipes/Recipe.swift +106 −0
@@ -0,0 +1,106 @@
1 +//
2 +// Recipe.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// A recipe is a complete, named, one-gesture preset: manual settings,
9 +// format, lens, shutter behavior, and the capture context to apply.
10 +// Recipes are exportable files — they're meant to be shared (CLAUDE.md §6).
11 +//
12 +
13 +import Foundation
14 +import UniformTypeIdentifiers
15 +
16 +struct Recipe: Codable, Identifiable, Sendable, Hashable {
17 + typealias ID = UUID
18 +
19 + let id: ID
20 + var name: String
21 + var settings: ManualSettings
22 + /// Project automatically applied while this recipe is active (by name,
23 + /// so an imported recipe stays meaningful on another device).
24 + var autoProjectName: String?
25 + /// Scene signal that *proposes* this recipe — never a silent switch.
26 + var sceneTrigger: SceneTrigger
27 + /// Overlay a visible timestamp on captures (e.g. "Chantier" recipe).
28 + var showsTimestamp: Bool
29 + /// Prioritize OCR indexing for photos taken with this recipe.
30 + var prioritizeOCR: Bool
31 +
32 + init(
33 + id: ID = UUID(),
34 + name: String,
35 + settings: ManualSettings = .automatic,
36 + autoProjectName: String? = nil,
37 + sceneTrigger: SceneTrigger = .none,
38 + showsTimestamp: Bool = false,
39 + prioritizeOCR: Bool = false
40 + ) {
41 + self.id = id
42 + self.name = name
43 + self.settings = settings
44 + self.autoProjectName = autoProjectName
45 + self.sceneTrigger = sceneTrigger
46 + self.showsTimestamp = showsTimestamp
47 + self.prioritizeOCR = prioritizeOCR
48 + }
49 +
50 + enum SceneTrigger: String, Codable, Sendable {
51 + case none
52 + case lowLight
53 + case documentDetected
54 + }
55 +
56 + // MARK: - Import / export (shared as files — a free acquisition channel)
57 +
58 + static let fileType = UTType(exportedAs: "ai.spboucher.focale.recipe")
59 +
60 + func exportData() throws -> Data {
61 + let encoder = JSONEncoder()
62 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
63 + return try encoder.encode(self)
64 + }
65 +
66 + init(importing data: Data) throws {
67 + self = try JSONDecoder().decode(Recipe.self, from: data)
68 + }
69 +}
70 +
71 +extension Recipe {
72 + /// Built-in starting recipes. User-facing names in French.
73 + static let builtIns: [Recipe] = [
74 + Recipe(
75 + name: "Documents",
76 + settings: {
77 + var s = ManualSettings.automatic
78 + s.format = .heic
79 + return s
80 + }(),
81 + sceneTrigger: .documentDetected,
82 + prioritizeOCR: true
83 + ),
84 + Recipe(
85 + name: "Nuit longue pose",
86 + settings: {
87 + var s = ManualSettings.automatic
88 + s.iso = 100
89 + s.shutterSeconds = 0.5
90 + s.format = .proRAW
91 + return s
92 + }(),
93 + sceneTrigger: .lowLight
94 + ),
95 + Recipe(
96 + name: "Chantier",
97 + settings: {
98 + var s = ManualSettings.automatic
99 + s.lens = .ultraWide
100 + return s
101 + }(),
102 + autoProjectName: "Chantier",
103 + showsTimestamp: true
104 + ),
105 + ]
106 +}
added Focale/Capture/Recipes/RecipeManagerView.swift +123 −0
@@ -0,0 +1,123 @@
1 +//
2 +// RecipeManagerView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Manage, share and import recipes. Recipes travel as files — a free
9 +// acquisition channel (CLAUDE.md §6).
10 +//
11 +
12 +import SwiftUI
13 +import UniformTypeIdentifiers
14 +
15 +struct RecipeManagerView: View {
16 + @Environment(AppModel.self) private var app
17 + @Environment(\.dismiss) private var dismiss
18 +
19 + @State private var showsImporter = false
20 + @State private var importError: String?
21 +
22 + var body: some View {
23 + NavigationStack {
24 + List {
25 + ForEach(app.recipes.recipes) { recipe in
26 + row(recipe)
27 + }
28 + .onDelete { offsets in
29 + for offset in offsets {
30 + app.recipes.remove(app.recipes.recipes[offset].id)
31 + }
32 + }
33 + }
34 + .navigationTitle("Recettes")
35 + .navigationBarTitleDisplayMode(.inline)
36 + .toolbar {
37 + ToolbarItem(placement: .topBarLeading) {
38 + Button("Fermer") { dismiss() }
39 + }
40 + ToolbarItem(placement: .topBarTrailing) {
41 + Button("Importer…", systemImage: "square.and.arrow.down") {
42 + showsImporter = true
43 + }
44 + }
45 + }
46 + .fileImporter(
47 + isPresented: $showsImporter,
48 + allowedContentTypes: [.json, Recipe.fileType]
49 + ) { result in
50 + importRecipe(result)
51 + }
52 + .alert("Import impossible", isPresented: Binding(
53 + get: { importError != nil },
54 + set: { if !$0 { importError = nil } }
55 + )) {
56 + Button("OK", role: .cancel) {}
57 + } message: {
58 + Text(importError ?? "")
59 + }
60 + }
61 + }
62 +
63 + private func row(_ recipe: Recipe) -> some View {
64 + HStack {
65 + VStack(alignment: .leading, spacing: 3) {
66 + Text(recipe.name).font(.body.weight(.medium))
67 + Text(summary(of: recipe))
68 + .font(.caption)
69 + .foregroundStyle(DesignTokens.textSecondary)
70 + }
71 + Spacer()
72 + if app.recipes.activeRecipeID == recipe.id {
73 + Image(systemName: "checkmark.circle.fill")
74 + .foregroundStyle(DesignTokens.accent)
75 + }
76 + if let url = exportURL(for: recipe) {
77 + ShareLink(item: url) {
78 + Image(systemName: "square.and.arrow.up")
79 + }
80 + .buttonStyle(.borderless)
81 + }
82 + }
83 + .contentShape(Rectangle())
84 + .onTapGesture {
85 + app.camera.applyRecipe(recipe)
86 + }
87 + }
88 +
89 + private func summary(of recipe: Recipe) -> String {
90 + var parts: [String] = [recipe.settings.lens.displayName,
91 + recipe.settings.format.displayName]
92 + if let iso = recipe.settings.iso { parts.append("ISO \(Int(iso))") }
93 + if let shutter = recipe.settings.shutterSeconds {
94 + parts.append(shutter >= 0.25
95 + ? String(format: "%.1f s", shutter)
96 + : "1/\(Int((1.0 / shutter).rounded()))")
97 + }
98 + if let project = recipe.autoProjectName { parts.append("projet « \(project) »") }
99 + return parts.joined(separator: " · ")
100 + }
101 +
102 + /// Recipes are tiny JSON files; writing them eagerly per row is cheap.
103 + private func exportURL(for recipe: Recipe) -> URL? {
104 + guard let data = try? recipe.exportData() else { return nil }
105 + let safeName = recipe.name.replacingOccurrences(of: "/", with: "-")
106 + let url = FileManager.default.temporaryDirectory
107 + .appending(path: "\(safeName).focalerecipe")
108 + guard (try? data.write(to: url, options: .atomic)) != nil else { return nil }
109 + return url
110 + }
111 +
112 + private func importRecipe(_ result: Result<URL, Error>) {
113 + guard case .success(let url) = result else { return }
114 + let accessing = url.startAccessingSecurityScopedResource()
115 + defer { if accessing { url.stopAccessingSecurityScopedResource() } }
116 + do {
117 + let data = try Data(contentsOf: url)
118 + try app.recipes.importRecipe(from: data)
119 + } catch {
120 + importError = "Ce fichier n'est pas une recette Focale valide."
121 + }
122 + }
123 +}
added Focale/Capture/Recipes/RecipeStore.swift +93 −0
@@ -0,0 +1,93 @@
1 +//
2 +// RecipeStore.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import Observation
11 +
12 +@MainActor
13 +@Observable
14 +final class RecipeStore {
15 + private(set) var recipes: [Recipe] = []
16 + var activeRecipeID: Recipe.ID? {
17 + didSet { save() }
18 + }
19 +
20 + private let fileURL: URL
21 +
22 + var activeRecipe: Recipe? {
23 + guard let activeRecipeID else { return nil }
24 + return recipes.first { $0.id == activeRecipeID }
25 + }
26 +
27 + init(directory: URL = .applicationSupportDirectory) {
28 + fileURL = directory.appending(path: "recipes.json")
29 + load()
30 + if recipes.isEmpty {
31 + recipes = Recipe.builtIns
32 + save()
33 + }
34 + }
35 +
36 + func add(_ recipe: Recipe) {
37 + recipes.append(recipe)
38 + save()
39 + }
40 +
41 + func update(_ recipe: Recipe) {
42 + guard let index = recipes.firstIndex(where: { $0.id == recipe.id }) else { return }
43 + recipes[index] = recipe
44 + save()
45 + }
46 +
47 + func remove(_ id: Recipe.ID) {
48 + recipes.removeAll { $0.id == id }
49 + if activeRecipeID == id { activeRecipeID = nil }
50 + save()
51 + }
52 +
53 + @discardableResult
54 + func importRecipe(from data: Data) throws -> Recipe {
55 + var recipe = try Recipe(importing: data)
56 + // Re-identify on import so two devices can hold the same shared file.
57 + recipe = Recipe(
58 + name: recipe.name,
59 + settings: recipe.settings,
60 + autoProjectName: recipe.autoProjectName,
61 + sceneTrigger: recipe.sceneTrigger,
62 + showsTimestamp: recipe.showsTimestamp,
63 + prioritizeOCR: recipe.prioritizeOCR
64 + )
65 + add(recipe)
66 + return recipe
67 + }
68 +
69 + // MARK: - Persistence
70 +
71 + private struct Snapshot: Codable {
72 + var recipes: [Recipe]
73 + var activeRecipeID: Recipe.ID?
74 + }
75 +
76 + private func load() {
77 + guard let data = try? Data(contentsOf: fileURL),
78 + let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data)
79 + else { return }
80 + recipes = snapshot.recipes
81 + activeRecipeID = snapshot.activeRecipeID
82 + }
83 +
84 + private func save() {
85 + let snapshot = Snapshot(recipes: recipes, activeRecipeID: activeRecipeID)
86 + guard let data = try? JSONEncoder().encode(snapshot) else { return }
87 + try? FileManager.default.createDirectory(
88 + at: fileURL.deletingLastPathComponent(),
89 + withIntermediateDirectories: true
90 + )
91 + try? data.write(to: fileURL, options: .atomic)
92 + }
93 +}
added Focale/Capture/Session/CaptureService.swift +309 −0
@@ -0,0 +1,309 @@
1 +//
2 +// CaptureService.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Owns the AVCaptureSession. All session work happens on a dedicated
9 +// serial queue, never on the main actor (CLAUDE.md §10). Configured
10 +// once; reconfigured inside begin/commitConfiguration.
11 +//
12 +
13 +@preconcurrency import AVFoundation
14 +import Foundation
15 +
16 +/// Serialized access to the capture session. `@unchecked Sendable` because
17 +/// every mutable member is confined to `sessionQueue`.
18 +final class CaptureService: NSObject, @unchecked Sendable {
19 +
20 + let session = AVCaptureSession()
21 +
22 + private let sessionQueue = DispatchQueue(label: "ai.spboucher.focale.capture.session")
23 + private let photoOutput = AVCapturePhotoOutput()
24 + private var videoInput: AVCaptureDeviceInput?
25 + private var device: AVCaptureDevice?
26 + /// Delegates retained for the duration of their capture, keyed by settings ID.
27 + private var inFlightProcessors: [Int64: PhotoCaptureProcessor] = [:]
28 +
29 + // MARK: - Configuration (once, off the main thread)
30 +
31 + func configure(lens: LensKind = .wide) async throws -> DeviceCapabilities {
32 + try await withCheckedThrowingContinuation { continuation in
33 + sessionQueue.async {
34 + do {
35 + let capabilities = try self.configureSession(lens: lens)
36 + continuation.resume(returning: capabilities)
37 + } catch {
38 + continuation.resume(throwing: error)
39 + }
40 + }
41 + }
42 + }
43 +
44 + func start() {
45 + sessionQueue.async {
46 + guard !self.session.isRunning else { return }
47 + self.session.startRunning()
48 + }
49 + }
50 +
51 + func stop() {
52 + sessionQueue.async {
53 + guard self.session.isRunning else { return }
54 + self.session.stopRunning()
55 + }
56 + }
57 +
58 + private func configureSession(lens: LensKind) throws -> DeviceCapabilities {
59 + session.beginConfiguration()
60 + defer { session.commitConfiguration() }
61 +
62 + session.sessionPreset = .photo
63 +
64 + if let videoInput {
65 + session.removeInput(videoInput)
66 + self.videoInput = nil
67 + }
68 +
69 + guard let device = Self.device(for: lens) ?? Self.device(for: .wide) else {
70 + throw CaptureError.noCamera
71 + }
72 + let input = try AVCaptureDeviceInput(device: device)
73 + guard session.canAddInput(input) else { throw CaptureError.cannotConfigure }
74 + session.addInput(input)
75 + videoInput = input
76 + self.device = device
77 +
78 + if !session.outputs.contains(photoOutput) {
79 + guard session.canAddOutput(photoOutput) else { throw CaptureError.cannotConfigure }
80 + session.addOutput(photoOutput)
81 + }
82 + photoOutput.maxPhotoQualityPrioritization = .quality
83 + // ProRAW must be enabled after the output joins a session.
84 + if photoOutput.isAppleProRAWSupported {
85 + photoOutput.isAppleProRAWEnabled = true
86 + }
87 + // Portrait-only app: without this, captures come out rotated.
88 + if let connection = photoOutput.connection(with: .video),
89 + connection.isVideoRotationAngleSupported(90) {
90 + connection.videoRotationAngle = 90
91 + }
92 +
93 + return snapshotCapabilities(of: device)
94 + }
95 +
96 + /// Queried live — never assume anything about the iPhone model.
97 + private func snapshotCapabilities(of device: AVCaptureDevice) -> DeviceCapabilities {
98 + let format = device.activeFormat
99 + let lenses = LensKind.allCases.filter { Self.device(for: $0) != nil }
100 + let minShutter = max(format.minExposureDuration.seconds, 1.0 / 8000.0)
101 + let maxShutter = min(format.maxExposureDuration.seconds, 1.0)
102 +
103 + return DeviceCapabilities(
104 + availableLenses: lenses,
105 + supportsProRAW: photoOutput.isAppleProRAWSupported,
106 + isoRange: format.minISO...max(format.maxISO, format.minISO + 1),
107 + shutterRange: minShutter...max(maxShutter, minShutter * 2),
108 + exposureBiasRange: device.minExposureTargetBias...device.maxExposureTargetBias,
109 + maxWhiteBalanceGain: device.maxWhiteBalanceGain,
110 + maxZoomFactor: min(device.activeFormat.videoMaxZoomFactor, 10.0),
111 + supportsManualFocus: device.isLockingFocusWithCustomLensPositionSupported,
112 + hasDepthCapture: !format.supportedDepthDataFormats.isEmpty,
113 + hasFlash: device.hasFlash
114 + )
115 + }
116 +
117 + private static func device(for lens: LensKind) -> AVCaptureDevice? {
118 + let deviceType: AVCaptureDevice.DeviceType = switch lens {
119 + case .ultraWide: .builtInUltraWideCamera
120 + case .wide: .builtInWideAngleCamera
121 + case .telephoto: .builtInTelephotoCamera
122 + }
123 + return AVCaptureDevice.DiscoverySession(
124 + deviceTypes: [deviceType], mediaType: .video, position: .back
125 + ).devices.first
126 + }
127 +
128 + // MARK: - Manual controls
129 +
130 + /// Applies a settings snapshot. Any nil field returns that control to auto.
131 + func apply(_ settings: ManualSettings) {
132 + sessionQueue.async {
133 + guard let device = self.device else { return }
134 + do {
135 + try device.lockForConfiguration()
136 + defer { device.unlockForConfiguration() }
137 +
138 + self.applyExposure(settings, to: device)
139 + self.applyFocus(settings, to: device)
140 + self.applyWhiteBalance(settings, to: device)
141 +
142 + let zoom = settings.zoomFactor.clamped(
143 + to: 1.0...device.activeFormat.videoMaxZoomFactor
144 + )
145 + if abs(device.videoZoomFactor - zoom) > 0.01 {
146 + device.videoZoomFactor = zoom
147 + }
148 + } catch {
149 + // Configuration lock failed; controls stay at their last value.
150 + }
151 + }
152 + }
153 +
154 + private func applyExposure(_ settings: ManualSettings, to device: AVCaptureDevice) {
155 + let format = device.activeFormat
156 + if let iso = settings.iso, let shutter = settings.shutterSeconds,
157 + device.isExposureModeSupported(.custom) {
158 + let clampedISO = iso.clamped(to: format.minISO...format.maxISO)
159 + let duration = CMTime(
160 + seconds: shutter.clamped(
161 + to: format.minExposureDuration.seconds...format.maxExposureDuration.seconds
162 + ),
163 + preferredTimescale: 1_000_000_000
164 + )
165 + device.setExposureModeCustom(duration: duration, iso: clampedISO)
166 + } else if device.isExposureModeSupported(.continuousAutoExposure) {
167 + device.exposureMode = .continuousAutoExposure
168 + let bias = settings.exposureBias.clamped(
169 + to: device.minExposureTargetBias...device.maxExposureTargetBias
170 + )
171 + device.setExposureTargetBias(bias)
172 + }
173 + }
174 +
175 + private func applyFocus(_ settings: ManualSettings, to device: AVCaptureDevice) {
176 + if let lensPosition = settings.lensPosition,
177 + device.isLockingFocusWithCustomLensPositionSupported {
178 + device.setFocusModeLocked(lensPosition: lensPosition.clamped(to: 0...1))
179 + } else if device.isFocusModeSupported(.continuousAutoFocus) {
180 + device.focusMode = .continuousAutoFocus
181 + }
182 + }
183 +
184 + private func applyWhiteBalance(_ settings: ManualSettings, to device: AVCaptureDevice) {
185 + if let kelvin = settings.whiteBalanceKelvin, device.isLockingWhiteBalanceWithCustomDeviceGainsSupported {
186 + let values = AVCaptureDevice.WhiteBalanceTemperatureAndTintValues(
187 + temperature: kelvin, tint: 0
188 + )
189 + var gains = device.deviceWhiteBalanceGains(for: values)
190 + let maxGain = device.maxWhiteBalanceGain
191 + gains.redGain = gains.redGain.clamped(to: 1...maxGain)
192 + gains.greenGain = gains.greenGain.clamped(to: 1...maxGain)
193 + gains.blueGain = gains.blueGain.clamped(to: 1...maxGain)
194 + device.setWhiteBalanceModeLocked(with: gains)
195 + } else if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) {
196 + device.whiteBalanceMode = .continuousAutoWhiteBalance
197 + }
198 + }
199 +
200 + func switchLens(_ lens: LensKind) async throws -> DeviceCapabilities {
201 + try await withCheckedThrowingContinuation { continuation in
202 + sessionQueue.async {
203 + do {
204 + continuation.resume(returning: try self.configureSession(lens: lens))
205 + } catch {
206 + continuation.resume(throwing: error)
207 + }
208 + }
209 + }
210 + }
211 +
212 + // MARK: - Scene signal (raw sensor reads, no inference)
213 +
214 + func currentSceneSignal() async -> SceneSignal {
215 + await withCheckedContinuation { continuation in
216 + sessionQueue.async {
217 + guard let device = self.device else {
218 + continuation.resume(returning: .unknown)
219 + return
220 + }
221 + let ev = SceneSignal.estimateEV(
222 + iso: device.iso,
223 + shutterSeconds: device.exposureDuration.seconds
224 + )
225 + let signal = SceneSignal(
226 + luminanceEV: ev,
227 + motion: .unknown,
228 + subjectDistance: nil,
229 + isLowLight: (ev ?? 10) < 4
230 + )
231 + continuation.resume(returning: signal)
232 + }
233 + }
234 + }
235 +
236 + // MARK: - Capture
237 +
238 + /// The shutter path is sacred (CLAUDE.md §4): this enqueues the capture
239 + /// and returns immediately. All processing and disk I/O are deferred to
240 + /// the photo output's own callbacks.
241 + func capturePhoto(
242 + format: CaptureFormat,
243 + flashMode: FlashMode,
244 + context: CaptureContext,
245 + completion: @escaping @Sendable (Result<CapturedPhoto, Error>) -> Void
246 + ) {
247 + sessionQueue.async {
248 + let settings = self.makePhotoSettings(for: format, flashMode: flashMode)
249 + let processor = PhotoCaptureProcessor(context: context) { [weak self] result in
250 + if let self {
251 + self.sessionQueue.async {
252 + self.inFlightProcessors[settings.uniqueID] = nil
253 + }
254 + }
255 + completion(result)
256 + }
257 + self.inFlightProcessors[settings.uniqueID] = processor
258 + self.photoOutput.capturePhoto(with: settings, delegate: processor)
259 + }
260 + }
261 +
262 + private func makePhotoSettings(
263 + for format: CaptureFormat, flashMode: FlashMode
264 + ) -> AVCapturePhotoSettings {
265 + let settings: AVCapturePhotoSettings
266 + if format == .proRAW,
267 + photoOutput.isAppleProRAWEnabled,
268 + let rawFormat = photoOutput.availableRawPhotoPixelFormatTypes.first(
269 + where: { AVCapturePhotoOutput.isAppleProRAWPixelFormat($0) }
270 + ) {
271 + settings = AVCapturePhotoSettings(
272 + rawPixelFormatType: rawFormat,
273 + processedFormat: [AVVideoCodecKey: AVVideoCodecType.hevc]
274 + )
275 + } else if photoOutput.availablePhotoCodecTypes.contains(.hevc) {
276 + settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc])
277 + } else {
278 + settings = AVCapturePhotoSettings()
279 + }
280 + settings.photoQualityPrioritization = .quality
281 + let avFlashMode = flashMode.avFlashMode
282 + if photoOutput.supportedFlashModes.contains(avFlashMode) {
283 + settings.flashMode = avFlashMode
284 + }
285 + return settings
286 + }
287 +}
288 +
289 +enum CaptureError: Error {
290 + case noCamera
291 + case cannotConfigure
292 + case captureFailed
293 +}
294 +
295 +extension FlashMode {
296 + var avFlashMode: AVCaptureDevice.FlashMode {
297 + switch self {
298 + case .auto: .auto
299 + case .on: .on
300 + case .off: .off
301 + }
302 + }
303 +}
304 +
305 +extension Comparable {
306 + func clamped(to range: ClosedRange<Self>) -> Self {
307 + min(max(self, range.lowerBound), range.upperBound)
308 + }
309 +}
added Focale/Capture/Session/DeviceCapabilities.swift +50 −0
@@ -0,0 +1,50 @@
1 +//
2 +// DeviceCapabilities.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Every hardware capability is queried before a control is exposed
9 +// (CLAUDE.md §4): no dead buttons, no assumptions about iPhone model.
10 +//
11 +
12 +import Foundation
13 +
14 +/// Sendable snapshot of what the active capture device supports.
15 +struct DeviceCapabilities: Sendable {
16 + var availableLenses: [LensKind]
17 + var supportsProRAW: Bool
18 + var isoRange: ClosedRange<Float>
19 + var shutterRange: ClosedRange<Double> // seconds
20 + var exposureBiasRange: ClosedRange<Float> // EV
21 + var maxWhiteBalanceGain: Float
22 + var maxZoomFactor: Double
23 + var supportsManualFocus: Bool
24 + var hasDepthCapture: Bool // LiDAR / depth for SceneSignal
25 + var hasFlash: Bool
26 +
27 + static let none = DeviceCapabilities(
28 + availableLenses: [],
29 + supportsProRAW: false,
30 + isoRange: 100...100,
31 + shutterRange: 0.008...0.008,
32 + exposureBiasRange: 0...0,
33 + maxWhiteBalanceGain: 1,
34 + maxZoomFactor: 1,
35 + supportsManualFocus: false,
36 + hasDepthCapture: false,
37 + hasFlash: false
38 + )
39 +
40 + func supports(_ control: CaptureControl) -> Bool {
41 + switch control {
42 + case .iso: isoRange.lowerBound < isoRange.upperBound
43 + case .shutterSpeed: shutterRange.lowerBound < shutterRange.upperBound
44 + case .exposureBias: exposureBiasRange.lowerBound < exposureBiasRange.upperBound
45 + case .focus: supportsManualFocus
46 + case .whiteBalance: maxWhiteBalanceGain > 1
47 + case .zoom: maxZoomFactor > 1
48 + }
49 + }
50 +}
added Focale/Design/DesignTokens.swift +49 −0
@@ -0,0 +1,49 @@
1 +//
2 +// DesignTokens.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +enum DesignTokens {
12 + // MARK: - Color
13 +
14 + /// Warm amber accent — the aperture color of the app icon.
15 + static let accent = Color(red: 1.0, green: 0.58, blue: 0.16)
16 + static let surface = Color(red: 0.07, green: 0.08, blue: 0.10)
17 + static let surfaceRaised = Color(red: 0.12, green: 0.13, blue: 0.16)
18 + static let textSecondary = Color.white.opacity(0.6)
19 +
20 + /// Generated (model) content is always visually distinct from real data
21 + /// (CLAUDE.md §8). This tint marks anything that came out of the LLM.
22 + static let generatedContent = Color(red: 0.62, green: 0.55, blue: 0.95)
23 +
24 + // MARK: - Metrics
25 +
26 + static let cornerRadius: CGFloat = 14
27 + static let controlSpacing: CGFloat = 12
28 + static let shutterDiameter: CGFloat = 76
29 +
30 + // MARK: - Typography
31 +
32 + /// Monospaced digits so dial values don't jitter while scrubbing.
33 + static let controlValueFont = Font.system(size: 15, weight: .semibold, design: .rounded)
34 + .monospacedDigit()
35 + static let controlLabelFont = Font.system(size: 11, weight: .medium, design: .rounded)
36 +}
37 +
38 +/// Small badge marking model-generated text, per CLAUDE.md §8:
39 +/// a generated description must never look like a real datum.
40 +struct GeneratedBadge: View {
41 + var body: some View {
42 + Label("Générée", systemImage: "sparkles")
43 + .font(.caption2.weight(.semibold))
44 + .padding(.horizontal, 6)
45 + .padding(.vertical, 2)
46 + .background(DesignTokens.generatedContent.opacity(0.2), in: Capsule())
47 + .foregroundStyle(DesignTokens.generatedContent)
48 + }
49 +}
added Focale/Index/Scheduler/IndexPipeline.swift +282 −0
@@ -0,0 +1,282 @@
1 +//
2 +// IndexPipeline.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Walks the system photo library newest → oldest (people search the last
9 +// two years), runs stage 1 on everything, stage 2 on candidates only,
10 +// and checkpoints so an interruption never restarts from zero.
11 +//
12 +
13 +import CoreGraphics
14 +import Foundation
15 +import Photos
16 +import SwiftData
17 +import UIKit
18 +
19 +struct IndexProgress: Sendable {
20 + var indexed: Int
21 + var total: Int
22 +
23 + /// Honest progress line for the UI (CLAUDE.md §5), in French.
24 + var statusLine: String {
25 + guard total > 0 else { return "Photothèque vide" }
26 + if indexed >= total { return "\(total) photos indexées" }
27 + return "\(indexed.formatted()) / \(total.formatted()) photos indexées — les plus récentes sont déjà cherchables"
28 + }
29 +}
30 +
31 +actor IndexPipeline {
32 +
33 + private let container: ModelContainer
34 + private let vision = VisionIndexer()
35 + private let semantic = SemanticIndexer()
36 + private var isSuspendedForCapture = false
37 + private var isBackfilling = false
38 + private var isForegroundIndexing = false
39 +
40 + init(container: ModelContainer) {
41 + self.container = container
42 + }
43 +
44 + // MARK: - Capture priority (CLAUDE.md §9: viewfinder first, always)
45 +
46 + func suspendForCapture() {
47 + isSuspendedForCapture = true
48 + }
49 +
50 + func resumeAfterCapture() {
51 + isSuspendedForCapture = false
52 + }
53 +
54 + // MARK: - Photos taken in Focale: already understood
55 +
56 + /// Zero-inference ingestion: the context alone makes the photo findable.
57 + /// Stage 1 + stage 2 follow immediately — a fresh Focale photo is the
58 + /// best candidate there is (context present, cost minimal).
59 + func ingestCapturedPhoto(localIdentifier: String, context: CaptureContext) async {
60 + let modelContext = ModelContext(container)
61 + let record = PhotoRecord(localIdentifier: localIdentifier, captureDate: .now)
62 + record.captureContext = context
63 + modelContext.insert(record)
64 + try? modelContext.save()
65 +
66 + await indexAsset(localIdentifier: localIdentifier, runSemantic: true)
67 + }
68 +
69 + func updateSubjectHint(localIdentifier: String, hint: String) {
70 + let modelContext = ModelContext(container)
71 + guard let record = fetchRecord(localIdentifier, in: modelContext) else { return }
72 + record.subjectHint = hint
73 + if var context = record.captureContext {
74 + context.subjectHint = hint
75 + record.captureContext = context
76 + }
77 + try? modelContext.save()
78 + }
79 +
80 + // MARK: - Progress (honest, always)
81 +
82 + func progress() -> IndexProgress {
83 + let total = PHAsset.fetchAssets(with: .image, options: nil).count
84 + let modelContext = ModelContext(container)
85 + let indexed = (try? modelContext.fetchCount(
86 + FetchDescriptor<PhotoRecord>(predicate: #Predicate { $0.visionIndexedAt != nil })
87 + )) ?? 0
88 + return IndexProgress(indexed: indexed, total: total)
89 + }
90 +
91 + /// Opportunistic stage-1 pass over the most recent photos while the app
92 + /// is open — search must be useful immediately, not after the first
93 + /// night on the charger (CLAUDE.md §5). Yields to the viewfinder and
94 + /// backs off on heat; the deep backfill stays with BGProcessingTask.
95 + func indexRecentInForeground(limit: Int = 400) async {
96 + guard !isForegroundIndexing else { return }
97 + isForegroundIndexing = true
98 + defer { isForegroundIndexing = false }
99 +
100 + let options = PHFetchOptions()
101 + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
102 + options.fetchLimit = limit
103 + let assets = PHAsset.fetchAssets(with: .image, options: options)
104 +
105 + for index in 0..<assets.count {
106 + guard !isSuspendedForCapture, !Task.isCancelled else { return }
107 + let thermal = ProcessInfo.processInfo.thermalState
108 + guard thermal == .nominal || thermal == .fair else { return }
109 + await indexAsset(asset: assets.object(at: index), runSemantic: false)
110 + await Task.yield()
111 + }
112 + }
113 +
114 + // MARK: - Backfill (newest → oldest, resumable)
115 +
116 + /// Runs until done, cancelled, or `shouldContinue` says stop
117 + /// (BGTask expiration, thermal state, capture opening).
118 + func backfill(shouldContinue: @escaping @Sendable () -> Bool) async {
119 + guard !isBackfilling else { return }
120 + isBackfilling = true
121 + defer { isBackfilling = false }
122 +
123 + let options = PHFetchOptions()
124 + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
125 + if let checkpoint = loadCheckpointDate() {
126 + options.predicate = NSPredicate(format: "creationDate < %@", checkpoint as NSDate)
127 + }
128 + let assets = PHAsset.fetchAssets(with: .image, options: options)
129 +
130 + for index in 0..<assets.count {
131 + guard shouldContinue(), !isSuspendedForCapture, !Task.isCancelled else { return }
132 + // Stage 2 suspends at .fair; stage 1 keeps going until .serious.
133 + if ProcessInfo.processInfo.thermalState == .serious ||
134 + ProcessInfo.processInfo.thermalState == .critical { return }
135 +
136 + let asset = assets.object(at: index)
137 + await indexAsset(asset: asset, runSemantic: false)
138 + saveCheckpointDate(asset.creationDate)
139 + }
140 + }
141 +
142 + // MARK: - Single-asset indexing
143 +
144 + func indexAsset(localIdentifier: String, runSemantic: Bool) async {
145 + let fetched = PHAsset.fetchAssets(
146 + withLocalIdentifiers: [localIdentifier], options: nil
147 + )
148 + guard let asset = fetched.firstObject else { return }
149 + await indexAsset(asset: asset, runSemantic: runSemantic)
150 + }
151 +
152 + private func indexAsset(asset: PHAsset, runSemantic: Bool) async {
153 + let modelContext = ModelContext(container)
154 + let identifier = asset.localIdentifier
155 + let record = fetchRecord(identifier, in: modelContext)
156 + ?? {
157 + let new = PhotoRecord(localIdentifier: identifier, captureDate: asset.creationDate)
158 + modelContext.insert(new)
159 + return new
160 + }()
161 +
162 + if record.visionIndexedAt == nil {
163 + guard let image = await requestThumbnail(for: asset) else {
164 + try? modelContext.save()
165 + return
166 + }
167 + guard let signals = try? await vision.index(image) else {
168 + try? modelContext.save()
169 + return
170 + }
171 + record.ocrText = signals.ocrText
172 + record.featurePrint = signals.featurePrint
173 + record.classificationLabels = signals.classificationLabels
174 + record.faceCount = signals.faceCount
175 + record.visionIndexedAt = .now
176 + record.isFavorite = asset.isFavorite
177 + try? modelContext.save()
178 + }
179 +
180 + // Stage 2 gate: candidates only, and never past .fair thermal state.
181 + let wantsSemantic = runSemantic || record.isFavorite
182 + if wantsSemantic,
183 + record.semanticIndexedAt == nil, !record.semanticRefused,
184 + ProcessInfo.processInfo.thermalState == .nominal
185 + || ProcessInfo.processInfo.thermalState == .fair {
186 + await runSemanticStage(on: record, in: modelContext)
187 + }
188 + }
189 +
190 + /// Stage 2 on an explicit candidate set (user request, search deepening).
191 + func deepen(localIdentifiers: [String], reason: SemanticCandidateReason) async {
192 + let modelContext = ModelContext(container)
193 + for identifier in localIdentifiers {
194 + guard !isSuspendedForCapture else { return }
195 + guard let record = fetchRecord(identifier, in: modelContext),
196 + record.semanticIndexedAt == nil, !record.semanticRefused
197 + else { continue }
198 + await runSemanticStage(on: record, in: modelContext)
199 + }
200 + }
201 +
202 + private func runSemanticStage(on record: PhotoRecord, in modelContext: ModelContext) async {
203 + let candidate = SemanticCandidate(
204 + localIdentifier: record.localIdentifier,
205 + ocrExcerpt: record.ocrText,
206 + classificationLabels: record.classificationLabels,
207 + contextJSON: record.captureContext?.metadataJSON()
208 + )
209 + if let semantics = await semantic.analyze(candidate) {
210 + record.gist = semantics.gist
211 + record.kindRawValue = semantics.kind.rawValue
212 + record.entities = semantics.entities
213 + record.hasActionableInfo = semantics.hasActionableInfo
214 + record.semanticIndexedAt = .now
215 + } else if SemanticIndexer.isModelAvailable {
216 + record.semanticRefused = true // normal state, not an error
217 + }
218 + try? modelContext.save()
219 + }
220 +
221 + // MARK: - Helpers
222 +
223 + private func fetchRecord(
224 + _ localIdentifier: String, in modelContext: ModelContext
225 + ) -> PhotoRecord? {
226 + var descriptor = FetchDescriptor<PhotoRecord>(
227 + predicate: #Predicate { $0.localIdentifier == localIdentifier }
228 + )
229 + descriptor.fetchLimit = 1
230 + return try? modelContext.fetch(descriptor).first
231 + }
232 +
233 + /// Bridges PHImageManager's may-fire-twice handler to a single resume.
234 + /// @unchecked: the handler is delivered serially on the main queue.
235 + private final class ResumeFlag: @unchecked Sendable {
236 + var resumed = false
237 + }
238 +
239 + /// Thumbnails come from PhotoKit, never stored twice (CLAUDE.md §9).
240 + /// The result handler is @Sendable so it carries no actor isolation —
241 + /// PhotoKit calls it on the main queue, not on this actor.
242 + private func requestThumbnail(for asset: PHAsset, size: CGFloat = 1024) async -> CGImage? {
243 + let options = PHImageRequestOptions()
244 + options.deliveryMode = .highQualityFormat
245 + options.isNetworkAccessAllowed = true // the user's own iCloud photo, inbound only
246 + options.isSynchronous = false
247 + let flag = ResumeFlag()
248 + return await withCheckedContinuation { continuation in
249 + let handler: @Sendable (UIImage?, [AnyHashable: Any]?) -> Void = { image, info in
250 + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
251 + guard !degraded, !flag.resumed else { return }
252 + flag.resumed = true
253 + continuation.resume(returning: image?.cgImage)
254 + }
255 + PHImageManager.default().requestImage(
256 + for: asset,
257 + targetSize: CGSize(width: size, height: size),
258 + contentMode: .aspectFit,
259 + options: options,
260 + resultHandler: handler
261 + )
262 + }
263 + }
264 +
265 + private func loadCheckpointDate() -> Date? {
266 + let modelContext = ModelContext(container)
267 + return (try? modelContext.fetch(FetchDescriptor<IndexCheckpoint>()))?
268 + .first?.oldestIndexedDate
269 + }
270 +
271 + private func saveCheckpointDate(_ date: Date?) {
272 + guard let date else { return }
273 + let modelContext = ModelContext(container)
274 + if let checkpoint = (try? modelContext.fetch(FetchDescriptor<IndexCheckpoint>()))?.first {
275 + checkpoint.oldestIndexedDate = date
276 + checkpoint.updatedAt = .now
277 + } else {
278 + modelContext.insert(IndexCheckpoint(oldestIndexedDate: date))
279 + }
280 + try? modelContext.save()
281 + }
282 +}
added Focale/Index/Scheduler/IndexScheduler.swift +87 −0
@@ -0,0 +1,87 @@
1 +//
2 +// IndexScheduler.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// BGProcessingTask: on charge, screen off — never on battery
9 +// (CLAUDE.md §5). Incremental resume via the pipeline's checkpoint.
10 +//
11 +
12 +import BackgroundTasks
13 +import Foundation
14 +
15 +final class IndexScheduler: @unchecked Sendable {
16 +
17 + static let shared = IndexScheduler()
18 + static let taskIdentifier = "ai.spboucher.focale.index"
19 +
20 + private var pipeline: IndexPipeline?
21 +
22 + private init() {}
23 +
24 + /// Must be called before the app finishes launching.
25 + func register(pipeline: IndexPipeline) {
26 + self.pipeline = pipeline
27 + BGTaskScheduler.shared.register(
28 + forTaskWithIdentifier: Self.taskIdentifier,
29 + using: nil
30 + ) { [weak self] task in
31 + guard let task = task as? BGProcessingTask else { return }
32 + self?.handle(task)
33 + }
34 + schedule()
35 + }
36 +
37 + func schedule() {
38 + let request = BGProcessingTaskRequest(identifier: Self.taskIdentifier)
39 + request.requiresExternalPower = true // never on battery
40 + request.requiresNetworkConnectivity = false
41 + try? BGTaskScheduler.shared.submit(request)
42 + }
43 +
44 + private func handle(_ task: BGProcessingTask) {
45 + guard let pipeline else {
46 + task.setTaskCompleted(success: false)
47 + return
48 + }
49 +
50 + let expired = ExpirationFlag()
51 + task.expirationHandler = {
52 + expired.set()
53 + }
54 +
55 + // Expiration flips the flag; backfill polls it between photos and
56 + // the checkpoint already holds our position for the next night.
57 + let taskBox = UncheckedSendableBox(task)
58 + Task {
59 + await pipeline.backfill { !expired.isSet }
60 + self.schedule() // re-arm for the next night
61 + taskBox.value.setTaskCompleted(success: true)
62 + }
63 + }
64 +}
65 +
66 +/// Wrapper moving a non-Sendable framework object we use serially anyway.
67 +private struct UncheckedSendableBox<Value>: @unchecked Sendable {
68 + let value: Value
69 + init(_ value: Value) { self.value = value }
70 +}
71 +
72 +/// Tiny thread-safe flag bridging the BGTask expiration callback.
73 +private final class ExpirationFlag: @unchecked Sendable {
74 + private let lock = NSLock()
75 + private var flag = false
76 +
77 + var isSet: Bool {
78 + lock.lock(); defer { lock.unlock() }
79 + return flag
80 + }
81 +
82 + func set() {
83 + lock.lock()
84 + flag = true
85 + lock.unlock()
86 + }
87 +}
added Focale/Index/Semantic/PhotoSemantics.swift +33 −0
@@ -0,0 +1,33 @@
1 +//
2 +// PhotoSemantics.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Structured output of stage 2. Never a free String out of the model
9 +// (CLAUDE.md §10) — always @Generable.
10 +//
11 +
12 +import Foundation
13 +import FoundationModels
14 +
15 +@Generable
16 +enum PhotoKind: String, CaseIterable {
17 + case document, receipt, person, place, object, screen, food, animal, other
18 +}
19 +
20 +@Generable
21 +struct PhotoSemantics {
22 + @Guide(description: "Ce que montre l'image, une phrase, factuelle")
23 + let gist: String
24 +
25 + @Guide(description: "Type de contenu")
26 + let kind: PhotoKind
27 +
28 + @Guide(description: "Éléments cherchables présents dans l'image", .count(0...8))
29 + let entities: [String]
30 +
31 + @Guide(description: "Vrai si l'image contient une information à conserver (montant, date, code, adresse)")
32 + let hasActionableInfo: Bool
33 +}
added Focale/Index/Semantic/SemanticIndexer.swift +83 −0
@@ -0,0 +1,83 @@
1 +//
2 +// SemanticIndexer.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Stage 2 — Foundation Models, on candidates only (CLAUDE.md §5).
9 +// The math that rules everything: 40 000 photos × 1–3 s = 11–33 h, serial.
10 +// So this actor runs one inference at a time, only on photos that earn it.
11 +//
12 +// Note: direct image input in the prompt arrives with the iOS 27 SDK;
13 +// until then stage 2 reasons over stage-1 signals + capture context,
14 +// which is already rich for Focale-taken photos.
15 +//
16 +
17 +import Foundation
18 +import FoundationModels
19 +
20 +/// Why a photo earned a stage-2 pass. Anything else stays Vision-only.
21 +enum SemanticCandidateReason: Sendable {
22 + case capturedInFocale // context already present: minimal cost, maximal value
23 + case userRequested // the user opened the photo and asked
24 + case searchDeepening // stage-1 search found nothing; deepen a small subset
25 + case markedImportant // favorite, shared, frequently opened
26 +}
27 +
28 +/// Inputs handed to the model — stage-1 signals, never the photo library.
29 +struct SemanticCandidate: Sendable {
30 + var localIdentifier: String
31 + var ocrExcerpt: String?
32 + var classificationLabels: [String]
33 + var contextJSON: String?
34 +}
35 +
36 +actor SemanticIndexer {
37 +
38 + static var isModelAvailable: Bool {
39 + SystemLanguageModel.default.availability == .available
40 + }
41 +
42 + /// Serialized on the Neural Engine anyway (CLAUDE.md §2) — one session,
43 + /// one inference at a time.
44 + private var session: LanguageModelSession?
45 +
46 + /// Returns nil on guardrail refusal — a normal state, no error surfaced
47 + /// (CLAUDE.md §8). The photo stays indexed by stage 1.
48 + func analyze(_ candidate: SemanticCandidate) async -> PhotoSemantics? {
49 + guard Self.isModelAvailable else { return nil }
50 +
51 + let session = self.session ?? LanguageModelSession(instructions: """
52 + Tu analyses les signaux extraits d'une photo pour la rendre cherchable.
53 + Réponds uniquement à partir des signaux fournis. Sur incertitude, \
54 + reste vague plutôt que d'inventer. Ne devine jamais un chiffre, \
55 + un montant ou une date : ils viennent de l'OCR, pas de toi.
56 + """)
57 + self.session = session
58 +
59 + var prompt = "Signaux de la photo :\n"
60 + if let ocr = candidate.ocrExcerpt, !ocr.isEmpty {
61 + prompt += "Texte OCR (données réelles) :\n\(String(ocr.prefix(1200)))\n"
62 + }
63 + if !candidate.classificationLabels.isEmpty {
64 + prompt += "Classification : \(candidate.classificationLabels.joined(separator: ", "))\n"
65 + }
66 + if let context = candidate.contextJSON {
67 + prompt += "Contexte de capture : \(context)\n"
68 + }
69 +
70 + do {
71 + let response = try await session.respond(
72 + to: prompt,
73 + generating: PhotoSemantics.self
74 + )
75 + return response.content
76 + } catch {
77 + // Guardrail refusal or transient failure: treat as normal,
78 + // reset the session so one bad turn doesn't poison the next.
79 + self.session = nil
80 + return nil
81 + }
82 + }
83 +}
added Focale/Index/Store/IndexStore.swift +99 −0
@@ -0,0 +1,99 @@
1 +//
2 +// IndexStore.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// SwiftData store for the photo index. Photos are referenced by
9 +// localIdentifier only — never duplicated, moved, or modified.
10 +// Target: < 500 MB for 50 000 photos; thumbnails come from PhotoKit,
11 +// never stored twice (CLAUDE.md §9).
12 +//
13 +
14 +import Foundation
15 +import SwiftData
16 +
17 +@Model
18 +final class PhotoRecord {
19 + @Attribute(.unique) var localIdentifier: String
20 + var captureDate: Date?
21 +
22 + // Stage progress
23 + var visionIndexedAt: Date?
24 + var semanticIndexedAt: Date?
25 + /// Model guardrails refused the image — a normal state, not an error
26 + /// (CLAUDE.md §8). Stage-1 data remains fully usable.
27 + var semanticRefused: Bool
28 +
29 + // Stage 1 — Vision (real data)
30 + var ocrText: String?
31 + var featurePrint: Data?
32 + var classificationLabels: [String]
33 + var faceCount: Int
34 +
35 + // Capture context (real data, free at shutter time)
36 + var contextJSON: Data?
37 + var projectName: String?
38 + var subjectHint: String?
39 + var placeName: String?
40 + var placeLocality: String?
41 +
42 + // Stage 2 — Foundation Models (generated data, displayed as such)
43 + var gist: String?
44 + var kindRawValue: String?
45 + var entities: [String]
46 + var hasActionableInfo: Bool
47 +
48 + var isFavorite: Bool
49 +
50 + init(localIdentifier: String, captureDate: Date?) {
51 + self.localIdentifier = localIdentifier
52 + self.captureDate = captureDate
53 + self.semanticRefused = false
54 + self.classificationLabels = []
55 + self.faceCount = 0
56 + self.entities = []
57 + self.hasActionableInfo = false
58 + self.isFavorite = false
59 + }
60 +
61 + var captureContext: CaptureContext? {
62 + get {
63 + guard let contextJSON else { return nil }
64 + return try? JSONDecoder().decode(CaptureContext.self, from: contextJSON)
65 + }
66 + set {
67 + contextJSON = newValue.flatMap { try? JSONEncoder().encode($0) }
68 + projectName = newValue?.projectName
69 + subjectHint = newValue?.subjectHint
70 + placeName = newValue?.place?.name
71 + placeLocality = newValue?.place?.locality
72 + }
73 + }
74 +}
75 +
76 +/// Incremental resume point — indexing never restarts from scratch.
77 +@Model
78 +final class IndexCheckpoint {
79 + /// Creation date of the oldest photo already backfilled (newest → oldest).
80 + var oldestIndexedDate: Date?
81 + var updatedAt: Date
82 +
83 + init(oldestIndexedDate: Date? = nil) {
84 + self.oldestIndexedDate = oldestIndexedDate
85 + self.updatedAt = .now
86 + }
87 +}
88 +
89 +enum IndexStore {
90 + static let container: ModelContainer = {
91 + let schema = Schema([PhotoRecord.self, IndexCheckpoint.self])
92 + let configuration = ModelConfiguration(schema: schema)
93 + do {
94 + return try ModelContainer(for: schema, configurations: [configuration])
95 + } catch {
96 + fatalError("Failed to create index store: \(error)")
97 + }
98 + }()
99 +}
added Focale/Index/Vision/VisionIndexer.swift +92 −0
@@ -0,0 +1,92 @@
1 +//
2 +// VisionIndexer.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Stage 1 — Vision framework, on everything (CLAUDE.md §5). Fast, mature,
9 +// cheap. OCR is the silent winner: receipts, whiteboards, screenshots,
10 +// serial numbers — what people search most and Photos finds worst.
11 +//
12 +
13 +import CoreGraphics
14 +import Foundation
15 +import Vision
16 +
17 +/// Stage-1 output for one photo. All of it is real data, never generated.
18 +struct VisionSignals: Sendable {
19 + var ocrText: String?
20 + var featurePrint: Data?
21 + var classificationLabels: [String]
22 + /// Count only — local grouping, never named identification without
23 + /// an explicit user action (CLAUDE.md §5).
24 + var faceCount: Int
25 +}
26 +
27 +struct VisionIndexer: Sendable {
28 +
29 + /// Each request runs independently: one unsupported or failing request
30 + /// (e.g. feature prints on the simulator) must never cost us the OCR.
31 + func index(_ image: CGImage) async throws -> VisionSignals {
32 + let handler = VNImageRequestHandler(cgImage: image, options: [:])
33 +
34 + let textRequest = VNRecognizeTextRequest()
35 + textRequest.recognitionLevel = .accurate
36 + textRequest.usesLanguageCorrection = true
37 + textRequest.recognitionLanguages = ["fr-CA", "en-US"]
38 + try? handler.perform([textRequest])
39 +
40 + let featurePrintRequest = VNGenerateImageFeaturePrintRequest()
41 + try? handler.perform([featurePrintRequest])
42 +
43 + let classifyRequest = VNClassifyImageRequest()
44 + try? handler.perform([classifyRequest])
45 +
46 + let faceRequest = VNDetectFaceRectanglesRequest()
47 + try? handler.perform([faceRequest])
48 +
49 + let ocrText = textRequest.results?
50 + .compactMap { $0.topCandidates(1).first?.string }
51 + .joined(separator: "\n")
52 +
53 + let featurePrint = (featurePrintRequest.results?.first).map(Self.data(from:))
54 +
55 + let labels = (classifyRequest.results ?? [])
56 + .filter { $0.confidence > 0.7 }
57 + .prefix(10)
58 + .map(\.identifier)
59 +
60 + return VisionSignals(
61 + ocrText: (ocrText?.isEmpty ?? true) ? nil : ocrText,
62 + featurePrint: featurePrint,
63 + classificationLabels: Array(labels),
64 + faceCount: faceRequest.results?.count ?? 0
65 + )
66 + }
67 +
68 + // MARK: - Feature print storage & distance
69 +
70 + private static func data(from observation: VNFeaturePrintObservation) -> Data {
71 + observation.data
72 + }
73 +
74 + /// Euclidean distance between two stored feature prints (Float32 vectors).
75 + /// Powers "photos like this one" and near-duplicate grouping with no LLM.
76 + static func distance(_ lhs: Data, _ rhs: Data) -> Float? {
77 + guard lhs.count == rhs.count, !lhs.isEmpty,
78 + lhs.count % MemoryLayout<Float>.stride == 0 else { return nil }
79 + return lhs.withUnsafeBytes { lhsRaw in
80 + rhs.withUnsafeBytes { rhsRaw in
81 + let a = lhsRaw.bindMemory(to: Float.self)
82 + let b = rhsRaw.bindMemory(to: Float.self)
83 + var sum: Float = 0
84 + for i in 0..<a.count {
85 + let d = a[i] - b[i]
86 + sum += d * d
87 + }
88 + return sum.squareRoot()
89 + }
90 + }
91 + }
92 +}
added Focale/Intent/CaptureContext.swift +125 −0
@@ -0,0 +1,125 @@
1 +//
2 +// CaptureContext.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// The differentiating piece of the whole project (CLAUDE.md §4):
9 +// everything we know about a photo at the moment the shutter fires,
10 +// captured for free, with zero inference cost.
11 +//
12 +
13 +import Foundation
14 +
15 +/// Context recorded at shutter time. Written both to the local index and
16 +/// into the asset's own metadata, so the information survives an uninstall.
17 +struct CaptureContext: Codable, Sendable {
18 + var recipe: Recipe.ID? // active preset
19 + var project: Project.ID? // project declared by the user
20 + var projectName: String? // denormalized for metadata embedding
21 + var subjectHint: String? // typed or dictated before/after the shot
22 + var settings: ManualSettings // ISO, shutter, lens, focus
23 + var scene: SceneSignal // luminance, motion, distance (LiDAR if available)
24 + var burstRole: BurstRole? // selection within a burst
25 + var place: PlaceRef? // place, never raw coordinates
26 +
27 + /// Compact JSON used when embedding into asset metadata (EXIF UserComment).
28 + func metadataJSON() -> String? {
29 + let encoder = JSONEncoder()
30 + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
31 + guard let data = try? encoder.encode(self) else { return nil }
32 + return String(data: data, encoding: .utf8)
33 + }
34 +
35 + static func decode(fromMetadataJSON json: String) -> CaptureContext? {
36 + guard let data = json.data(using: .utf8) else { return nil }
37 + return try? JSONDecoder().decode(CaptureContext.self, from: data)
38 + }
39 +}
40 +
41 +/// Snapshot of the manual settings applied when the photo was taken.
42 +struct ManualSettings: Codable, Sendable, Hashable {
43 + var iso: Float? // nil = auto
44 + var shutterSeconds: Double? // nil = auto
45 + var lensPosition: Float? // 0...1, nil = autofocus
46 + var whiteBalanceKelvin: Float? // nil = auto WB
47 + var exposureBias: Float
48 + var lens: LensKind
49 + var zoomFactor: Double
50 + var format: CaptureFormat
51 + /// Optional so recipes saved before this field existed still decode.
52 + /// nil = auto.
53 + var flashMode: FlashMode?
54 +
55 + static let automatic = ManualSettings(
56 + iso: nil, shutterSeconds: nil, lensPosition: nil,
57 + whiteBalanceKelvin: nil, exposureBias: 0,
58 + lens: .wide, zoomFactor: 1.0, format: .heic, flashMode: nil
59 + )
60 +}
61 +
62 +enum FlashMode: String, Codable, Sendable, CaseIterable {
63 + case auto, on, off
64 +
65 + var displayName: String {
66 + switch self {
67 + case .auto: "Flash auto"
68 + case .on: "Flash activé"
69 + case .off: "Flash désactivé"
70 + }
71 + }
72 +
73 + var symbolName: String {
74 + switch self {
75 + case .auto: "bolt.badge.a"
76 + case .on: "bolt.fill"
77 + case .off: "bolt.slash.fill"
78 + }
79 + }
80 +
81 + var next: FlashMode {
82 + switch self {
83 + case .auto: .on
84 + case .on: .off
85 + case .off: .auto
86 + }
87 + }
88 +}
89 +
90 +enum LensKind: String, Codable, Sendable, CaseIterable {
91 + case ultraWide, wide, telephoto
92 +
93 + /// User-facing name, in French (code stays in English).
94 + var displayName: String {
95 + switch self {
96 + case .ultraWide: "Ultra grand-angle"
97 + case .wide: "Grand-angle"
98 + case .telephoto: "Téléobjectif"
99 + }
100 + }
101 +}
102 +
103 +enum CaptureFormat: String, Codable, Sendable, CaseIterable {
104 + case heic
105 + case proRAW
106 +
107 + var displayName: String {
108 + switch self {
109 + case .heic: "HEIC"
110 + case .proRAW: "ProRAW"
111 + }
112 + }
113 +}
114 +
115 +enum BurstRole: String, Codable, Sendable {
116 + case selected // user's pick within the burst
117 + case member
118 +}
119 +
120 +/// A place, deliberately coarse. Never raw coordinates (CLAUDE.md §4).
121 +struct PlaceRef: Codable, Sendable {
122 + var name: String? // "Chalet", "Garage Marcel"
123 + var locality: String? // "Québec"
124 + var countryCode: String? // "CA"
125 +}
added Focale/Intent/PlaceProvider.swift +76 −0
@@ -0,0 +1,76 @@
1 +//
2 +// PlaceProvider.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Turns the device location into a coarse PlaceRef (locality, never raw
9 +// coordinates — CLAUDE.md §4). Everything stays on-device; CLGeocoder
10 +// lookups are throttled and cached.
11 +//
12 +
13 +import CoreLocation
14 +import Foundation
15 +import Observation
16 +
17 +@MainActor
18 +@Observable
19 +final class PlaceProvider: NSObject, CLLocationManagerDelegate {
20 +
21 + private(set) var currentPlace: PlaceRef?
22 +
23 + private let manager = CLLocationManager()
24 + private let geocoder = CLGeocoder()
25 + private var lastGeocodedLocation: CLLocation?
26 +
27 + override init() {
28 + super.init()
29 + manager.delegate = self
30 + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
31 + }
32 +
33 + func startIfAuthorized() {
34 + switch manager.authorizationStatus {
35 + case .notDetermined:
36 + manager.requestWhenInUseAuthorization()
37 + case .authorizedWhenInUse, .authorizedAlways:
38 + manager.startUpdatingLocation()
39 + default:
40 + break
41 + }
42 + }
43 +
44 + func stop() {
45 + manager.stopUpdatingLocation()
46 + }
47 +
48 + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
49 + Task { @MainActor in self.startIfAuthorized() }
50 + }
51 +
52 + nonisolated func locationManager(
53 + _ manager: CLLocationManager,
54 + didUpdateLocations locations: [CLLocation]
55 + ) {
56 + guard let location = locations.last else { return }
57 + Task { @MainActor in self.geocodeIfNeeded(location) }
58 + }
59 +
60 + private func geocodeIfNeeded(_ location: CLLocation) {
61 + // Re-geocode only after moving ~500 m; place names don't change faster.
62 + if let previous = lastGeocodedLocation,
63 + location.distance(from: previous) < 500 { return }
64 + lastGeocodedLocation = location
65 +
66 + Task {
67 + guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first
68 + else { return }
69 + currentPlace = PlaceRef(
70 + name: placemark.areasOfInterest?.first,
71 + locality: placemark.locality,
72 + countryCode: placemark.isoCountryCode
73 + )
74 + }
75 + }
76 +}
added Focale/Intent/Project.swift +98 −0
@@ -0,0 +1,98 @@
1 +//
2 +// Project.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Project mode: the user declares "chantier cuisine" once, then every
9 +// photo taken is tagged. One gesture, zero friction afterwards.
10 +//
11 +
12 +import Foundation
13 +import Observation
14 +
15 +struct Project: Codable, Identifiable, Sendable, Hashable {
16 + typealias ID = UUID
17 +
18 + let id: ID
19 + var name: String
20 + var createdAt: Date
21 + var endedAt: Date?
22 +
23 + var isActive: Bool { endedAt == nil }
24 +
25 + init(id: ID = UUID(), name: String, createdAt: Date = .now, endedAt: Date? = nil) {
26 + self.id = id
27 + self.name = name
28 + self.createdAt = createdAt
29 + self.endedAt = endedAt
30 + }
31 +}
32 +
33 +/// Owns the list of projects and the single currently active one.
34 +@MainActor
35 +@Observable
36 +final class ProjectStore {
37 + private(set) var projects: [Project] = []
38 + private(set) var activeProjectID: Project.ID?
39 +
40 + private let fileURL: URL
41 +
42 + var activeProject: Project? {
43 + guard let activeProjectID else { return nil }
44 + return projects.first { $0.id == activeProjectID }
45 + }
46 +
47 + init(directory: URL = .applicationSupportDirectory) {
48 + fileURL = directory.appending(path: "projects.json")
49 + load()
50 + }
51 +
52 + /// Declare a new project and make it active immediately.
53 + @discardableResult
54 + func declare(_ name: String) -> Project {
55 + let project = Project(name: name)
56 + projects.insert(project, at: 0)
57 + activeProjectID = project.id
58 + save()
59 + return project
60 + }
61 +
62 + func activate(_ id: Project.ID?) {
63 + activeProjectID = id
64 + save()
65 + }
66 +
67 + func end(_ id: Project.ID) {
68 + guard let index = projects.firstIndex(where: { $0.id == id }) else { return }
69 + projects[index].endedAt = .now
70 + if activeProjectID == id { activeProjectID = nil }
71 + save()
72 + }
73 +
74 + // MARK: - Persistence
75 +
76 + private struct Snapshot: Codable {
77 + var projects: [Project]
78 + var activeProjectID: Project.ID?
79 + }
80 +
81 + private func load() {
82 + guard let data = try? Data(contentsOf: fileURL),
83 + let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data)
84 + else { return }
85 + projects = snapshot.projects
86 + activeProjectID = snapshot.activeProjectID
87 + }
88 +
89 + private func save() {
90 + let snapshot = Snapshot(projects: projects, activeProjectID: activeProjectID)
91 + guard let data = try? JSONEncoder().encode(snapshot) else { return }
92 + try? FileManager.default.createDirectory(
93 + at: fileURL.deletingLastPathComponent(),
94 + withIntermediateDirectories: true
95 + )
96 + try? data.write(to: fileURL, options: .atomic)
97 + }
98 +}
added Focale/Intent/SceneSignal.swift +42 −0
@@ -0,0 +1,42 @@
1 +//
2 +// SceneSignal.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Cheap physical signals read off the capture device at shutter time.
9 +// No inference — these come straight from the sensor.
10 +//
11 +
12 +import Foundation
13 +
14 +struct SceneSignal: Codable, Sendable {
15 + /// Estimated scene brightness in EV100 (log2 scale). Lower = darker.
16 + var luminanceEV: Double?
17 + /// Rough device/subject motion classification.
18 + var motion: MotionLevel
19 + /// Subject distance in meters when LiDAR/depth is available.
20 + var subjectDistance: Double?
21 + /// Convenience flag used by recipe scene triggers.
22 + var isLowLight: Bool
23 +
24 + static let unknown = SceneSignal(
25 + luminanceEV: nil, motion: .unknown, subjectDistance: nil, isLowLight: false
26 + )
27 +}
28 +
29 +enum MotionLevel: String, Codable, Sendable {
30 + case still
31 + case moving
32 + case unknown
33 +}
34 +
35 +extension SceneSignal {
36 + /// EV100 from the current exposure triplet: EV = log2(1 / (t * S/100)).
37 + /// Good enough to distinguish "sunlit" from "dim room" for scene triggers.
38 + static func estimateEV(iso: Float, shutterSeconds: Double) -> Double? {
39 + guard iso > 0, shutterSeconds > 0 else { return nil }
40 + return log2(1.0 / (shutterSeconds * Double(iso) / 100.0))
41 + }
42 +}
added Focale/Library/LibraryView.swift +202 −0
@@ -0,0 +1,202 @@
1 +//
2 +// LibraryView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Timeline of the system library (by localIdentifier, nothing duplicated)
9 +// with the honest indexing banner: usable results now, transparent
10 +// progress, never an opaque 20-hour spinner (CLAUDE.md §5).
11 +//
12 +
13 +import Photos
14 +import SwiftUI
15 +
16 +struct LibraryView: View {
17 + @Environment(AppModel.self) private var app
18 +
19 + @State private var assetIdentifiers: [String] = []
20 + @State private var progress: IndexProgress?
21 + @State private var viewer: PhotoViewerContext?
22 +
23 + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
24 +
25 + var body: some View {
26 + NavigationStack {
27 + ScrollView {
28 + if let progress, progress.indexed < progress.total {
29 + indexingBanner(progress)
30 + }
31 +
32 + livingAlbumsRow
33 +
34 + LazyVGrid(columns: columns, spacing: 2) {
35 + ForEach(assetIdentifiers, id: \.self) { identifier in
36 + Button {
37 + viewer = PhotoViewerContext(
38 + id: identifier, identifiers: assetIdentifiers
39 + )
40 + } label: {
41 + AssetThumbnailView(localIdentifier: identifier)
42 + .aspectRatio(1, contentMode: .fill)
43 + .clipped()
44 + }
45 + .buttonStyle(.plain)
46 + .accessibilityIdentifier("photo-thumbnail")
47 + }
48 + }
49 + }
50 + .background(DesignTokens.surface)
51 + .navigationTitle("Bibliothèque")
52 + .fullScreenCover(item: $viewer) { context in
53 + PhotoDetailView(context: context)
54 + }
55 + .task {
56 + loadAssets()
57 + progress = await app.indexPipeline.progress()
58 + }
59 + }
60 + }
61 +
62 + private func indexingBanner(_ progress: IndexProgress) -> some View {
63 + HStack(spacing: 10) {
64 + ProgressView(value: Double(progress.indexed), total: Double(max(progress.total, 1)))
65 + .frame(width: 60)
66 + Text(progress.statusLine)
67 + .font(.footnote)
68 + .foregroundStyle(DesignTokens.textSecondary)
69 + Spacer()
70 + }
71 + .padding(12)
72 + .background(DesignTokens.surfaceRaised, in: RoundedRectangle(
73 + cornerRadius: DesignTokens.cornerRadius
74 + ))
75 + .padding(.horizontal, 12)
76 + .padding(.top, 4)
77 + }
78 +
79 + private var livingAlbumsRow: some View {
80 + ScrollView(.horizontal, showsIndicators: false) {
81 + HStack(spacing: 10) {
82 + ForEach(app.albums.albums) { album in
83 + NavigationLink {
84 + LivingAlbumView(album: album)
85 + } label: {
86 + Label(album.name, systemImage: "sparkles.rectangle.stack")
87 + .font(.footnote.weight(.medium))
88 + .padding(.horizontal, 12)
89 + .padding(.vertical, 8)
90 + .background(DesignTokens.surfaceRaised, in: Capsule())
91 + .foregroundStyle(.white)
92 + }
93 + }
94 + }
95 + .padding(.horizontal, 12)
96 + .padding(.vertical, 8)
97 + }
98 + }
99 +
100 + /// Newest first — the index and the eye agree on what matters.
101 + private func loadAssets() {
102 + let options = PHFetchOptions()
103 + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
104 + options.fetchLimit = 600
105 + let fetched = PHAsset.fetchAssets(with: .image, options: options)
106 + var identifiers: [String] = []
107 + identifiers.reserveCapacity(fetched.count)
108 + fetched.enumerateObjects { asset, _, _ in
109 + identifiers.append(asset.localIdentifier)
110 + }
111 + assetIdentifiers = identifiers
112 + }
113 +}
114 +
115 +/// A living album fills itself from its query (CLAUDE.md §7).
116 +struct LivingAlbumView: View {
117 + @Environment(AppModel.self) private var app
118 + let album: LivingAlbum
119 +
120 + @State private var results: [SearchResult] = []
121 + @State private var viewer: PhotoViewerContext?
122 +
123 + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
124 +
125 + var body: some View {
126 + ScrollView {
127 + LazyVGrid(columns: columns, spacing: 2) {
128 + ForEach(results) { result in
129 + Button {
130 + viewer = PhotoViewerContext(
131 + id: result.localIdentifier,
132 + identifiers: results.map(\.localIdentifier)
133 + )
134 + } label: {
135 + AssetThumbnailView(localIdentifier: result.localIdentifier)
136 + .aspectRatio(1, contentMode: .fill)
137 + .clipped()
138 + }
139 + .buttonStyle(.plain)
140 + }
141 + }
142 + }
143 + .background(DesignTokens.surface)
144 + .navigationTitle(album.name)
145 + .fullScreenCover(item: $viewer) { context in
146 + PhotoDetailView(context: context)
147 + }
148 + .task {
149 + results = await app.searchEngine.search(album.filter)
150 + }
151 + }
152 +}
153 +
154 +/// Thumbnails straight from PhotoKit — never stored twice (CLAUDE.md §9).
155 +struct AssetThumbnailView: View {
156 + let localIdentifier: String
157 + @State private var image: UIImage?
158 + @Environment(\.displayScale) private var displayScale
159 +
160 + var body: some View {
161 + GeometryReader { geometry in
162 + ZStack {
163 + DesignTokens.surfaceRaised
164 + if let image {
165 + Image(uiImage: image)
166 + .resizable()
167 + .scaledToFill()
168 + }
169 + }
170 + .task(id: localIdentifier) {
171 + image = await Self.loadThumbnail(
172 + localIdentifier: localIdentifier,
173 + size: geometry.size,
174 + scale: displayScale
175 + )
176 + }
177 + }
178 + }
179 +
180 + private static func loadThumbnail(
181 + localIdentifier: String, size: CGSize, scale: CGFloat
182 + ) async -> UIImage? {
183 + let fetched = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
184 + guard let asset = fetched.firstObject else { return nil }
185 + let target = CGSize(width: size.width * scale, height: size.height * scale)
186 + return await withCheckedContinuation { continuation in
187 + let options = PHImageRequestOptions()
188 + options.deliveryMode = .opportunistic
189 + options.isNetworkAccessAllowed = true
190 + var resumed = false
191 + PHImageManager.default().requestImage(
192 + for: asset, targetSize: target, contentMode: .aspectFill, options: options
193 + ) { image, info in
194 + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
195 + if !degraded, !resumed {
196 + resumed = true
197 + continuation.resume(returning: image)
198 + }
199 + }
200 + }
201 + }
202 +}
added Focale/Library/LivingAlbum.swift +81 −0
@@ -0,0 +1,81 @@
1 +//
2 +// LivingAlbum.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// An album defined by a query, not a selection (CLAUDE.md §7).
9 +// It fills itself as you shoot.
10 +//
11 +
12 +import Foundation
13 +import Observation
14 +
15 +struct LivingAlbum: Codable, Identifiable, Sendable {
16 + let id: UUID
17 + var name: String
18 + var filter: SearchFilter
19 +
20 + init(id: UUID = UUID(), name: String, filter: SearchFilter) {
21 + self.id = id
22 + self.name = name
23 + self.filter = filter
24 + }
25 +}
26 +
27 +@MainActor
28 +@Observable
29 +final class LivingAlbumStore {
30 + private(set) var albums: [LivingAlbum] = []
31 + private let fileURL: URL
32 +
33 + init(directory: URL = .applicationSupportDirectory) {
34 + fileURL = directory.appending(path: "living-albums.json")
35 + load()
36 + if albums.isEmpty {
37 + albums = Self.starters
38 + save()
39 + }
40 + }
41 +
42 + func add(_ album: LivingAlbum) {
43 + albums.append(album)
44 + save()
45 + }
46 +
47 + func remove(_ id: LivingAlbum.ID) {
48 + albums.removeAll { $0.id == id }
49 + save()
50 + }
51 +
52 + // User-facing names in French.
53 + private static var starters: [LivingAlbum] {
54 + var receipts = SearchFilter.empty
55 + receipts.kinds = [PhotoKind.receipt.rawValue]
56 +
57 + var actionable = SearchFilter.empty
58 + actionable.actionableOnly = true
59 +
60 + return [
61 + LivingAlbum(name: "Tous mes reçus", filter: receipts),
62 + LivingAlbum(name: "Infos à conserver", filter: actionable),
63 + ]
64 + }
65 +
66 + private func load() {
67 + guard let data = try? Data(contentsOf: fileURL),
68 + let decoded = try? JSONDecoder().decode([LivingAlbum].self, from: data)
69 + else { return }
70 + albums = decoded
71 + }
72 +
73 + private func save() {
74 + guard let data = try? JSONEncoder().encode(albums) else { return }
75 + try? FileManager.default.createDirectory(
76 + at: fileURL.deletingLastPathComponent(),
77 + withIntermediateDirectories: true
78 + )
79 + try? data.write(to: fileURL, options: .atomic)
80 + }
81 +}
added Focale/Library/PhotoDetailView.swift +378 −0
@@ -0,0 +1,378 @@
1 +//
2 +// PhotoDetailView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Full-screen photo viewer: swipe between photos, pinch to zoom, and an
9 +// info panel where real data (EXIF, OCR, context) is clearly separated
10 +// from generated content (CLAUDE.md §8).
11 +//
12 +
13 +import Photos
14 +import SwiftData
15 +import SwiftUI
16 +
17 +/// What a thumbnail tap opens: the tapped photo within its surrounding list.
18 +struct PhotoViewerContext: Identifiable {
19 + let id: String // selected localIdentifier
20 + let identifiers: [String] // the list being browsed
21 +}
22 +
23 +struct PhotoDetailView: View {
24 + let context: PhotoViewerContext
25 + @Environment(\.dismiss) private var dismiss
26 +
27 + @State private var selection: String
28 + @State private var showsInfo = false
29 + @State private var showsSimilar = false
30 + @State private var isFavorite = false
31 +
32 + init(context: PhotoViewerContext) {
33 + self.context = context
34 + _selection = State(initialValue: context.id)
35 + }
36 +
37 + var body: some View {
38 + NavigationStack {
39 + TabView(selection: $selection) {
40 + ForEach(context.identifiers, id: \.self) { identifier in
41 + ZoomableAssetImage(localIdentifier: identifier)
42 + .tag(identifier)
43 + .ignoresSafeArea()
44 + }
45 + }
46 + .tabViewStyle(.page(indexDisplayMode: .never))
47 + .background(.black)
48 + .navigationBarTitleDisplayMode(.inline)
49 + .toolbar {
50 + ToolbarItem(placement: .topBarLeading) {
51 + Button {
52 + dismiss()
53 + } label: {
54 + Image(systemName: "xmark")
55 + }
56 + }
57 + ToolbarItemGroup(placement: .topBarTrailing) {
58 + Button {
59 + toggleFavorite()
60 + } label: {
61 + Image(systemName: isFavorite ? "heart.fill" : "heart")
62 + }
63 + .accessibilityLabel(isFavorite ? "Retirer des favoris" : "Ajouter aux favoris")
64 + .accessibilityIdentifier("favorite-button")
65 + Button {
66 + showsSimilar = true
67 + } label: {
68 + Image(systemName: "square.grid.2x2")
69 + }
70 + .accessibilityLabel("Photos semblables")
71 + Button {
72 + showsInfo.toggle()
73 + } label: {
74 + Image(systemName: "info.circle")
75 + }
76 + }
77 + }
78 + .sheet(isPresented: $showsInfo) {
79 + PhotoInfoSheet(localIdentifier: selection)
80 + .presentationDetents([.medium, .large])
81 + }
82 + .sheet(isPresented: $showsSimilar) {
83 + SimilarPhotosSheet(localIdentifier: selection)
84 + }
85 + .task(id: selection) { refreshFavorite() }
86 + }
87 + .preferredColorScheme(.dark)
88 + }
89 +
90 + private func refreshFavorite() {
91 + let asset = PHAsset.fetchAssets(
92 + withLocalIdentifiers: [selection], options: nil
93 + ).firstObject
94 + isFavorite = asset?.isFavorite ?? false
95 + }
96 +
97 + /// Favorites feed the stage-2 candidate list (CLAUDE.md §5) — marking
98 + /// a photo important is an explicit signal it deserves deeper indexing.
99 + private func toggleFavorite() {
100 + let identifier = selection
101 + let newValue = !isFavorite
102 + isFavorite = newValue
103 + Task {
104 + // The change block runs on PhotoKit's own queue: it must be
105 + // @Sendable (isolation-free) or the Swift 6 runtime isolation
106 + // check crashes the app.
107 + let change: @Sendable () -> Void = {
108 + let fetched = PHAsset.fetchAssets(
109 + withLocalIdentifiers: [identifier], options: nil
110 + )
111 + guard let asset = fetched.firstObject else { return }
112 + PHAssetChangeRequest(for: asset).isFavorite = newValue
113 + }
114 + try? await PHPhotoLibrary.shared().performChanges(change)
115 + }
116 + }
117 +}
118 +
119 +/// "Des photos comme celle-ci" — pure feature-print distance, no LLM
120 +/// (CLAUDE.md §5).
121 +struct SimilarPhotosSheet: View {
122 + let localIdentifier: String
123 + @Environment(AppModel.self) private var app
124 + @Environment(\.dismiss) private var dismiss
125 +
126 + @State private var results: [SearchResult] = []
127 + @State private var hasLoaded = false
128 + @State private var viewer: PhotoViewerContext?
129 +
130 + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
131 +
132 + var body: some View {
133 + NavigationStack {
134 + ScrollView {
135 + if hasLoaded && results.isEmpty {
136 + Text("Pas encore de photos semblables — l'index s'enrichit à mesure que les photos sont analysées.")
137 + .font(.footnote)
138 + .foregroundStyle(DesignTokens.textSecondary)
139 + .multilineTextAlignment(.center)
140 + .padding(30)
141 + } else {
142 + LazyVGrid(columns: columns, spacing: 2) {
143 + ForEach(results) { result in
144 + Button {
145 + viewer = PhotoViewerContext(
146 + id: result.localIdentifier,
147 + identifiers: results.map(\.localIdentifier)
148 + )
149 + } label: {
150 + AssetThumbnailView(localIdentifier: result.localIdentifier)
151 + .aspectRatio(1, contentMode: .fill)
152 + .clipped()
153 + }
154 + .buttonStyle(.plain)
155 + }
156 + }
157 + }
158 + }
159 + .background(DesignTokens.surface)
160 + .navigationTitle("Photos semblables")
161 + .navigationBarTitleDisplayMode(.inline)
162 + .toolbar {
163 + ToolbarItem(placement: .topBarLeading) {
164 + Button("Fermer") { dismiss() }
165 + }
166 + }
167 + .fullScreenCover(item: $viewer) { context in
168 + PhotoDetailView(context: context)
169 + }
170 + .task {
171 + var filter = SearchFilter.empty
172 + filter.similarToIdentifier = localIdentifier
173 + results = await app.searchEngine.search(filter, limit: 60)
174 + .filter { $0.localIdentifier != localIdentifier }
175 + hasLoaded = true
176 + }
177 + }
178 + .preferredColorScheme(.dark)
179 + }
180 +}
181 +
182 +// MARK: - Full-resolution image with pinch zoom
183 +
184 +struct ZoomableAssetImage: View {
185 + let localIdentifier: String
186 +
187 + @State private var image: UIImage?
188 + @State private var zoom: CGFloat = 1
189 + @State private var steadyZoom: CGFloat = 1
190 +
191 + var body: some View {
192 + GeometryReader { geometry in
193 + ZStack {
194 + Color.black
195 + if let image {
196 + Image(uiImage: image)
197 + .resizable()
198 + .scaledToFit()
199 + .scaleEffect(zoom)
200 + .frame(width: geometry.size.width, height: geometry.size.height)
201 + .gesture(
202 + MagnifyGesture()
203 + .onChanged { value in
204 + zoom = (steadyZoom * value.magnification)
205 + .clamped(to: 1...6)
206 + }
207 + .onEnded { _ in steadyZoom = zoom }
208 + )
209 + .onTapGesture(count: 2) {
210 + withAnimation(.snappy) {
211 + zoom = zoom > 1 ? 1 : 2.5
212 + steadyZoom = zoom
213 + }
214 + }
215 + } else {
216 + ProgressView()
217 + }
218 + }
219 + }
220 + .task(id: localIdentifier) {
221 + image = await Self.loadFullImage(localIdentifier: localIdentifier)
222 + }
223 + }
224 +
225 + private static func loadFullImage(localIdentifier: String) async -> UIImage? {
226 + let fetched = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
227 + guard let asset = fetched.firstObject else { return nil }
228 + return await withCheckedContinuation { continuation in
229 + let options = PHImageRequestOptions()
230 + options.deliveryMode = .highQualityFormat
231 + options.isNetworkAccessAllowed = true
232 + var resumed = false
233 + PHImageManager.default().requestImage(
234 + for: asset,
235 + targetSize: PHImageManagerMaximumSize,
236 + contentMode: .aspectFit,
237 + options: options
238 + ) { image, info in
239 + let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
240 + if !degraded, !resumed {
241 + resumed = true
242 + continuation.resume(returning: image)
243 + }
244 + }
245 + }
246 + }
247 +}
248 +
249 +// MARK: - Info panel: real data first, generated content badged
250 +
251 +struct PhotoInfoSheet: View {
252 + let localIdentifier: String
253 +
254 + @State private var info: PhotoInfo?
255 +
256 + struct PhotoInfo {
257 + var captureDate: Date?
258 + var projectName: String?
259 + var subjectHint: String?
260 + var placeText: String?
261 + var settingsText: String?
262 + var ocrText: String?
263 + var gist: String? // generated
264 + var entities: [String] // generated
265 + }
266 +
267 + var body: some View {
268 + ScrollView {
269 + VStack(alignment: .leading, spacing: 16) {
270 + if let info {
271 + // Real data — plain, trustworthy presentation.
272 + if let date = info.captureDate {
273 + row("Date", date.formatted(date: .long, time: .shortened))
274 + }
275 + if let project = info.projectName { row("Projet", project) }
276 + if let hint = info.subjectHint { row("Sujet noté", hint) }
277 + if let place = info.placeText { row("Lieu", place) }
278 + if let settings = info.settingsText { row("Réglages", settings) }
279 + if let ocr = info.ocrText, !ocr.isEmpty {
280 + VStack(alignment: .leading, spacing: 4) {
281 + Text("Texte dans l'image (OCR)")
282 + .font(.caption)
283 + .foregroundStyle(DesignTokens.textSecondary)
284 + Text(ocr)
285 + .font(.callout.monospaced())
286 + .textSelection(.enabled)
287 + }
288 + }
289 +
290 + // Generated data — visually distinct, always (CLAUDE.md §8).
291 + if info.gist != nil || !info.entities.isEmpty {
292 + VStack(alignment: .leading, spacing: 6) {
293 + GeneratedBadge()
294 + if let gist = info.gist {
295 + Text(gist).font(.callout)
296 + }
297 + if !info.entities.isEmpty {
298 + Text(info.entities.joined(separator: " · "))
299 + .font(.footnote)
300 + .foregroundStyle(DesignTokens.textSecondary)
301 + }
302 + }
303 + .padding(12)
304 + .frame(maxWidth: .infinity, alignment: .leading)
305 + .background(
306 + DesignTokens.generatedContent.opacity(0.08),
307 + in: RoundedRectangle(cornerRadius: DesignTokens.cornerRadius)
308 + )
309 + }
310 +
311 + if info.ocrText == nil && info.gist == nil {
312 + Text("Photo pas encore indexée — elle le sera automatiquement.")
313 + .font(.footnote)
314 + .foregroundStyle(DesignTokens.textSecondary)
315 + }
316 + } else {
317 + ProgressView()
318 + }
319 + }
320 + .frame(maxWidth: .infinity, alignment: .leading)
321 + .padding(20)
322 + }
323 + .presentationBackground(DesignTokens.surfaceRaised)
324 + .task(id: localIdentifier) { loadInfo() }
325 + }
326 +
327 + private func row(_ label: String, _ value: String) -> some View {
328 + VStack(alignment: .leading, spacing: 2) {
329 + Text(label)
330 + .font(.caption)
331 + .foregroundStyle(DesignTokens.textSecondary)
332 + Text(value)
333 + .font(.callout)
334 + .textSelection(.enabled)
335 + }
336 + }
337 +
338 + private func loadInfo() {
339 + let modelContext = ModelContext(IndexStore.container)
340 + var descriptor = FetchDescriptor<PhotoRecord>(
341 + predicate: #Predicate { $0.localIdentifier == localIdentifier }
342 + )
343 + descriptor.fetchLimit = 1
344 + let record = try? modelContext.fetch(descriptor).first
345 +
346 + let asset = PHAsset.fetchAssets(
347 + withLocalIdentifiers: [localIdentifier], options: nil
348 + ).firstObject
349 +
350 + var settingsText: String?
351 + if let settings = record?.captureContext?.settings {
352 + var parts: [String] = [settings.lens.displayName]
353 + if let iso = settings.iso { parts.append("ISO \(Int(iso))") }
354 + if let shutter = settings.shutterSeconds {
355 + parts.append(shutter >= 0.25
356 + ? String(format: "%.1f s", shutter)
357 + : "1/\(Int((1.0 / shutter).rounded()))")
358 + }
359 + parts.append(settings.format.displayName)
360 + settingsText = parts.joined(separator: " · ")
361 + }
362 +
363 + let placeText = [record?.placeName, record?.placeLocality]
364 + .compactMap(\.self)
365 + .joined(separator: ", ")
366 +
367 + info = PhotoInfo(
368 + captureDate: record?.captureDate ?? asset?.creationDate,
369 + projectName: record?.projectName,
370 + subjectHint: record?.subjectHint,
371 + placeText: placeText.isEmpty ? nil : placeText,
372 + settingsText: settingsText,
373 + ocrText: record?.ocrText,
374 + gist: record?.gist,
375 + entities: record?.entities ?? []
376 + )
377 + }
378 +}
added Focale/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +20 −0
@@ -0,0 +1,20 @@
1 +{
2 + "colors" : [
3 + {
4 + "color" : {
5 + "color-space" : "srgb",
6 + "components" : {
7 + "alpha" : "1.000",
8 + "blue" : "0x29",
9 + "green" : "0x94",
10 + "red" : "0xFF"
11 + }
12 + },
13 + "idiom" : "universal"
14 + }
15 + ],
16 + "info" : {
17 + "author" : "xcode",
18 + "version" : 1
19 + }
20 +}
added Focale/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 −0

Binary file not shown.

added Focale/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "images" : [
3 + {
4 + "filename" : "AppIcon-1024.png",
5 + "idiom" : "universal",
6 + "platform" : "ios",
7 + "size" : "1024x1024"
8 + }
9 + ],
10 + "info" : {
11 + "author" : "xcode",
12 + "version" : 1
13 + }
14 +}
added Focale/Resources/Assets.xcassets/Contents.json +6 −0
@@ -0,0 +1,6 @@
1 +{
2 + "info" : {
3 + "author" : "xcode",
4 + "version" : 1
5 + }
6 +}
added Focale/Resources/Info.plist +90 −0
@@ -0,0 +1,90 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<!--
4 + Info.plist — Focale
5 + Author: Simon-Pierre Boucher
6 + Contact: contact@spboucher.ai
7 +-->
8 +<plist version="1.0">
9 +<dict>
10 + <key>CFBundleDevelopmentRegion</key>
11 + <string>fr</string>
12 + <key>CFBundleDisplayName</key>
13 + <string>Focale</string>
14 + <key>CFBundleExecutable</key>
15 + <string>$(EXECUTABLE_NAME)</string>
16 + <key>CFBundleIdentifier</key>
17 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
18 + <key>CFBundleInfoDictionaryVersion</key>
19 + <string>6.0</string>
20 + <key>CFBundleName</key>
21 + <string>$(PRODUCT_NAME)</string>
22 + <key>CFBundlePackageType</key>
23 + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
24 + <key>CFBundleShortVersionString</key>
25 + <string>$(MARKETING_VERSION)</string>
26 + <key>CFBundleVersion</key>
27 + <string>$(CURRENT_PROJECT_VERSION)</string>
28 + <key>UILaunchScreen</key>
29 + <dict/>
30 + <key>UISupportedInterfaceOrientations</key>
31 + <array>
32 + <string>UIInterfaceOrientationPortrait</string>
33 + </array>
34 + <key>NSCameraUsageDescription</key>
35 + <string>Focale est un appareil photo. La caméra sert uniquement à prendre tes photos — rien ne quitte ton appareil, jamais.</string>
36 + <key>NSPhotoLibraryUsageDescription</key>
37 + <string>Focale indexe ta photothèque sur l’appareil pour que chaque photo soit retrouvable. Aucune photo n’est copiée, déplacée ou envoyée où que ce soit : tout reste sur ton iPhone, toujours.</string>
38 + <key>NSPhotoLibraryAddUsageDescription</key>
39 + <string>Focale enregistre tes photos directement dans ta photothèque système, sans bibliothèque parallèle.</string>
40 + <key>NSLocationWhenInUseUsageDescription</key>
41 + <string>Le lieu est joint à tes photos pour les retrouver plus tard (« la photo prise au chalet »). Il reste sur ton appareil.</string>
42 + <key>UIBackgroundModes</key>
43 + <array>
44 + <string>processing</string>
45 + </array>
46 + <key>BGTaskSchedulerPermittedIdentifiers</key>
47 + <array>
48 + <string>ai.spboucher.focale.index</string>
49 + </array>
50 + <key>ITSAppUsesNonExemptEncryption</key>
51 + <false/>
52 + <key>LSSupportsOpeningDocumentsInPlace</key>
53 + <false/>
54 + <key>UTExportedTypeDeclarations</key>
55 + <array>
56 + <dict>
57 + <key>UTTypeIdentifier</key>
58 + <string>ai.spboucher.focale.recipe</string>
59 + <key>UTTypeDescription</key>
60 + <string>Recette Focale</string>
61 + <key>UTTypeConformsTo</key>
62 + <array>
63 + <string>public.json</string>
64 + </array>
65 + <key>UTTypeTagSpecification</key>
66 + <dict>
67 + <key>public.filename-extension</key>
68 + <array>
69 + <string>focalerecipe</string>
70 + </array>
71 + </dict>
72 + </dict>
73 + </array>
74 + <key>CFBundleDocumentTypes</key>
75 + <array>
76 + <dict>
77 + <key>CFBundleTypeName</key>
78 + <string>Recette Focale</string>
79 + <key>CFBundleTypeRole</key>
80 + <string>Viewer</string>
81 + <key>LSItemContentTypes</key>
82 + <array>
83 + <string>ai.spboucher.focale.recipe</string>
84 + </array>
85 + <key>LSHandlerRank</key>
86 + <string>Owner</string>
87 + </dict>
88 + </array>
89 +</dict>
90 +</plist>
added Focale/Search/QueryAnalyzer.swift +190 −0
@@ -0,0 +1,190 @@
1 +//
2 +// QueryAnalyzer.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Natural language → SearchFilter. The model gets a small prompt
9 +// (trivial cost) and only the query — never photo lists (CLAUDE.md §5).
10 +// The keyword fallback is a real product path, not an apology: it's
11 +// the whole search story on non-A17 Pro devices.
12 +//
13 +
14 +import Foundation
15 +import FoundationModels
16 +
17 +/// Model-facing mirror of SearchFilter. Dates as ISO strings — date math
18 +/// stays deterministic, out of the model's hands.
19 +@Generable
20 +struct ParsedQuery {
21 + @Guide(description: "Date de début ISO 8601 (AAAA-MM-JJ) si la requête mentionne une période, sinon vide")
22 + let startDate: String
23 +
24 + @Guide(description: "Date de fin ISO 8601 (AAAA-MM-JJ) si la requête mentionne une période, sinon vide")
25 + let endDate: String
26 +
27 + @Guide(description: "Types de contenu recherchés parmi: document, receipt, person, place, object, screen, food, animal", .count(0...3))
28 + let kinds: [String]
29 +
30 + @Guide(description: "Mots à chercher dans le texte visible des photos (OCR)", .count(0...5))
31 + let textTerms: [String]
32 +
33 + @Guide(description: "Sujets ou objets recherchés dans les images", .count(0...5))
34 + let entityTerms: [String]
35 +
36 + @Guide(description: "Lieux mentionnés dans la requête", .count(0...3))
37 + let placeTerms: [String]
38 +
39 + @Guide(description: "Nom de projet mentionné (chantier, voyage, inventaire...), sinon vide")
40 + let projectTerm: String
41 +}
42 +
43 +struct QueryAnalyzer: Sendable {
44 +
45 + /// `now` injected for testability of relative dates ("l'automne passé").
46 + func analyze(_ query: String, now: Date = .now) async -> SearchFilter {
47 + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
48 + guard !trimmed.isEmpty else { return .empty }
49 +
50 + if SystemLanguageModel.default.availability == .available,
51 + let parsed = await analyzeWithModel(trimmed, now: now) {
52 + return parsed
53 + }
54 + return KeywordQueryParser.parse(trimmed, now: now)
55 + }
56 +
57 + private func analyzeWithModel(_ query: String, now: Date) async -> SearchFilter? {
58 + let formatter = ISO8601DateFormatter()
59 + formatter.formatOptions = [.withFullDate]
60 + let today = formatter.string(from: now)
61 +
62 + let session = LanguageModelSession(instructions: """
63 + Tu convertis une requête de recherche de photos en filtre structuré.
64 + Nous sommes le \(today). Résous les périodes relatives \
65 + (« l'automne passé », « la semaine passée ») en dates absolues.
66 + N'invente aucun terme absent de la requête.
67 + """)
68 + do {
69 + let response = try await session.respond(
70 + to: "Requête : \(query)",
71 + generating: ParsedQuery.self
72 + )
73 + return makeFilter(from: response.content)
74 + } catch {
75 + return nil // fall back to the deterministic parser
76 + }
77 + }
78 +
79 + private func makeFilter(from parsed: ParsedQuery) -> SearchFilter {
80 + let formatter = ISO8601DateFormatter()
81 + formatter.formatOptions = [.withFullDate]
82 +
83 + var dateRange: ClosedRange<Date>?
84 + if let start = formatter.date(from: parsed.startDate),
85 + let end = formatter.date(from: parsed.endDate),
86 + start <= end {
87 + dateRange = start...end.addingTimeInterval(86_400) // inclusive end day
88 + }
89 +
90 + var filter = SearchFilter.empty
91 + filter.dateRange = dateRange
92 + filter.kinds = parsed.kinds.filter { PhotoKind(rawValue: $0) != nil }
93 + filter.textTerms = parsed.textTerms
94 + filter.entityTerms = parsed.entityTerms
95 + filter.placeTerms = parsed.placeTerms
96 + filter.projectTerm = parsed.projectTerm.isEmpty ? nil : parsed.projectTerm
97 + return filter
98 + }
99 +}
100 +
101 +/// Deterministic French keyword parser — the Vision-only path (CLAUDE.md §10),
102 +/// and the safety net when the model declines.
103 +enum KeywordQueryParser {
104 +
105 + private static let kindKeywords: [String: PhotoKind] = [
106 + "recu": .receipt, "reçu": .receipt, "facture": .receipt,
107 + "document": .document, "papier": .document, "contrat": .document,
108 + "capture": .screen, "ecran": .screen, "écran": .screen,
109 + "bouffe": .food, "repas": .food, "restaurant": .food,
110 + "chien": .animal, "chat": .animal, "animal": .animal,
111 + ]
112 +
113 + private static let stopWords: Set<String> = [
114 + "le", "la", "les", "de", "des", "du", "un", "une", "et", "ou",
115 + "avec", "sans", "dans", "sur", "mes", "mon", "ma", "photo", "photos",
116 + "que", "qui", "j'ai", "pris", "prise", "prises",
117 + ]
118 +
119 + static func parse(_ query: String, now: Date = .now) -> SearchFilter {
120 + var filter = SearchFilter.empty
121 + let folded = query.searchFolded
122 +
123 + filter.dateRange = parseDateRange(from: folded, now: now)
124 +
125 + var terms: [String] = []
126 + for word in folded.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) {
127 + let token = String(word)
128 + if token.count < 3 || stopWords.contains(token) { continue }
129 + if let kind = kindKeywords[token] {
130 + if !filter.kinds.contains(kind.rawValue) { filter.kinds.append(kind.rawValue) }
131 + continue
132 + }
133 + terms.append(token)
134 + }
135 + // Every remaining term is worth trying against both OCR and entities.
136 + filter.textTerms = terms
137 + filter.entityTerms = terms
138 + return filter
139 + }
140 +
141 + private static func parseDateRange(from folded: String, now: Date) -> ClosedRange<Date>? {
142 + let calendar = Calendar(identifier: .gregorian)
143 + let year = calendar.component(.year, from: now)
144 +
145 + // Explicit year ("2026") in the query.
146 + for candidate in (year - 10)...year {
147 + if folded.contains(String(candidate)) {
148 + return calendar.yearRange(candidate)
149 + }
150 + }
151 +
152 + if folded.contains("semaine passee") || folded.contains("semaine derniere") {
153 + let end = now
154 + let start = calendar.date(byAdding: .day, value: -14, to: end)!
155 + return start...end
156 + }
157 + if folded.contains("hier") {
158 + let start = calendar.startOfDay(for: calendar.date(byAdding: .day, value: -1, to: now)!)
159 + return start...now
160 + }
161 + if folded.contains("mois passe") || folded.contains("mois dernier") {
162 + let start = calendar.date(byAdding: .month, value: -2, to: now)!
163 + return start...now
164 + }
165 +
166 + let seasons: [(String, Int, Int)] = [ // (keyword, startMonth, endMonth)
167 + ("printemps", 3, 6), ("ete", 6, 9), ("automne", 9, 12), ("hiver", 12, 3),
168 + ]
169 + for (keyword, startMonth, endMonth) in seasons where folded.contains(keyword) {
170 + let isPast = folded.contains("passe") || folded.contains("dernier")
171 + let currentMonth = calendar.component(.month, from: now)
172 + var seasonYear = year
173 + if isPast && startMonth >= currentMonth { seasonYear -= 1 }
174 + let start = calendar.date(from: DateComponents(year: seasonYear, month: startMonth, day: 1))!
175 + let endYear = endMonth < startMonth ? seasonYear + 1 : seasonYear
176 + let end = calendar.date(from: DateComponents(year: endYear, month: endMonth, day: 21))!
177 + return start...end
178 + }
179 + return nil
180 + }
181 +}
182 +
183 +private extension Calendar {
184 + func yearRange(_ year: Int) -> ClosedRange<Date>? {
185 + guard let start = date(from: DateComponents(year: year, month: 1, day: 1)),
186 + let end = date(from: DateComponents(year: year + 1, month: 1, day: 1))
187 + else { return nil }
188 + return start...end
189 + }
190 +}
added Focale/Search/SearchEngine.swift +146 −0
@@ -0,0 +1,146 @@
1 +//
2 +// SearchEngine.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Executes a SearchFilter against the local index and ranks results:
9 +// OCR match, feature-print distance, capture context, recency
10 +// (CLAUDE.md §7). Foundation Models only ever intervenes at the very
11 +// end, on a small candidate set — and that happens in IndexPipeline,
12 +// not here.
13 +//
14 +
15 +import Foundation
16 +import SwiftData
17 +
18 +struct SearchResult: Sendable, Identifiable {
19 + let localIdentifier: String
20 + let score: Double
21 + /// Generated gist, shown with a GeneratedBadge — never as a real datum.
22 + let gist: String?
23 + let ocrMatched: Bool
24 +
25 + var id: String { localIdentifier }
26 +}
27 +
28 +actor SearchEngine {
29 +
30 + private let container: ModelContainer
31 +
32 + init(container: ModelContainer) {
33 + self.container = container
34 + }
35 +
36 + func search(_ filter: SearchFilter, limit: Int = 120) -> [SearchResult] {
37 + guard !filter.isEmpty else { return [] }
38 + let modelContext = ModelContext(container)
39 +
40 + // Cheap structured predicate first (dates, favorites), then
41 + // in-memory folding for French text matching.
42 + var descriptor = FetchDescriptor<PhotoRecord>(
43 + sortBy: [SortDescriptor(\.captureDate, order: .reverse)]
44 + )
45 + if let range = filter.dateRange {
46 + let start = range.lowerBound
47 + let end = range.upperBound
48 + descriptor.predicate = #Predicate {
49 + $0.captureDate != nil && $0.captureDate! >= start && $0.captureDate! <= end
50 + }
51 + }
52 + guard let records = try? modelContext.fetch(descriptor) else { return [] }
53 +
54 + let referencePrint: Data? = filter.similarToIdentifier.flatMap { identifier in
55 + records.first { $0.localIdentifier == identifier }?.featurePrint
56 + }
57 +
58 + var results: [SearchResult] = []
59 + results.reserveCapacity(min(records.count, limit * 4))
60 +
61 + for record in records {
62 + if filter.favoritesOnly && !record.isFavorite { continue }
63 + if filter.actionableOnly && !record.hasActionableInfo { continue }
64 + if !filter.kinds.isEmpty {
65 + guard let kind = record.kindRawValue, filter.kinds.contains(kind) else { continue }
66 + }
67 + if let score = score(record, filter: filter, referencePrint: referencePrint) {
68 + results.append(score)
69 + }
70 + }
71 +
72 + return Array(results.sorted { $0.score > $1.score }.prefix(limit))
73 + }
74 +
75 + /// Returns nil when the record doesn't match at all.
76 + private func score(
77 + _ record: PhotoRecord,
78 + filter: SearchFilter,
79 + referencePrint: Data?
80 + ) -> SearchResult? {
81 + var score = 0.0
82 + var matchedAnyTerm = false
83 + var ocrMatched = false
84 +
85 + let ocr = record.ocrText?.searchFolded ?? ""
86 + let entityHaystack = (
87 + record.entities + record.classificationLabels
88 + + [record.subjectHint ?? "", record.gist ?? ""]
89 + ).joined(separator: " ").searchFolded
90 +
91 + for term in Set(filter.textTerms.map(\.searchFolded)) where !term.isEmpty {
92 + if ocr.contains(term) {
93 + score += 3.0 // OCR is the strongest signal — real data
94 + matchedAnyTerm = true
95 + ocrMatched = true
96 + }
97 + }
98 + for term in Set(filter.entityTerms.map(\.searchFolded)) where !term.isEmpty {
99 + if entityHaystack.contains(term) {
100 + score += 2.0
101 + matchedAnyTerm = true
102 + }
103 + }
104 + for term in filter.placeTerms.map(\.searchFolded) where !term.isEmpty {
105 + let place = "\(record.placeName ?? "") \(record.placeLocality ?? "")".searchFolded
106 + if place.contains(term) {
107 + score += 2.0
108 + matchedAnyTerm = true
109 + }
110 + }
111 + if let project = filter.projectTerm?.searchFolded, !project.isEmpty {
112 + if (record.projectName ?? "").searchFolded.contains(project) {
113 + score += 2.5 // declared context beats inference
114 + matchedAnyTerm = true
115 + } else if filter.textTerms.isEmpty && filter.entityTerms.isEmpty {
116 + return nil // project-only query: hard filter
117 + }
118 + }
119 +
120 + if let reference = referencePrint, let print = record.featurePrint,
121 + let distance = VisionIndexer.distance(reference, print) {
122 + // Closer = higher; typical feature print distances land in 0...2.
123 + score += Double(max(0, 2.0 - distance))
124 + matchedAnyTerm = true
125 + }
126 +
127 + let hasTermCriteria = !filter.textTerms.isEmpty || !filter.entityTerms.isEmpty
128 + || !filter.placeTerms.isEmpty || filter.projectTerm != nil
129 + || filter.similarToIdentifier != nil
130 + if hasTermCriteria && !matchedAnyTerm { return nil }
131 +
132 + // Recency: gentle boost, people search the last two years first.
133 + if let date = record.captureDate {
134 + let days = max(0, Date.now.timeIntervalSince(date) / 86_400)
135 + score += 1.0 / (1.0 + days / 365.0)
136 + }
137 + if record.hasActionableInfo { score += 0.25 }
138 +
139 + return SearchResult(
140 + localIdentifier: record.localIdentifier,
141 + score: score,
142 + gist: record.gist,
143 + ocrMatched: ocrMatched
144 + )
145 + }
146 +}
added Focale/Search/SearchFilter.swift +41 −0
@@ -0,0 +1,41 @@
1 +//
2 +// SearchFilter.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// A natural query becomes this structured filter, which runs on the
9 +// local database — instant on 40 000 photos as on 400 (CLAUDE.md §7).
10 +// The model analyzes the query; it never walks the photos.
11 +//
12 +
13 +import Foundation
14 +
15 +struct SearchFilter: Codable, Sendable, Equatable {
16 + var dateRange: ClosedRange<Date>?
17 + var kinds: [String] // PhotoKind raw values
18 + var textTerms: [String] // matched against OCR text
19 + var entityTerms: [String] // matched against entities / labels / hints
20 + var placeTerms: [String]
21 + var projectTerm: String?
22 + var similarToIdentifier: String? // "photos like this one"
23 + var favoritesOnly: Bool
24 + var actionableOnly: Bool // "documents avec une date d'expiration"
25 +
26 + static let empty = SearchFilter(
27 + dateRange: nil, kinds: [], textTerms: [], entityTerms: [],
28 + placeTerms: [], projectTerm: nil, similarToIdentifier: nil,
29 + favoritesOnly: false, actionableOnly: false
30 + )
31 +
32 + var isEmpty: Bool { self == .empty }
33 +}
34 +
35 +extension String {
36 + /// Case- and diacritic-insensitive folding for French matching
37 + /// ("reçu" == "recu", "Éléonore" == "eleonore").
38 + var searchFolded: String {
39 + folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "fr_CA"))
40 + }
41 +}
added Focale/Search/SearchView.swift +131 −0
@@ -0,0 +1,131 @@
1 +//
2 +// SearchView.swift
3 +// Focale
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Natural language in, ranked local results out. Generated descriptions
9 +// wear a visible badge — never dressed up as real data (CLAUDE.md §8).
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct SearchView: View {
15 + @Environment(AppModel.self) private var app
16 +
17 + @State private var query = ""
18 + @State private var results: [SearchResult] = []
19 + @State private var isSearching = false
20 + @State private var hasSearched = false
21 + @State private var viewer: PhotoViewerContext?
22 + @State private var lastFilter: SearchFilter = .empty
23 + @State private var showsAlbumAlert = false
24 + @State private var albumName = ""
25 +
26 + private let analyzer = QueryAnalyzer()
27 + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
28 +
29 + var body: some View {
30 + NavigationStack {
31 + ScrollView {
32 + if isSearching {
33 + ProgressView().padding(.top, 40)
34 + } else if results.isEmpty && hasSearched {
35 + emptyState
36 + } else {
37 + resultsGrid
38 + }
39 + }
40 + .background(DesignTokens.surface)
41 + .navigationTitle("Recherche")
42 + .searchable(
43 + text: $query,
44 + placement: .navigationBarDrawer(displayMode: .always),
45 + prompt: "« le reçu du garage l'automne passé »"
46 + )
47 + .onSubmit(of: .search) { runSearch() }
48 + .fullScreenCover(item: $viewer) { context in
49 + PhotoDetailView(context: context)
50 + }
51 + .toolbar {
52 + if !results.isEmpty && !lastFilter.isEmpty {
53 + ToolbarItem(placement: .topBarTrailing) {
54 + Button("Créer un album vivant", systemImage: "sparkles.rectangle.stack") {
55 + albumName = query
56 + showsAlbumAlert = true
57 + }
58 + }
59 + }
60 + }
61 + .alert("Album vivant", isPresented: $showsAlbumAlert) {
62 + TextField("Nom de l'album", text: $albumName)
63 + Button("Créer") {
64 + let name = albumName.trimmingCharacters(in: .whitespacesAndNewlines)
65 + if !name.isEmpty {
66 + app.albums.add(LivingAlbum(name: name, filter: lastFilter))
67 + }
68 + albumName = ""
69 + }
70 + Button("Annuler", role: .cancel) { albumName = "" }
71 + } message: {
72 + Text("Cet album se remplira tout seul à mesure que tu photographies.")
73 + }
74 + }
75 + }
76 +
77 + private var resultsGrid: some View {
78 + LazyVGrid(columns: columns, spacing: 2) {
79 + ForEach(results) { result in
80 + Button {
81 + viewer = PhotoViewerContext(
82 + id: result.localIdentifier,
83 + identifiers: results.map(\.localIdentifier)
84 + )
85 + } label: {
86 + ZStack(alignment: .bottomLeading) {
87 + AssetThumbnailView(localIdentifier: result.localIdentifier)
88 + .aspectRatio(1, contentMode: .fill)
89 + .clipped()
90 + if result.gist != nil {
91 + // Generated content is visually distinct, always.
92 + GeneratedBadge()
93 + .padding(4)
94 + }
95 + }
96 + }
97 + .buttonStyle(.plain)
98 + }
99 + }
100 + }
101 +
102 + private var emptyState: some View {
103 + VStack(spacing: 10) {
104 + Image(systemName: "magnifyingglass")
105 + .font(.largeTitle)
106 + .foregroundStyle(DesignTokens.textSecondary)
107 + Text("Rien trouvé pour cette recherche")
108 + .font(.callout)
109 + .foregroundStyle(DesignTokens.textSecondary)
110 + Text("Les photos les plus récentes sont indexées en premier ; les plus anciennes arrivent au fil des nuits.")
111 + .font(.footnote)
112 + .foregroundStyle(DesignTokens.textSecondary)
113 + .multilineTextAlignment(.center)
114 + .padding(.horizontal, 30)
115 + }
116 + .padding(.top, 60)
117 + }
118 +
119 + private func runSearch() {
120 + let text = query
121 + isSearching = true
122 + Task {
123 + // The model analyzes the query only — it never walks the photos.
124 + let filter = await analyzer.analyze(text)
125 + lastFilter = filter
126 + results = await app.searchEngine.search(filter)
127 + isSearching = false
128 + hasSearched = true
129 + }
130 + }
131 +}
added FocaleUITests/FavoriteFlowUITests.swift +74 −0
@@ -0,0 +1,74 @@
1 +//
2 +// FavoriteFlowUITests.swift
3 +// FocaleUITests
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +//
8 +// Regression test for the heart-button crash: the PHPhotoLibrary change
9 +// block must stay isolation-free (Swift 6) or tapping the favorite
10 +// button kills the app on PhotoKit's queue.
11 +//
12 +
13 +import XCTest
14 +
15 +final class FavoriteFlowUITests: XCTestCase {
16 +
17 + override func setUpWithError() throws {
18 + continueAfterFailure = false
19 + }
20 +
21 + @MainActor
22 + func testHeartToggleTogglesAndDoesNotCrash() throws {
23 + let app = XCUIApplication()
24 + app.launch()
25 +
26 + // The test runner reinstalls the app, which resets the photo
27 + // permission: walk through onboarding + the system dialog if shown.
28 + let allowButton = app.buttons["Autoriser l'accès à mes photos"]
29 + if allowButton.waitForExistence(timeout: 5) {
30 + allowButton.tap()
31 + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
32 + let fullAccess = springboard.buttons.matching(NSPredicate(
33 + format: "label CONTAINS[c] 'toutes' OR label CONTAINS[c] 'complet' OR label CONTAINS[c] 'Allow'"
34 + )).firstMatch
35 + if fullAccess.waitForExistence(timeout: 8) {
36 + fullAccess.tap()
37 + }
38 + }
39 +
40 + let libraryTab = app.buttons["Bibliothèque"].firstMatch
41 + XCTAssertTrue(libraryTab.waitForExistence(timeout: 20), "Library tab should exist")
42 + libraryTab.tap()
43 +
44 + let thumbnail = app.buttons.matching(identifier: "photo-thumbnail").firstMatch
45 + XCTAssertTrue(thumbnail.waitForExistence(timeout: 15), "At least one photo thumbnail")
46 + thumbnail.tap()
47 +
48 + let addFavorite = app.buttons["Ajouter aux favoris"]
49 + let removeFavorite = app.buttons["Retirer des favoris"]
50 + XCTAssertTrue(
51 + addFavorite.waitForExistence(timeout: 10) || removeFavorite.exists,
52 + "Favorite button should be visible in the viewer toolbar"
53 + )
54 +
55 + let startedAsFavorite = removeFavorite.exists
56 + (startedAsFavorite ? removeFavorite : addFavorite).tap()
57 +
58 + // Optimistic UI: the label flips immediately, and the app survives
59 + // the PhotoKit write that crashed build 3.
60 + let flipped = startedAsFavorite ? addFavorite : removeFavorite
61 + XCTAssertTrue(flipped.waitForExistence(timeout: 5), "Heart state should flip after tap")
62 +
63 + // Give the PhotoKit change block time to run (the old crash fired here).
64 + Thread.sleep(forTimeInterval: 2)
65 + XCTAssertEqual(app.state, .runningForeground, "App must survive the favorite write")
66 +
67 + // Toggle back so the test leaves the library as it found it.
68 + flipped.tap()
69 + let restored = startedAsFavorite ? removeFavorite : addFavorite
70 + XCTAssertTrue(restored.waitForExistence(timeout: 5), "Heart state should flip back")
71 + Thread.sleep(forTimeInterval: 2)
72 + XCTAssertEqual(app.state, .runningForeground)
73 + }
74 +}
added README.md +177 −0
@@ -0,0 +1,177 @@
1 +# Focale
2 +
3 +<p align="center">
4 + <img src="Design/AppIcon.svg" width="160" alt="Icône Focale — iris d'obturateur à six lamelles"/>
5 +</p>
6 +
7 +<p align="center">
8 + <img src="https://img.shields.io/badge/iOS-26.0%2B-black?logo=apple" alt="iOS 26.0+"/>
9 + <img src="https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white" alt="Swift 6"/>
10 + <img src="https://img.shields.io/badge/SwiftUI-%40Observable-0A84FF" alt="SwiftUI"/>
11 + <img src="https://img.shields.io/badge/concurrence-stricte-8A2BE2" alt="Strict concurrency"/>
12 + <img src="https://img.shields.io/badge/build-passing-brightgreen" alt="Build passing"/>
13 + <img src="https://img.shields.io/badge/tests%20UI-passing-brightgreen" alt="UI tests passing"/>
14 + <img src="https://img.shields.io/badge/TestFlight-0.1.0%20(4)-blue" alt="TestFlight 0.1.0 (4)"/>
15 + <img src="https://img.shields.io/badge/confidentialit%C3%A9-100%25%20sur%20l'appareil-FF9429" alt="100% on-device"/>
16 +</p>
17 +
18 +**Appareil photo manuel + photothèque interrogeable.** SwiftUI, AVFoundation, PhotoKit, Vision, Foundation Models. Deux produits qui se sauvent mutuellement : une photo prise dans Focale arrive déjà comprise, et la vieille bibliothèque se rattrape en arrière-plan pendant que le produit est déjà utile.
19 +
20 +**Rien ne quitte l'appareil. Jamais.** Aucune photo dupliquée, déplacée ou modifiée. Utile avant que l'index soit complet.
21 +
22 +**Author :** Simon-Pierre Boucher — contact@spboucher.ai
23 +
24 +> La thèse produit, les contraintes API et les règles d'architecture vivent dans [CLAUDE.md](CLAUDE.md) — le lire avant toute modification.
25 +
26 +---
27 +
28 +## Captures d'écran
29 +
30 +| Appareil | Bibliothèque | Visionneuse |
31 +|:---:|:---:|:---:|
32 +| ![Écran de capture](docs/screenshots/capture.png) | ![Bibliothèque](docs/screenshots/bibliotheque.png) | ![Visionneuse](docs/screenshots/visionneuse.png) |
33 +| Recettes, projets, retardateur, grille, dials manuels | Albums vivants + progression d'indexation honnête | Zoom, favoris, « photos semblables », panneau d'info |
34 +
35 +*(Simulateur iPhone 17 Pro — l'aperçu caméra est noir sans matériel photo.)*
36 +
37 +---
38 +
39 +## Métriques
40 +
41 +| Métrique | Valeur |
42 +|---|---|
43 +| Fichiers Swift | 37 |
44 +| Lignes de code Swift | 4 554 |
45 +| Erreurs de build | 0 |
46 +| Concurrence | Swift 6, `SWIFT_STRICT_CONCURRENCY: complete` |
47 +| Cible de déploiement | iOS 26.0 (iPhone) |
48 +| Tests UI | 1 suite (régression crash favoris) — ✅ 22,7 s |
49 +| Builds TestFlight | 4 (0.1.0 (1) → (4)) |
50 +| Étapes CLAUDE.md couvertes | 1–2 complètes, fondations 3–5 |
51 +
52 +| Module | LOC | Rôle |
53 +|---|---|---|
54 +| `Capture/` | 1 999 | session AVFoundation, contrôles manuels, ProRAW, flash, retardateur, recettes, dispositions, gestes |
55 +| `Index/` | 676 | étage 1 Vision (tout) + étage 2 Foundation Models (candidats seulement) |
56 +| `Library/` | 661 | timeline, visionneuse plein écran, albums vivants |
57 +| `Search/` | 508 | requête naturelle → filtre structuré → classement local |
58 +| `Intent/` | 341 | `CaptureContext` — le contexte capturé au déclenchement, cœur du projet |
59 +| `App/` | 246 | entrée, onboarding accès photos (le risque produit n° 1) |
60 +| `FocaleUITests/` | 74 | test de régression du flux favoris |
61 +| `Design/` | 49 | tokens, badge « Générée » |
62 +
63 +---
64 +
65 +## Fonctionnalités
66 +
67 +### 📷 Appareil photo (étape 1 — vendable seul)
68 +- **Contrôles manuels complets** : ISO, vitesse d'obturation, mise au point, balance des blancs, compensation d'exposition, zoom — chaque contrôle est **vérifié contre le matériel** avant d'être affiché (aucun bouton mort).
69 +- **ProRAW** (iPhone 12 Pro+), HEIC, choix d'objectif (ultra grand-angle / grand-angle / télé).
70 +- **Flash** auto / activé / désactivé (affiché seulement si l'appareil a un flash).
71 +- **Retardateur** 3 s / 10 s avec compte à rebours annulable, **grille des tiers**, **retour au déclenchement** (clignotement d'écran + haptique immédiate).
72 +- **Le déclencheur est sacré** : aucune inférence, aucun disque, aucune allocation dans le chemin du déclenchement — réponse < 50 ms visée.
73 +- **Recettes** : presets complets nommés, rappelables en un geste, **exportables en fichier `.focalerecipe`** et importables — elles se partagent.
74 +- **Dispositions** : trois profils (Simple / Photographe / Expert), gestes entièrement réassignables (glissement gauche = ISO, pincement = zoom…), haptique paramétrable par contrôle.
75 +- Proposition de recette sur signal de scène (basse lumière) — **une proposition, jamais un basculement silencieux**.
76 +
77 +### 🎯 Contexte de capture (étape 2 — la pièce qui différencie tout)
78 +- Au déclenchement, Focale enregistre **sans coût d'inférence** : recette active, projet déclaré, réglages manuels, signal de scène (luminosité EV), lieu grossier (jamais de coordonnées brutes).
79 +- **Écrit dans les métadonnées EXIF de la photo** (sans ré-encodage) en plus de la base locale : si l'utilisateur désinstalle, l'information reste dans sa photo.
80 +- **Mode Projet** : déclare « chantier cuisine » une fois — tout ce qui suit est marqué. Zéro friction.
81 +- **Note de sujet après la prise** (« reçu du garage ») — jamais de saisie obligatoire avant une photo.
82 +
83 +### 🔍 Indexation & recherche (étapes 3–5)
84 +- **Étage 1 — Vision, sur tout** : OCR (le gagnant silencieux : reçus, tableaux blancs, numéros de série), empreintes visuelles (similarité sans LLM), classification, visages (comptage local seulement, jamais d'identification).
85 +- **Étage 2 — Foundation Models, sur candidats seulement** : le calcul à ne jamais oublier — 40 000 photos × 1-3 s = 11-33 h en série. L'étage 2 ne touche que les photos qui le méritent (prises dans Focale, favorites, demandées).
86 +- **Du plus récent au plus ancien, toujours.** Photos récentes indexées au premier plan dès l'ouverture ; rattrapage profond la nuit, en charge (`BGProcessingTask`), reprise incrémentale.
87 +- **Recherche naturelle** : « le reçu du garage l'automne passé » → filtre structuré exécuté sur la base locale (instantané). Le modèle analyse la requête, **jamais les photos**. Repli déterministe complet sur appareils sans A17 Pro.
88 +- **Albums vivants** : définis par une requête, ils se remplissent tout seuls. Créables depuis n'importe quelle recherche.
89 +- **« Photos semblables »** : distance d'empreinte visuelle, zéro LLM.
90 +- **Honnêteté** : contenu généré toujours badgé « Générée », l'OCR prime sur le modèle pour tout chiffre, progression d'indexation transparente.
91 +
92 +---
93 +
94 +## Architecture
95 +
96 +```
97 +Focale/
98 +├─ App/ entrée, onboarding accès photos, racine
99 +├─ Capture/
100 +│ ├─ Session/ AVCaptureSession (file série dédiée), capacités matérielles
101 +│ ├─ Controls/ modèle ISO/vitesse/focus/BB, dials, mapping de gestes
102 +│ ├─ Output/ délégué de capture, écriture PhotoKit + EXIF
103 +│ ├─ Recipes/ presets, gestionnaire, export/import
104 +│ └─ Layout/ profils d'interface Simple/Photographe/Expert
105 +├─ Intent/ CaptureContext, projets, signal de scène, lieu
106 +├─ Index/
107 +│ ├─ Vision/ étage 1 — OCR, feature prints, classification
108 +│ ├─ Semantic/ étage 2 — Foundation Models (@Generable)
109 +│ ├─ Store/ SwiftData (localIdentifier, jamais de doublon)
110 +│ └─ Scheduler/ pipeline + BGProcessingTask (en charge seulement)
111 +├─ Search/ analyse de requête (FM ou déterministe) + classement
112 +├─ Library/ timeline, visionneuse, albums vivants
113 +└─ Design/ tokens, badge « Générée »
114 +FocaleUITests/ tests de régression UI
115 +Design/AppIcon.svg icône source (rendue en PNG dans Assets.xcassets)
116 +```
117 +
118 +Le projet Xcode est **généré par XcodeGen** : `Focale.xcodeproj` est un artefact, la source de vérité est `project.yml`.
119 +
120 +---
121 +
122 +## Build & tests
123 +
124 +```bash
125 +xcodegen generate # regénère Focale.xcodeproj depuis project.yml
126 +open Focale.xcodeproj # build & run (la caméra exige un iPhone physique)
127 +
128 +# Tests UI (simulateur)
129 +xcodebuild test -project Focale.xcodeproj -scheme Focale \
130 + -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
131 +```
132 +
133 +- Xcode 26+, Swift 6 (concurrence stricte), cible iOS 26.0.
134 +- Foundation Models exige un appareil A17 Pro+ ; partout ailleurs le chemin Vision-only prend le relais automatiquement.
135 +
136 +### TestFlight
137 +
138 +```bash
139 +xcodebuild -project Focale.xcodeproj -scheme Focale -destination 'generic/platform=iOS' \
140 + -archivePath build/Focale-N.xcarchive archive -allowProvisioningUpdates
141 +open -a Xcode build/Focale-N.xcarchive # Organizer → Distribute App
142 +```
143 +
144 +| Build | Contenu |
145 +|---|---|
146 +| 0.1.0 (1) | fondation complète : caméra manuelle, contexte, index, recherche |
147 +| 0.1.0 (2) | permission caméra, visionneuse, orientation portrait, indexation au premier plan |
148 +| 0.1.0 (3) | projets/recettes dans l'UI, export `.focalerecipe`, note après prise, pincement, favoris, « photos semblables », albums vivants depuis recherche |
149 +| 0.1.0 (4) | **crashs d'isolation Swift 6 corrigés** (favoris, enregistrement de capture, vignettes), Vision résilient, flash, retardateur, grille, retour au déclenchement, test UI de régression |
150 +
151 +---
152 +
153 +## Leçons Swift 6 (à lire avant de toucher à PhotoKit)
154 +
155 +Les closures passées à `PHPhotoLibrary.performChanges` ou aux handlers de `PHImageManager` **héritent de l'isolation** (MainActor ou acteur) du contexte où elles sont créées. PhotoKit les exécute sur sa propre file → `dispatch_assert_queue_fail`, crash immédiat. Règle du projet : **tout bloc destiné à une file de framework est déclaré `@Sendable`**, récupère ses `PHAsset` à l'intérieur, et sort ses résultats via une boîte `@unchecked Sendable`. Voir `PhotoLibraryWriter.swift` pour le patron.
156 +
157 +---
158 +
159 +## Icône
160 +
161 +Source : `Design/AppIcon.svg` — iris d'obturateur à six lamelles ambre sur objectif charbon. Pour régénérer le PNG :
162 +
163 +```bash
164 +mkdir -p /tmp/focale-icon
165 +qlmanage -t -s 1024 -o /tmp/focale-icon Design/AppIcon.svg
166 +cp /tmp/focale-icon/AppIcon.svg.png Focale/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
167 +```
168 +
169 +⚠️ Le dégradé des lamelles doit rester `gradientUnits="userSpaceOnUse"` : un dégradé `objectBoundingBox` sur une `<line>` (boîte de surface nulle) ne se rend pas, par spécification SVG.
170 +
171 +---
172 +
173 +## Règles non négociables
174 +
175 +1. **Rien ne quitte l'appareil. Jamais.** Aucune escalade cloud, même optionnelle, même sur les métadonnées.
176 +2. **Aucune photo n'est dupliquée, déplacée ou modifiée.** Index par `localIdentifier` ; désinstaller ne fait rien perdre.
177 +3. **Utile avant que l'index soit complet.** Résultats progressifs, du plus récent au plus ancien.
added docs/screenshots/bibliotheque.png +0 −0

Binary file not shown.

added docs/screenshots/capture.png +0 −0

Binary file not shown.

added docs/screenshots/recherche.png +0 −0

Binary file not shown.

added docs/screenshots/visionneuse.png +0 −0

Binary file not shown.

added project.yml +63 −0
@@ -0,0 +1,63 @@
1 +# project.yml — Focale
2 +# Author: Simon-Pierre Boucher
3 +# Contact: contact@spboucher.ai
4 +#
5 +# Generate the Xcode project with: xcodegen generate
6 +
7 +name: Focale
8 +options:
9 + bundleIdPrefix: ai.spboucher
10 + deploymentTarget:
11 + iOS: "26.0"
12 + createIntermediateGroups: true
13 +
14 +settings:
15 + base:
16 + SWIFT_VERSION: "6.0"
17 + SWIFT_STRICT_CONCURRENCY: complete
18 + ENABLE_USER_SCRIPT_SANDBOXING: true
19 + DEVELOPMENT_TEAM: 3YM54G49SN
20 +
21 +schemes:
22 + Focale:
23 + build:
24 + targets:
25 + Focale: all
26 + FocaleUITests: [test]
27 + run:
28 + config: Debug
29 + test:
30 + config: Debug
31 + targets:
32 + - FocaleUITests
33 + archive:
34 + config: Release
35 +
36 +targets:
37 + FocaleUITests:
38 + type: bundle.ui-testing
39 + platform: iOS
40 + sources:
41 + - FocaleUITests
42 + dependencies:
43 + - target: Focale
44 + settings:
45 + base:
46 + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.focale.uitests
47 + TEST_TARGET_NAME: Focale
48 +
49 + Focale:
50 + type: application
51 + platform: iOS
52 + sources:
53 + - Focale
54 + settings:
55 + base:
56 + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.focale
57 + INFOPLIST_FILE: Focale/Resources/Info.plist
58 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
59 + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
60 + TARGETED_DEVICE_FAMILY: "1"
61 + CODE_SIGN_STYLE: Automatic
62 + CURRENT_PROJECT_VERSION: 4
63 + MARKETING_VERSION: 0.1.0
64