SPB Git

spb/focale Public

Swift 100%
20.4 KB · 549 lines swift
Raw Blame History
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