SPB Git

spb/focale Public

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