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