spb/focale Public
Swift 100%
1//2// CaptureView.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// The capture screen. First launch must give a good photo in three9// seconds with zero configuration (CLAUDE.md §6); depth is there for10// whoever reaches for it. The shutter button does one thing: capture.11//1213import SwiftUI1415struct CaptureView: View {16 @Environment(AppModel.self) private var app17 @Environment(\.scenePhase) private var scenePhase1819 @State private var showsNewProjectAlert = false20 @State private var newProjectName = ""21 @State private var showsSaveRecipeAlert = false22 @State private var newRecipeName = ""23 @State private var showsRecipeManager = false24 @State private var showsNoteAlert = false25 @State private var pinchStartZoom: Double?26 @State private var viewer: PhotoViewerContext?27 @AppStorage("compositionGrid") private var showsGrid = false28 @AppStorage("shutterTimerSeconds") private var timerSeconds = 029 @State private var countdown: Int?30 @State private var countdownTask: Task<Void, Never>?31 @State private var shutterPulse = 032 @State private var shutterFlashOpacity = 0.03334 private var camera: CameraModel { app.camera }3536 var body: some View {37 ZStack {38 DesignTokens.surface.ignoresSafeArea()3940 if camera.cameraAccessDenied {41 cameraDeniedView42 } else {43 GeometryReader { proxy in44 CameraPreviewView(45 session: camera.service.session,46 isSessionConfigured: camera.isConfigured47 )48 .gesture(mappedDrag(midX: proxy.size.width / 2))49 .simultaneousGesture(pinchGesture)50 }51 .ignoresSafeArea()5253 if showsGrid {54 CompositionGridOverlay()55 .ignoresSafeArea()56 .allowsHitTesting(false)57 }5859 // Shutter feedback: a quick screen blink so a capture is60 // unmistakable, plus the haptic below. The system shutter61 // sound plays on the device itself.62 Color.black63 .opacity(shutterFlashOpacity)64 .ignoresSafeArea()65 .allowsHitTesting(false)6667 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 }7576 VStack {77 topBar78 Spacer()79 recipeProposalBanner80 lastCaptureRow81 controlStrip82 bottomBar83 }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 in124 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 in146 if phase == .active {147 camera.service.start()148 } else {149 camera.service.stop()150 }151 }152 }153154 /// 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 }175176 // MARK: - Top bar: recipe, project, format177178 private var topBar: some View {179 HStack(spacing: 10) {180 Menu {181 ForEach(app.recipes.recipes) { recipe in182 Button(recipe.name) { camera.applyRecipe(recipe) }183 }184 Divider()185 Button("Enregistrer les réglages…", systemImage: "plus.circle") {186 showsSaveRecipeAlert = true187 }188 Button("Gérer les recettes…", systemImage: "folder.badge.gearshape") {189 showsRecipeManager = true190 }191 Button("Aucune recette", role: .destructive) {192 app.recipes.activeRecipeID = nil193 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 != nil201 )202 }203204 Menu {205 ForEach(app.projects.projects.filter(\.isActive)) { project in206 Button(project.name) { app.projects.activate(project.id) }207 }208 Divider()209 Button("Nouveau projet…", systemImage: "plus.circle") {210 showsNewProjectAlert = true211 }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 != nil222 )223 }224225 Spacer()226227 // Only shown when the hardware supports it — no dead buttons.228 if camera.capabilities.hasFlash {229 Button {230 camera.controls.flashMode = camera.controls.flashMode.next231 } label: {232 iconChip(233 symbol: camera.controls.flashMode.symbolName,234 highlighted: camera.controls.flashMode != .off235 )236 }237 .accessibilityLabel(camera.controls.flashMode.displayName)238 }239240 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" : nil247 )248 }249 .accessibilityLabel("Retardateur")250251 Button {252 showsGrid.toggle()253 } label: {254 iconChip(symbol: "grid", highlighted: showsGrid)255 }256 .accessibilityLabel("Grille de composition")257258 if camera.capabilities.supportsProRAW {259 Button {260 camera.controls.format = camera.controls.format == .heic ? .proRAW : .heic261 } label: {262 chip(263 text: camera.controls.format.displayName,264 symbol: "doc.badge.gearshape",265 highlighted: camera.controls.format == .proRAW266 )267 }268 }269 }270 .padding(.top, 8)271 }272273 // MARK: - Scene trigger proposal (never a silent switch)274275 @ViewBuilder276 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.cornerRadius294 ))295 .padding(.bottom, 8)296 }297 }298299 /// 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 @ViewBuilder302 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)315316 Button {317 showsNoteAlert = true318 } 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 }326327 Spacer()328 }329 .padding(.bottom, 8)330 .transition(.opacity)331 }332 }333334 /// Pinch drives whatever the gesture map assigns to it (zoom by default).335 private var pinchGesture: some Gesture {336 MagnifyGesture()337 .onChanged { value in338 guard let control = camera.layout.gestureMap.control(for: .pinch),339 camera.capabilities.supports(control) else { return }340 let start = pinchStartZoom ?? camera.controls.zoomFactor341 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: control349 )350 }351 camera.applyControls()352 }353 .onEnded { _ in pinchStartZoom = nil }354 }355356 // MARK: - Control strip (layout-driven, capability-filtered)357358 private var controlStrip: some View {359 ScrollView(.horizontal, showsIndicators: false) {360 HStack(spacing: DesignTokens.controlSpacing) {361 ForEach(camera.visibleControls) { control in362 ControlDial(363 control: control,364 haptics: camera.layout.hapticProfile(for: control),365 controls: camera.controls366 ) {367 camera.applyControls()368 }369 }370 }371 }372 .padding(.bottom, 10)373 }374375 // MARK: - Bottom bar: lens switcher + shutter376377 private var bottomBar: some View {378 ZStack {379 HStack {380 lensSwitcher381 Spacer()382 Menu {383 ForEach(ControlLayout.provided) { layout in384 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 shutterButton394 }395 }396397 private var lensSwitcher: some View {398 HStack(spacing: 6) {399 ForEach(camera.capabilities.availableLenses, id: \.self) { lens in400 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 }412413 private func lensLabel(_ lens: LensKind) -> String {414 switch lens {415 case .ultraWide: "0,5×"416 case .wide: "1×"417 case .telephoto: "T"418 }419 }420421 /// 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 }441442 private func handleShutterTap() {443 // A second tap during the countdown cancels it.444 if let task = countdownTask {445 task.cancel()446 countdownTask = nil447 countdown = nil448 return449 }450 guard timerSeconds > 0 else {451 fireCapture()452 return453 }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 second458 try? await Task.sleep(for: .seconds(1))459 if Task.isCancelled { return }460 }461 countdown = nil462 countdownTask = nil463 fireCapture()464 }465 }466467 private func fireCapture() {468 camera.capture() // first, always — nothing before the shutter469 shutterPulse += 1 // immediate haptic470 shutterFlashOpacity = 1 // screen blink: capture is unmistakable471 withAnimation(.easeOut(duration: 0.25)) {472 shutterFlashOpacity = 0473 }474 }475476 // MARK: - Mapped gestures (fully reassignable, CLAUDE.md §6)477478 private func mappedDrag(midX: CGFloat) -> some Gesture {479 DragGesture(minimumDistance: 12)480 .onChanged { gesture in481 let horizontal = abs(gesture.translation.width) > abs(gesture.translation.height)482 let slot: GestureSlot = if horizontal {483 .horizontalDrag484 } else if gesture.startLocation.x < midX {485 .leftVerticalDrag486 } else {487 .rightVerticalDrag488 }489 guard let control = camera.layout.gestureMap.control(for: slot),490 camera.capabilities.supports(control) else { return }491492 let delta = horizontal493 ? gesture.translation.width494 : -gesture.translation.height495 let current = camera.controls.normalizedValue(for: control)496 camera.controls.setNormalizedValue(497 current + delta / 3000.0, // smooth continuous drive498 for: control499 )500 camera.applyControls()501 }502 }503504 // MARK: - Helpers505506 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 }515516 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}530531/// Rule-of-thirds guide. Drawn, never captured into the photo.532struct CompositionGridOverlay: View {533 var body: some View {534 GeometryReader { proxy in535 Path { path in536 let w = proxy.size.width537 let h = proxy.size.height538 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}549