spb/focale Public
Swift 100%
1//2// RecipeManagerView.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Manage, share and import recipes. Recipes travel as files — a free9// acquisition channel (CLAUDE.md §6).10//1112import SwiftUI13import UniformTypeIdentifiers1415struct RecipeManagerView: View {16 @Environment(AppModel.self) private var app17 @Environment(\.dismiss) private var dismiss1819 @State private var showsImporter = false20 @State private var importError: String?2122 var body: some View {23 NavigationStack {24 List {25 ForEach(app.recipes.recipes) { recipe in26 row(recipe)27 }28 .onDelete { offsets in29 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 = true43 }44 }45 }46 .fileImporter(47 isPresented: $showsImporter,48 allowedContentTypes: [.json, Recipe.fileType]49 ) { result in50 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 }6263 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 }8889 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.2595 ? 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 }101102 /// 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.temporaryDirectory107 .appending(path: "\(safeName).focalerecipe")108 guard (try? data.write(to: url, options: .atomic)) != nil else { return nil }109 return url110 }111112 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}124