SPB Git

spb/focale Public

Swift 100%
14.3 KB · 379 lines swift
Raw Blame History
1//2//  PhotoDetailView.swift3//  Focale4//5//  Author: Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//8//  Full-screen photo viewer: swipe between photos, pinch to zoom, and an9//  info panel where real data (EXIF, OCR, context) is clearly separated10//  from generated content (CLAUDE.md §8).11//1213import Photos14import SwiftData15import SwiftUI1617/// What a thumbnail tap opens: the tapped photo within its surrounding list.18struct PhotoViewerContext: Identifiable {19    let id: String            // selected localIdentifier20    let identifiers: [String] // the list being browsed21}2223struct PhotoDetailView: View {24    let context: PhotoViewerContext25    @Environment(\.dismiss) private var dismiss2627    @State private var selection: String28    @State private var showsInfo = false29    @State private var showsSimilar = false30    @State private var isFavorite = false3132    init(context: PhotoViewerContext) {33        self.context = context34        _selection = State(initialValue: context.id)35    }3637    var body: some View {38        NavigationStack {39            TabView(selection: $selection) {40                ForEach(context.identifiers, id: \.self) { identifier in41                    ZoomableAssetImage(localIdentifier: identifier)42                        .tag(identifier)43                        .ignoresSafeArea()44                }45            }46            .tabViewStyle(.page(indexDisplayMode: .never))47            .background(.black)48            .navigationBarTitleDisplayMode(.inline)49            .toolbar {50                ToolbarItem(placement: .topBarLeading) {51                    Button {52                        dismiss()53                    } label: {54                        Image(systemName: "xmark")55                    }56                }57                ToolbarItemGroup(placement: .topBarTrailing) {58                    Button {59                        toggleFavorite()60                    } label: {61                        Image(systemName: isFavorite ? "heart.fill" : "heart")62                    }63                    .accessibilityLabel(isFavorite ? "Retirer des favoris" : "Ajouter aux favoris")64                    .accessibilityIdentifier("favorite-button")65                    Button {66                        showsSimilar = true67                    } label: {68                        Image(systemName: "square.grid.2x2")69                    }70                    .accessibilityLabel("Photos semblables")71                    Button {72                        showsInfo.toggle()73                    } label: {74                        Image(systemName: "info.circle")75                    }76                }77            }78            .sheet(isPresented: $showsInfo) {79                PhotoInfoSheet(localIdentifier: selection)80                    .presentationDetents([.medium, .large])81            }82            .sheet(isPresented: $showsSimilar) {83                SimilarPhotosSheet(localIdentifier: selection)84            }85            .task(id: selection) { refreshFavorite() }86        }87        .preferredColorScheme(.dark)88    }8990    private func refreshFavorite() {91        let asset = PHAsset.fetchAssets(92            withLocalIdentifiers: [selection], options: nil93        ).firstObject94        isFavorite = asset?.isFavorite ?? false95    }9697    /// Favorites feed the stage-2 candidate list (CLAUDE.md §5) — marking98    /// a photo important is an explicit signal it deserves deeper indexing.99    private func toggleFavorite() {100        let identifier = selection101        let newValue = !isFavorite102        isFavorite = newValue103        Task {104            // The change block runs on PhotoKit's own queue: it must be105            // @Sendable (isolation-free) or the Swift 6 runtime isolation106            // check crashes the app.107            let change: @Sendable () -> Void = {108                let fetched = PHAsset.fetchAssets(109                    withLocalIdentifiers: [identifier], options: nil110                )111                guard let asset = fetched.firstObject else { return }112                PHAssetChangeRequest(for: asset).isFavorite = newValue113            }114            try? await PHPhotoLibrary.shared().performChanges(change)115        }116    }117}118119/// "Des photos comme celle-ci" — pure feature-print distance, no LLM120/// (CLAUDE.md §5).121struct SimilarPhotosSheet: View {122    let localIdentifier: String123    @Environment(AppModel.self) private var app124    @Environment(\.dismiss) private var dismiss125126    @State private var results: [SearchResult] = []127    @State private var hasLoaded = false128    @State private var viewer: PhotoViewerContext?129130    private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]131132    var body: some View {133        NavigationStack {134            ScrollView {135                if hasLoaded && results.isEmpty {136                    Text("Pas encore de photos semblables — l'index s'enrichit à mesure que les photos sont analysées.")137                        .font(.footnote)138                        .foregroundStyle(DesignTokens.textSecondary)139                        .multilineTextAlignment(.center)140                        .padding(30)141                } else {142                    LazyVGrid(columns: columns, spacing: 2) {143                        ForEach(results) { result in144                            Button {145                                viewer = PhotoViewerContext(146                                    id: result.localIdentifier,147                                    identifiers: results.map(\.localIdentifier)148                                )149                            } label: {150                                AssetThumbnailView(localIdentifier: result.localIdentifier)151                                    .aspectRatio(1, contentMode: .fill)152                                    .clipped()153                            }154                            .buttonStyle(.plain)155                        }156                    }157                }158            }159            .background(DesignTokens.surface)160            .navigationTitle("Photos semblables")161            .navigationBarTitleDisplayMode(.inline)162            .toolbar {163                ToolbarItem(placement: .topBarLeading) {164                    Button("Fermer") { dismiss() }165                }166            }167            .fullScreenCover(item: $viewer) { context in168                PhotoDetailView(context: context)169            }170            .task {171                var filter = SearchFilter.empty172                filter.similarToIdentifier = localIdentifier173                results = await app.searchEngine.search(filter, limit: 60)174                    .filter { $0.localIdentifier != localIdentifier }175                hasLoaded = true176            }177        }178        .preferredColorScheme(.dark)179    }180}181182// MARK: - Full-resolution image with pinch zoom183184struct ZoomableAssetImage: View {185    let localIdentifier: String186187    @State private var image: UIImage?188    @State private var zoom: CGFloat = 1189    @State private var steadyZoom: CGFloat = 1190191    var body: some View {192        GeometryReader { geometry in193            ZStack {194                Color.black195                if let image {196                    Image(uiImage: image)197                        .resizable()198                        .scaledToFit()199                        .scaleEffect(zoom)200                        .frame(width: geometry.size.width, height: geometry.size.height)201                        .gesture(202                            MagnifyGesture()203                                .onChanged { value in204                                    zoom = (steadyZoom * value.magnification)205                                        .clamped(to: 1...6)206                                }207                                .onEnded { _ in steadyZoom = zoom }208                        )209                        .onTapGesture(count: 2) {210                            withAnimation(.snappy) {211                                zoom = zoom > 1 ? 1 : 2.5212                                steadyZoom = zoom213                            }214                        }215                } else {216                    ProgressView()217                }218            }219        }220        .task(id: localIdentifier) {221            image = await Self.loadFullImage(localIdentifier: localIdentifier)222        }223    }224225    private static func loadFullImage(localIdentifier: String) async -> UIImage? {226        let fetched = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)227        guard let asset = fetched.firstObject else { return nil }228        return await withCheckedContinuation { continuation in229            let options = PHImageRequestOptions()230            options.deliveryMode = .highQualityFormat231            options.isNetworkAccessAllowed = true232            var resumed = false233            PHImageManager.default().requestImage(234                for: asset,235                targetSize: PHImageManagerMaximumSize,236                contentMode: .aspectFit,237                options: options238            ) { image, info in239                let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false240                if !degraded, !resumed {241                    resumed = true242                    continuation.resume(returning: image)243                }244            }245        }246    }247}248249// MARK: - Info panel: real data first, generated content badged250251struct PhotoInfoSheet: View {252    let localIdentifier: String253254    @State private var info: PhotoInfo?255256    struct PhotoInfo {257        var captureDate: Date?258        var projectName: String?259        var subjectHint: String?260        var placeText: String?261        var settingsText: String?262        var ocrText: String?263        var gist: String?          // generated264        var entities: [String]     // generated265    }266267    var body: some View {268        ScrollView {269            VStack(alignment: .leading, spacing: 16) {270                if let info {271                    // Real data — plain, trustworthy presentation.272                    if let date = info.captureDate {273                        row("Date", date.formatted(date: .long, time: .shortened))274                    }275                    if let project = info.projectName { row("Projet", project) }276                    if let hint = info.subjectHint { row("Sujet noté", hint) }277                    if let place = info.placeText { row("Lieu", place) }278                    if let settings = info.settingsText { row("Réglages", settings) }279                    if let ocr = info.ocrText, !ocr.isEmpty {280                        VStack(alignment: .leading, spacing: 4) {281                            Text("Texte dans l'image (OCR)")282                                .font(.caption)283                                .foregroundStyle(DesignTokens.textSecondary)284                            Text(ocr)285                                .font(.callout.monospaced())286                                .textSelection(.enabled)287                        }288                    }289290                    // Generated data — visually distinct, always (CLAUDE.md §8).291                    if info.gist != nil || !info.entities.isEmpty {292                        VStack(alignment: .leading, spacing: 6) {293                            GeneratedBadge()294                            if let gist = info.gist {295                                Text(gist).font(.callout)296                            }297                            if !info.entities.isEmpty {298                                Text(info.entities.joined(separator: " · "))299                                    .font(.footnote)300                                    .foregroundStyle(DesignTokens.textSecondary)301                            }302                        }303                        .padding(12)304                        .frame(maxWidth: .infinity, alignment: .leading)305                        .background(306                            DesignTokens.generatedContent.opacity(0.08),307                            in: RoundedRectangle(cornerRadius: DesignTokens.cornerRadius)308                        )309                    }310311                    if info.ocrText == nil && info.gist == nil {312                        Text("Photo pas encore indexée — elle le sera automatiquement.")313                            .font(.footnote)314                            .foregroundStyle(DesignTokens.textSecondary)315                    }316                } else {317                    ProgressView()318                }319            }320            .frame(maxWidth: .infinity, alignment: .leading)321            .padding(20)322        }323        .presentationBackground(DesignTokens.surfaceRaised)324        .task(id: localIdentifier) { loadInfo() }325    }326327    private func row(_ label: String, _ value: String) -> some View {328        VStack(alignment: .leading, spacing: 2) {329            Text(label)330                .font(.caption)331                .foregroundStyle(DesignTokens.textSecondary)332            Text(value)333                .font(.callout)334                .textSelection(.enabled)335        }336    }337338    private func loadInfo() {339        let modelContext = ModelContext(IndexStore.container)340        var descriptor = FetchDescriptor<PhotoRecord>(341            predicate: #Predicate { $0.localIdentifier == localIdentifier }342        )343        descriptor.fetchLimit = 1344        let record = try? modelContext.fetch(descriptor).first345346        let asset = PHAsset.fetchAssets(347            withLocalIdentifiers: [localIdentifier], options: nil348        ).firstObject349350        var settingsText: String?351        if let settings = record?.captureContext?.settings {352            var parts: [String] = [settings.lens.displayName]353            if let iso = settings.iso { parts.append("ISO \(Int(iso))") }354            if let shutter = settings.shutterSeconds {355                parts.append(shutter >= 0.25356                    ? String(format: "%.1f s", shutter)357                    : "1/\(Int((1.0 / shutter).rounded()))")358            }359            parts.append(settings.format.displayName)360            settingsText = parts.joined(separator: " · ")361        }362363        let placeText = [record?.placeName, record?.placeLocality]364            .compactMap(\.self)365            .joined(separator: ", ")366367        info = PhotoInfo(368            captureDate: record?.captureDate ?? asset?.creationDate,369            projectName: record?.projectName,370            subjectHint: record?.subjectHint,371            placeText: placeText.isEmpty ? nil : placeText,372            settingsText: settingsText,373            ocrText: record?.ocrText,374            gist: record?.gist,375            entities: record?.entities ?? []376        )377    }378}379