spb/focale Public
Swift 100%
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