// // PhotoDetailView.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Full-screen photo viewer: swipe between photos, pinch to zoom, and an // info panel where real data (EXIF, OCR, context) is clearly separated // from generated content (CLAUDE.md §8). // import Photos import SwiftData import SwiftUI /// What a thumbnail tap opens: the tapped photo within its surrounding list. struct PhotoViewerContext: Identifiable { let id: String // selected localIdentifier let identifiers: [String] // the list being browsed } struct PhotoDetailView: View { let context: PhotoViewerContext @Environment(\.dismiss) private var dismiss @State private var selection: String @State private var showsInfo = false @State private var showsSimilar = false @State private var isFavorite = false init(context: PhotoViewerContext) { self.context = context _selection = State(initialValue: context.id) } var body: some View { NavigationStack { TabView(selection: $selection) { ForEach(context.identifiers, id: \.self) { identifier in ZoomableAssetImage(localIdentifier: identifier) .tag(identifier) .ignoresSafeArea() } } .tabViewStyle(.page(indexDisplayMode: .never)) .background(.black) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "xmark") } } ToolbarItemGroup(placement: .topBarTrailing) { Button { toggleFavorite() } label: { Image(systemName: isFavorite ? "heart.fill" : "heart") } .accessibilityLabel(isFavorite ? "Retirer des favoris" : "Ajouter aux favoris") .accessibilityIdentifier("favorite-button") Button { showsSimilar = true } label: { Image(systemName: "square.grid.2x2") } .accessibilityLabel("Photos semblables") Button { showsInfo.toggle() } label: { Image(systemName: "info.circle") } } } .sheet(isPresented: $showsInfo) { PhotoInfoSheet(localIdentifier: selection) .presentationDetents([.medium, .large]) } .sheet(isPresented: $showsSimilar) { SimilarPhotosSheet(localIdentifier: selection) } .task(id: selection) { refreshFavorite() } } .preferredColorScheme(.dark) } private func refreshFavorite() { let asset = PHAsset.fetchAssets( withLocalIdentifiers: [selection], options: nil ).firstObject isFavorite = asset?.isFavorite ?? false } /// Favorites feed the stage-2 candidate list (CLAUDE.md §5) — marking /// a photo important is an explicit signal it deserves deeper indexing. private func toggleFavorite() { let identifier = selection let newValue = !isFavorite isFavorite = newValue Task { // The change block runs on PhotoKit's own queue: it must be // @Sendable (isolation-free) or the Swift 6 runtime isolation // check crashes the app. let change: @Sendable () -> Void = { let fetched = PHAsset.fetchAssets( withLocalIdentifiers: [identifier], options: nil ) guard let asset = fetched.firstObject else { return } PHAssetChangeRequest(for: asset).isFavorite = newValue } try? await PHPhotoLibrary.shared().performChanges(change) } } } /// "Des photos comme celle-ci" — pure feature-print distance, no LLM /// (CLAUDE.md §5). struct SimilarPhotosSheet: View { let localIdentifier: String @Environment(AppModel.self) private var app @Environment(\.dismiss) private var dismiss @State private var results: [SearchResult] = [] @State private var hasLoaded = false @State private var viewer: PhotoViewerContext? private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)] var body: some View { NavigationStack { ScrollView { if hasLoaded && results.isEmpty { Text("Pas encore de photos semblables — l'index s'enrichit à mesure que les photos sont analysées.") .font(.footnote) .foregroundStyle(DesignTokens.textSecondary) .multilineTextAlignment(.center) .padding(30) } else { LazyVGrid(columns: columns, spacing: 2) { ForEach(results) { result in Button { viewer = PhotoViewerContext( id: result.localIdentifier, identifiers: results.map(\.localIdentifier) ) } label: { AssetThumbnailView(localIdentifier: result.localIdentifier) .aspectRatio(1, contentMode: .fill) .clipped() } .buttonStyle(.plain) } } } } .background(DesignTokens.surface) .navigationTitle("Photos semblables") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fermer") { dismiss() } } } .fullScreenCover(item: $viewer) { context in PhotoDetailView(context: context) } .task { var filter = SearchFilter.empty filter.similarToIdentifier = localIdentifier results = await app.searchEngine.search(filter, limit: 60) .filter { $0.localIdentifier != localIdentifier } hasLoaded = true } } .preferredColorScheme(.dark) } } // MARK: - Full-resolution image with pinch zoom struct ZoomableAssetImage: View { let localIdentifier: String @State private var image: UIImage? @State private var zoom: CGFloat = 1 @State private var steadyZoom: CGFloat = 1 var body: some View { GeometryReader { geometry in ZStack { Color.black if let image { Image(uiImage: image) .resizable() .scaledToFit() .scaleEffect(zoom) .frame(width: geometry.size.width, height: geometry.size.height) .gesture( MagnifyGesture() .onChanged { value in zoom = (steadyZoom * value.magnification) .clamped(to: 1...6) } .onEnded { _ in steadyZoom = zoom } ) .onTapGesture(count: 2) { withAnimation(.snappy) { zoom = zoom > 1 ? 1 : 2.5 steadyZoom = zoom } } } else { ProgressView() } } } .task(id: localIdentifier) { image = await Self.loadFullImage(localIdentifier: localIdentifier) } } private static func loadFullImage(localIdentifier: String) async -> UIImage? { let fetched = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil) guard let asset = fetched.firstObject else { return nil } return await withCheckedContinuation { continuation in let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true var resumed = false PHImageManager.default().requestImage( for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: options ) { image, info in let degraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false if !degraded, !resumed { resumed = true continuation.resume(returning: image) } } } } } // MARK: - Info panel: real data first, generated content badged struct PhotoInfoSheet: View { let localIdentifier: String @State private var info: PhotoInfo? struct PhotoInfo { var captureDate: Date? var projectName: String? var subjectHint: String? var placeText: String? var settingsText: String? var ocrText: String? var gist: String? // generated var entities: [String] // generated } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { if let info { // Real data — plain, trustworthy presentation. if let date = info.captureDate { row("Date", date.formatted(date: .long, time: .shortened)) } if let project = info.projectName { row("Projet", project) } if let hint = info.subjectHint { row("Sujet noté", hint) } if let place = info.placeText { row("Lieu", place) } if let settings = info.settingsText { row("Réglages", settings) } if let ocr = info.ocrText, !ocr.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Texte dans l'image (OCR)") .font(.caption) .foregroundStyle(DesignTokens.textSecondary) Text(ocr) .font(.callout.monospaced()) .textSelection(.enabled) } } // Generated data — visually distinct, always (CLAUDE.md §8). if info.gist != nil || !info.entities.isEmpty { VStack(alignment: .leading, spacing: 6) { GeneratedBadge() if let gist = info.gist { Text(gist).font(.callout) } if !info.entities.isEmpty { Text(info.entities.joined(separator: " · ")) .font(.footnote) .foregroundStyle(DesignTokens.textSecondary) } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( DesignTokens.generatedContent.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.cornerRadius) ) } if info.ocrText == nil && info.gist == nil { Text("Photo pas encore indexée — elle le sera automatiquement.") .font(.footnote) .foregroundStyle(DesignTokens.textSecondary) } } else { ProgressView() } } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) } .presentationBackground(DesignTokens.surfaceRaised) .task(id: localIdentifier) { loadInfo() } } private func row(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption) .foregroundStyle(DesignTokens.textSecondary) Text(value) .font(.callout) .textSelection(.enabled) } } private func loadInfo() { let modelContext = ModelContext(IndexStore.container) var descriptor = FetchDescriptor( predicate: #Predicate { $0.localIdentifier == localIdentifier } ) descriptor.fetchLimit = 1 let record = try? modelContext.fetch(descriptor).first let asset = PHAsset.fetchAssets( withLocalIdentifiers: [localIdentifier], options: nil ).firstObject var settingsText: String? if let settings = record?.captureContext?.settings { var parts: [String] = [settings.lens.displayName] if let iso = settings.iso { parts.append("ISO \(Int(iso))") } if let shutter = settings.shutterSeconds { parts.append(shutter >= 0.25 ? String(format: "%.1f s", shutter) : "1/\(Int((1.0 / shutter).rounded()))") } parts.append(settings.format.displayName) settingsText = parts.joined(separator: " · ") } let placeText = [record?.placeName, record?.placeLocality] .compactMap(\.self) .joined(separator: ", ") info = PhotoInfo( captureDate: record?.captureDate ?? asset?.creationDate, projectName: record?.projectName, subjectHint: record?.subjectHint, placeText: placeText.isEmpty ? nil : placeText, settingsText: settingsText, ocrText: record?.ocrText, gist: record?.gist, entities: record?.entities ?? [] ) } }