// // RecipeManagerView.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Manage, share and import recipes. Recipes travel as files — a free // acquisition channel (CLAUDE.md §6). // import SwiftUI import UniformTypeIdentifiers struct RecipeManagerView: View { @Environment(AppModel.self) private var app @Environment(\.dismiss) private var dismiss @State private var showsImporter = false @State private var importError: String? var body: some View { NavigationStack { List { ForEach(app.recipes.recipes) { recipe in row(recipe) } .onDelete { offsets in for offset in offsets { app.recipes.remove(app.recipes.recipes[offset].id) } } } .navigationTitle("Recettes") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fermer") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button("Importer…", systemImage: "square.and.arrow.down") { showsImporter = true } } } .fileImporter( isPresented: $showsImporter, allowedContentTypes: [.json, Recipe.fileType] ) { result in importRecipe(result) } .alert("Import impossible", isPresented: Binding( get: { importError != nil }, set: { if !$0 { importError = nil } } )) { Button("OK", role: .cancel) {} } message: { Text(importError ?? "") } } } private func row(_ recipe: Recipe) -> some View { HStack { VStack(alignment: .leading, spacing: 3) { Text(recipe.name).font(.body.weight(.medium)) Text(summary(of: recipe)) .font(.caption) .foregroundStyle(DesignTokens.textSecondary) } Spacer() if app.recipes.activeRecipeID == recipe.id { Image(systemName: "checkmark.circle.fill") .foregroundStyle(DesignTokens.accent) } if let url = exportURL(for: recipe) { ShareLink(item: url) { Image(systemName: "square.and.arrow.up") } .buttonStyle(.borderless) } } .contentShape(Rectangle()) .onTapGesture { app.camera.applyRecipe(recipe) } } private func summary(of recipe: Recipe) -> String { var parts: [String] = [recipe.settings.lens.displayName, recipe.settings.format.displayName] if let iso = recipe.settings.iso { parts.append("ISO \(Int(iso))") } if let shutter = recipe.settings.shutterSeconds { parts.append(shutter >= 0.25 ? String(format: "%.1f s", shutter) : "1/\(Int((1.0 / shutter).rounded()))") } if let project = recipe.autoProjectName { parts.append("projet « \(project) »") } return parts.joined(separator: " · ") } /// Recipes are tiny JSON files; writing them eagerly per row is cheap. private func exportURL(for recipe: Recipe) -> URL? { guard let data = try? recipe.exportData() else { return nil } let safeName = recipe.name.replacingOccurrences(of: "/", with: "-") let url = FileManager.default.temporaryDirectory .appending(path: "\(safeName).focalerecipe") guard (try? data.write(to: url, options: .atomic)) != nil else { return nil } return url } private func importRecipe(_ result: Result) { guard case .success(let url) = result else { return } let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } do { let data = try Data(contentsOf: url) try app.recipes.importRecipe(from: data) } catch { importError = "Ce fichier n'est pas une recette Focale valide." } } }