// // CaptureView.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // The capture screen. First launch must give a good photo in three // seconds with zero configuration (CLAUDE.md §6); depth is there for // whoever reaches for it. The shutter button does one thing: capture. // import SwiftUI struct CaptureView: View { @Environment(AppModel.self) private var app @Environment(\.scenePhase) private var scenePhase @State private var showsNewProjectAlert = false @State private var newProjectName = "" @State private var showsSaveRecipeAlert = false @State private var newRecipeName = "" @State private var showsRecipeManager = false @State private var showsNoteAlert = false @State private var pinchStartZoom: Double? @State private var viewer: PhotoViewerContext? @AppStorage("compositionGrid") private var showsGrid = false @AppStorage("shutterTimerSeconds") private var timerSeconds = 0 @State private var countdown: Int? @State private var countdownTask: Task? @State private var shutterPulse = 0 @State private var shutterFlashOpacity = 0.0 private var camera: CameraModel { app.camera } var body: some View { ZStack { DesignTokens.surface.ignoresSafeArea() if camera.cameraAccessDenied { cameraDeniedView } else { GeometryReader { proxy in CameraPreviewView( session: camera.service.session, isSessionConfigured: camera.isConfigured ) .gesture(mappedDrag(midX: proxy.size.width / 2)) .simultaneousGesture(pinchGesture) } .ignoresSafeArea() if showsGrid { CompositionGridOverlay() .ignoresSafeArea() .allowsHitTesting(false) } // Shutter feedback: a quick screen blink so a capture is // unmistakable, plus the haptic below. The system shutter // sound plays on the device itself. Color.black .opacity(shutterFlashOpacity) .ignoresSafeArea() .allowsHitTesting(false) if let countdown { Text("\(countdown)") .font(.system(size: 110, weight: .bold, design: .rounded)) .foregroundStyle(.white) .shadow(radius: 12) .contentTransition(.numericText(countsDown: true)) .allowsHitTesting(false) } VStack { topBar Spacer() recipeProposalBanner lastCaptureRow controlStrip bottomBar } .padding(.horizontal, 16) .padding(.bottom, 12) } } .alert("Nouveau projet", isPresented: $showsNewProjectAlert) { TextField("chantier cuisine, voyage Gaspésie…", text: $newProjectName) Button("Créer") { let name = newProjectName.trimmingCharacters(in: .whitespacesAndNewlines) if !name.isEmpty { app.projects.declare(name) } newProjectName = "" } Button("Annuler", role: .cancel) { newProjectName = "" } } message: { Text("Tout ce que tu photographies ensuite sera marqué automatiquement.") } .alert("Nouvelle recette", isPresented: $showsSaveRecipeAlert) { TextField("Nom de la recette", text: $newRecipeName) Button("Enregistrer") { camera.saveCurrentSettingsAsRecipe(named: newRecipeName) newRecipeName = "" } Button("Annuler", role: .cancel) { newRecipeName = "" } } message: { Text("Les réglages actuels (ISO, vitesse, objectif, format…) seront rappelables en un geste.") } .alert("Note sur la dernière photo", isPresented: $showsNoteAlert) { TextField("« reçu du garage », « compteur d'eau »…", text: Binding( get: { camera.subjectHint }, set: { camera.subjectHint = $0 } )) Button("Ajouter") { camera.applySubjectHintToLastCapture() camera.subjectHint = "" } Button("Annuler", role: .cancel) { camera.subjectHint = "" } } .sheet(isPresented: $showsRecipeManager) { RecipeManagerView() } .fullScreenCover(item: $viewer) { context in PhotoDetailView(context: context) } .task { await camera.configureIfNeeded() camera.applyControls() app.placeProvider.startIfAuthorized() } .task { // Scene watcher: proposes a recipe on low light — a proposal, // never a silent switch (CLAUDE.md §6). while !Task.isCancelled { try? await Task.sleep(for: .seconds(3)) guard camera.isConfigured, scenePhase == .active else { continue } let scene = await camera.service.currentSceneSignal() camera.evaluateSceneTriggers(scene) } } .onDisappear { camera.viewfinderDidDisappear() app.placeProvider.stop() } .onChange(of: scenePhase) { _, phase in if phase == .active { camera.service.start() } else { camera.service.stop() } } } /// Explanation, not a dead black screen, when camera access is refused. private var cameraDeniedView: some View { VStack(spacing: 16) { Image(systemName: "video.slash") .font(.system(size: 44)) .foregroundStyle(DesignTokens.accent) Text("Focale n'a pas accès à la caméra") .font(.title3.bold()) Text("Dans Réglages → Confidentialité → Caméra, active Focale. La caméra sert uniquement à prendre tes photos — rien ne quitte ton appareil.") .font(.callout) .foregroundStyle(DesignTokens.textSecondary) .multilineTextAlignment(.center) Button("Ouvrir les Réglages") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .buttonStyle(.borderedProminent) } .padding(28) } // MARK: - Top bar: recipe, project, format private var topBar: some View { HStack(spacing: 10) { Menu { ForEach(app.recipes.recipes) { recipe in Button(recipe.name) { camera.applyRecipe(recipe) } } Divider() Button("Enregistrer les réglages…", systemImage: "plus.circle") { showsSaveRecipeAlert = true } Button("Gérer les recettes…", systemImage: "folder.badge.gearshape") { showsRecipeManager = true } Button("Aucune recette", role: .destructive) { app.recipes.activeRecipeID = nil camera.controls.resetToAuto() camera.applyControls() } } label: { chip( text: app.recipes.activeRecipe?.name ?? "Recette", symbol: "wand.and.stars", highlighted: app.recipes.activeRecipe != nil ) } Menu { ForEach(app.projects.projects.filter(\.isActive)) { project in Button(project.name) { app.projects.activate(project.id) } } Divider() Button("Nouveau projet…", systemImage: "plus.circle") { showsNewProjectAlert = true } if app.projects.activeProjectID != nil { Button("Aucun projet", role: .destructive) { app.projects.activate(nil) } } } label: { chip( text: app.projects.activeProject?.name ?? "Projet", symbol: "folder", highlighted: app.projects.activeProject != nil ) } Spacer() // Only shown when the hardware supports it — no dead buttons. if camera.capabilities.hasFlash { Button { camera.controls.flashMode = camera.controls.flashMode.next } label: { iconChip( symbol: camera.controls.flashMode.symbolName, highlighted: camera.controls.flashMode != .off ) } .accessibilityLabel(camera.controls.flashMode.displayName) } Button { timerSeconds = switch timerSeconds { case 0: 3; case 3: 10; default: 0 } } label: { iconChip( symbol: timerSeconds == 0 ? "timer" : "timer.circle.fill", highlighted: timerSeconds > 0, caption: timerSeconds > 0 ? "\(timerSeconds)s" : nil ) } .accessibilityLabel("Retardateur") Button { showsGrid.toggle() } label: { iconChip(symbol: "grid", highlighted: showsGrid) } .accessibilityLabel("Grille de composition") if camera.capabilities.supportsProRAW { Button { camera.controls.format = camera.controls.format == .heic ? .proRAW : .heic } label: { chip( text: camera.controls.format.displayName, symbol: "doc.badge.gearshape", highlighted: camera.controls.format == .proRAW ) } } } .padding(.top, 8) } // MARK: - Scene trigger proposal (never a silent switch) @ViewBuilder private var recipeProposalBanner: some View { if let proposed = camera.proposedRecipe { HStack { Text("Basse lumière — passer à « \(proposed.name) » ?") .font(.footnote) Spacer() Button("Oui") { camera.applyRecipe(proposed) } .font(.footnote.bold()) Button { camera.dismissProposedRecipe() } label: { Image(systemName: "xmark") .font(.footnote) } } .padding(10) .background(.ultraThinMaterial, in: RoundedRectangle( cornerRadius: DesignTokens.cornerRadius )) .padding(.bottom, 8) } } /// After a capture: tappable thumbnail of the last photo + note button. /// The context is added around the shot, never in front of it. @ViewBuilder private var lastCaptureRow: some View { if let identifier = camera.lastCapturedIdentifier { HStack(spacing: 10) { Button { viewer = PhotoViewerContext(id: identifier, identifiers: [identifier]) } label: { AssetThumbnailView(localIdentifier: identifier) .frame(width: 44, height: 44) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .strokeBorder(.white.opacity(0.35), lineWidth: 1)) } .buttonStyle(.plain) Button { showsNoteAlert = true } label: { Label("Ajouter une note", systemImage: "square.and.pencil") .font(.footnote.weight(.medium)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(.ultraThinMaterial, in: Capsule()) .foregroundStyle(.white) } Spacer() } .padding(.bottom, 8) .transition(.opacity) } } /// Pinch drives whatever the gesture map assigns to it (zoom by default). private var pinchGesture: some Gesture { MagnifyGesture() .onChanged { value in guard let control = camera.layout.gestureMap.control(for: .pinch), camera.capabilities.supports(control) else { return } let start = pinchStartZoom ?? camera.controls.zoomFactor if pinchStartZoom == nil { pinchStartZoom = start } if control == .zoom { camera.controls.zoomFactor = (start * value.magnification) .clamped(to: 1...camera.capabilities.maxZoomFactor) } else { let current = camera.controls.normalizedValue(for: control) camera.controls.setNormalizedValue( current + (value.magnification - 1) / 50.0, for: control ) } camera.applyControls() } .onEnded { _ in pinchStartZoom = nil } } // MARK: - Control strip (layout-driven, capability-filtered) private var controlStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: DesignTokens.controlSpacing) { ForEach(camera.visibleControls) { control in ControlDial( control: control, haptics: camera.layout.hapticProfile(for: control), controls: camera.controls ) { camera.applyControls() } } } } .padding(.bottom, 10) } // MARK: - Bottom bar: lens switcher + shutter private var bottomBar: some View { ZStack { HStack { lensSwitcher Spacer() Menu { ForEach(ControlLayout.provided) { layout in Button(layout.name) { camera.layout = layout } } } label: { Image(systemName: "slider.horizontal.3") .font(.title3) .foregroundStyle(.white) .frame(width: 44, height: 44) } } shutterButton } } private var lensSwitcher: some View { HStack(spacing: 6) { ForEach(camera.capabilities.availableLenses, id: \.self) { lens in Button { Task { await camera.selectLens(lens) } } label: { Text(lensLabel(lens)) .font(.footnote.weight(.semibold)) .foregroundStyle(camera.controls.lens == lens ? DesignTokens.accent : .white) .frame(width: 36, height: 36) .background(.ultraThinMaterial, in: Circle()) } } } } private func lensLabel(_ lens: LensKind) -> String { switch lens { case .ultraWide: "0,5×" case .wide: "1×" case .telephoto: "T" } } /// The shutter is sacred: the capture call goes first, feedback after. private var shutterButton: some View { Button { handleShutterTap() } label: { ZStack { Circle() .strokeBorder(.white, lineWidth: 4) .frame(width: DesignTokens.shutterDiameter, height: DesignTokens.shutterDiameter) Circle() .fill(countdownTask == nil ? .white : DesignTokens.accent) .frame(width: DesignTokens.shutterDiameter - 14, height: DesignTokens.shutterDiameter - 14) } } .buttonStyle(.plain) .sensoryFeedback(.impact(weight: .medium), trigger: shutterPulse) .accessibilityLabel(countdownTask == nil ? "Déclencheur" : "Annuler le retardateur") } private func handleShutterTap() { // A second tap during the countdown cancels it. if let task = countdownTask { task.cancel() countdownTask = nil countdown = nil return } guard timerSeconds > 0 else { fireCapture() return } countdownTask = Task { for remaining in stride(from: timerSeconds, through: 1, by: -1) { withAnimation(.snappy) { countdown = remaining } shutterPulse += 1 // haptic tick each second try? await Task.sleep(for: .seconds(1)) if Task.isCancelled { return } } countdown = nil countdownTask = nil fireCapture() } } private func fireCapture() { camera.capture() // first, always — nothing before the shutter shutterPulse += 1 // immediate haptic shutterFlashOpacity = 1 // screen blink: capture is unmistakable withAnimation(.easeOut(duration: 0.25)) { shutterFlashOpacity = 0 } } // MARK: - Mapped gestures (fully reassignable, CLAUDE.md §6) private func mappedDrag(midX: CGFloat) -> some Gesture { DragGesture(minimumDistance: 12) .onChanged { gesture in let horizontal = abs(gesture.translation.width) > abs(gesture.translation.height) let slot: GestureSlot = if horizontal { .horizontalDrag } else if gesture.startLocation.x < midX { .leftVerticalDrag } else { .rightVerticalDrag } guard let control = camera.layout.gestureMap.control(for: slot), camera.capabilities.supports(control) else { return } let delta = horizontal ? gesture.translation.width : -gesture.translation.height let current = camera.controls.normalizedValue(for: control) camera.controls.setNormalizedValue( current + delta / 3000.0, // smooth continuous drive for: control ) camera.applyControls() } } // MARK: - Helpers private func chip(text: String, symbol: String, highlighted: Bool) -> some View { Label(text, systemImage: symbol) .font(.footnote.weight(.medium)) .lineLimit(1) .padding(.horizontal, 10) .padding(.vertical, 6) .background(.ultraThinMaterial, in: Capsule()) .foregroundStyle(highlighted ? DesignTokens.accent : .white) } private func iconChip(symbol: String, highlighted: Bool, caption: String? = nil) -> some View { HStack(spacing: 3) { Image(systemName: symbol) if let caption { Text(caption).font(.caption2.weight(.semibold)) } } .font(.footnote.weight(.medium)) .padding(.horizontal, 9) .padding(.vertical, 6) .background(.ultraThinMaterial, in: Capsule()) .foregroundStyle(highlighted ? DesignTokens.accent : .white) } } /// Rule-of-thirds guide. Drawn, never captured into the photo. struct CompositionGridOverlay: View { var body: some View { GeometryReader { proxy in Path { path in let w = proxy.size.width let h = proxy.size.height for fraction in [1.0 / 3.0, 2.0 / 3.0] { path.move(to: CGPoint(x: w * fraction, y: 0)) path.addLine(to: CGPoint(x: w * fraction, y: h)) path.move(to: CGPoint(x: 0, y: h * fraction)) path.addLine(to: CGPoint(x: w, y: h * fraction)) } } .stroke(.white.opacity(0.35), lineWidth: 0.75) } } }