spb/focale Public
Swift 100%
1//2// SearchView.swift3// Focale4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7//8// Natural language in, ranked local results out. Generated descriptions9// wear a visible badge — never dressed up as real data (CLAUDE.md §8).10//1112import SwiftUI1314struct SearchView: View {15 @Environment(AppModel.self) private var app1617 @State private var query = ""18 @State private var results: [SearchResult] = []19 @State private var isSearching = false20 @State private var hasSearched = false21 @State private var viewer: PhotoViewerContext?22 @State private var lastFilter: SearchFilter = .empty23 @State private var showsAlbumAlert = false24 @State private var albumName = ""2526 private let analyzer = QueryAnalyzer()27 private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]2829 var body: some View {30 NavigationStack {31 ScrollView {32 if isSearching {33 ProgressView().padding(.top, 40)34 } else if results.isEmpty && hasSearched {35 emptyState36 } else {37 resultsGrid38 }39 }40 .background(DesignTokens.surface)41 .navigationTitle("Recherche")42 .searchable(43 text: $query,44 placement: .navigationBarDrawer(displayMode: .always),45 prompt: "« le reçu du garage l'automne passé »"46 )47 .onSubmit(of: .search) { runSearch() }48 .fullScreenCover(item: $viewer) { context in49 PhotoDetailView(context: context)50 }51 .toolbar {52 if !results.isEmpty && !lastFilter.isEmpty {53 ToolbarItem(placement: .topBarTrailing) {54 Button("Créer un album vivant", systemImage: "sparkles.rectangle.stack") {55 albumName = query56 showsAlbumAlert = true57 }58 }59 }60 }61 .alert("Album vivant", isPresented: $showsAlbumAlert) {62 TextField("Nom de l'album", text: $albumName)63 Button("Créer") {64 let name = albumName.trimmingCharacters(in: .whitespacesAndNewlines)65 if !name.isEmpty {66 app.albums.add(LivingAlbum(name: name, filter: lastFilter))67 }68 albumName = ""69 }70 Button("Annuler", role: .cancel) { albumName = "" }71 } message: {72 Text("Cet album se remplira tout seul à mesure que tu photographies.")73 }74 }75 }7677 private var resultsGrid: some View {78 LazyVGrid(columns: columns, spacing: 2) {79 ForEach(results) { result in80 Button {81 viewer = PhotoViewerContext(82 id: result.localIdentifier,83 identifiers: results.map(\.localIdentifier)84 )85 } label: {86 ZStack(alignment: .bottomLeading) {87 AssetThumbnailView(localIdentifier: result.localIdentifier)88 .aspectRatio(1, contentMode: .fill)89 .clipped()90 if result.gist != nil {91 // Generated content is visually distinct, always.92 GeneratedBadge()93 .padding(4)94 }95 }96 }97 .buttonStyle(.plain)98 }99 }100 }101102 private var emptyState: some View {103 VStack(spacing: 10) {104 Image(systemName: "magnifyingglass")105 .font(.largeTitle)106 .foregroundStyle(DesignTokens.textSecondary)107 Text("Rien trouvé pour cette recherche")108 .font(.callout)109 .foregroundStyle(DesignTokens.textSecondary)110 Text("Les photos les plus récentes sont indexées en premier ; les plus anciennes arrivent au fil des nuits.")111 .font(.footnote)112 .foregroundStyle(DesignTokens.textSecondary)113 .multilineTextAlignment(.center)114 .padding(.horizontal, 30)115 }116 .padding(.top, 60)117 }118119 private func runSearch() {120 let text = query121 isSearching = true122 Task {123 // The model analyzes the query only — it never walks the photos.124 let filter = await analyzer.analyze(text)125 lastFilter = filter126 results = await app.searchEngine.search(filter)127 isSearching = false128 hasSearched = true129 }130 }131}132