// // SearchView.swift // Focale // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // // Natural language in, ranked local results out. Generated descriptions // wear a visible badge — never dressed up as real data (CLAUDE.md §8). // import SwiftUI struct SearchView: View { @Environment(AppModel.self) private var app @State private var query = "" @State private var results: [SearchResult] = [] @State private var isSearching = false @State private var hasSearched = false @State private var viewer: PhotoViewerContext? @State private var lastFilter: SearchFilter = .empty @State private var showsAlbumAlert = false @State private var albumName = "" private let analyzer = QueryAnalyzer() private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)] var body: some View { NavigationStack { ScrollView { if isSearching { ProgressView().padding(.top, 40) } else if results.isEmpty && hasSearched { emptyState } else { resultsGrid } } .background(DesignTokens.surface) .navigationTitle("Recherche") .searchable( text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "« le reçu du garage l'automne passé »" ) .onSubmit(of: .search) { runSearch() } .fullScreenCover(item: $viewer) { context in PhotoDetailView(context: context) } .toolbar { if !results.isEmpty && !lastFilter.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button("Créer un album vivant", systemImage: "sparkles.rectangle.stack") { albumName = query showsAlbumAlert = true } } } } .alert("Album vivant", isPresented: $showsAlbumAlert) { TextField("Nom de l'album", text: $albumName) Button("Créer") { let name = albumName.trimmingCharacters(in: .whitespacesAndNewlines) if !name.isEmpty { app.albums.add(LivingAlbum(name: name, filter: lastFilter)) } albumName = "" } Button("Annuler", role: .cancel) { albumName = "" } } message: { Text("Cet album se remplira tout seul à mesure que tu photographies.") } } } private var resultsGrid: some View { LazyVGrid(columns: columns, spacing: 2) { ForEach(results) { result in Button { viewer = PhotoViewerContext( id: result.localIdentifier, identifiers: results.map(\.localIdentifier) ) } label: { ZStack(alignment: .bottomLeading) { AssetThumbnailView(localIdentifier: result.localIdentifier) .aspectRatio(1, contentMode: .fill) .clipped() if result.gist != nil { // Generated content is visually distinct, always. GeneratedBadge() .padding(4) } } } .buttonStyle(.plain) } } } private var emptyState: some View { VStack(spacing: 10) { Image(systemName: "magnifyingglass") .font(.largeTitle) .foregroundStyle(DesignTokens.textSecondary) Text("Rien trouvé pour cette recherche") .font(.callout) .foregroundStyle(DesignTokens.textSecondary) Text("Les photos les plus récentes sont indexées en premier ; les plus anciennes arrivent au fil des nuits.") .font(.footnote) .foregroundStyle(DesignTokens.textSecondary) .multilineTextAlignment(.center) .padding(.horizontal, 30) } .padding(.top, 60) } private func runSearch() { let text = query isSearching = true Task { // The model analyzes the query only — it never walks the photos. let filter = await analyzer.analyze(text) lastFilter = filter results = await app.searchEngine.search(filter) isSearching = false hasSearched = true } } }