Files
Flaro/Flixy/Views/Search/SearchView.swift
T
ScarriffleandClaude Opus 5 81e22c4f97 Hinter der Insel läuft jetzt das Wasser durch
Der schwarze Streifen über der Navigation war kein gezeichneter Balken,
sondern fehlender Inhalt: der gesamte Baum saß innerhalb des
Sicherheitsrands. Deshalb erreichte kein noch so gut gesetzter Rand
weiter unten die Fläche darüber — Inhalt kam dort nie hin.

Jetzt reißt der HStack den Rand oben auf und gibt den Platz kontrolliert
zurück: Seitenleiste und Insel bekommen ihren Abstand zur Statusleiste
explizit, der Inhalt bekommt ihn als Sicherheitsrand. Der Aufmacher
nimmt ihn sich wieder — um genau diese Strecke höher und um genau diese
Strecke größer, sodass unten alles bleibt, wo es war. Dazu ein knapper
Verlauf oben, damit Uhrzeit und Batterie auch über einem hellen Bild
lesbar bleiben.

Und die Leiste oben fährt beim Scrollen weg. Nur sie: der
Sicherheitsrand darunter bleibt stehen, sonst ruckte die Seite bei jedem
Ein- und Ausblenden unter dem Finger weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:52:07 +02:00

125 lines
4.0 KiB
Swift

import SwiftUI
import FlixyDesign
import MediaKit
/// Sucht über alle Mediatheken des Servers.
struct SearchView: View {
@Environment(FlixySession.self) private var session
@State private var text = ""
@State private var hubs: [MediaRowGroup] = []
@State private var isSearching = false
@State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
private static let recentKey = "flixy.search.recent"
private static let recentLimit = 8
var body: some View {
Group {
if text.isEmpty {
recentSearches
} else if hubs.isEmpty && !isSearching {
ContentUnavailableView.search(text: text)
} else {
results
}
}
.background(Color.flixyCanvas)
.navigationTitle(Text("search.title"))
.flixyInlineTitle()
.searchable(text: $text)
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
// von denen elf verworfen werden.
.task(id: text) { await search() }
}
private var results: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
ForEach(hubs) { hub in
MediaRow(row: hub)
}
}
.padding(.vertical, FlixySpacing.loose)
}
.flixyRevealsTopChrome()
}
@ViewBuilder
private var recentSearches: some View {
if recent.isEmpty {
ContentUnavailableView {
Label("search.title", systemImage: "magnifyingglass")
} description: {
Text("search.hint")
}
} else {
List {
Section {
ForEach(recent, id: \.self) { entry in
Button {
text = entry
} label: {
Label(entry, systemImage: "clock.arrow.circlepath")
.foregroundStyle(Color.flixyTextSecondary)
}
}
.onDelete { offsets in
recent.remove(atOffsets: offsets)
persistRecent()
}
} header: {
Text("search.recent")
}
.listRowBackground(Color.flixySurface)
}
.scrollContentBackground(.hidden)
.flixyRevealsTopChrome()
}
}
private func search() async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let backend = session.backend, trimmed.count >= 2 else {
hubs = []
return
}
do {
try await Task.sleep(for: .milliseconds(300))
} catch {
// Ein neuer Tastendruck hat diese Aufgabe abgebrochen — genau der
// Zweck der Pause.
return
}
isSearching = true
defer { isSearching = false }
do {
hubs = try await backend.search(trimmed, limit: 30)
if !hubs.isEmpty { remember(trimmed) }
} catch {
// Abgebrochen, weil weitergetippt oder der Bildschirm verlassen
// wurde: `Task.isCancelled` statt `catch is CancellationError` —
// eine abgebrochene Netzanfrage kommt als Übertragungsfehler
// zurück, nicht als Abbruch, und die alte Prüfung lief deshalb
// ins Leere.
if Task.isCancelled { return }
hubs = []
}
}
private func remember(_ entry: String) {
recent.removeAll { $0.caseInsensitiveCompare(entry) == .orderedSame }
recent.insert(entry, at: 0)
recent = Array(recent.prefix(Self.recentLimit))
persistRecent()
}
private func persistRecent() {
UserDefaults.standard.set(recent, forKey: Self.recentKey)
}
}