Zwei Schalter, die im selben Bildschirm untereinander stehen, ergaben zusammen gar keinen Aufmacher mehr: „Gesehenes aus Neuzugängen ausblenden" und eine angehakte Mediathek. Der Grund steht in `HomeFeed.prepared`. Der Zweig für das Ausblenden baut die betroffenen Reihen neu — und baute sie **ohne `libraryID`** neu, weil der neue Parameter dort nicht mitgegeben war. Getroffen hat es dabei ausgerechnet die Reihen `latest.<mediathek>`, die als einzige überhaupt eine tragen. Danach warf der Filter des Aufmachers jede Reihe ohne Mediathek weg, und übrig blieb nichts. Dasselbe an zweiter Stelle: `combiningContinueRows` verlor `libraryID` **und** `moreKey` — Letzteres ein eigener, älterer Fehler, durch den bei zusammengelegten Weiterschauen-Reihen „Alle anzeigen" wegfiel. **Dazu eine Regel, die ich zurücknehme.** Gestern stand hier: trifft die Auswahl nichts, bleibt nichts. Das war als Ehrlichkeit gedacht und ist in der Praxis der schlechtere Fehler — ein Aufmacher, der lautlos verschwindet, wird überall gesucht, nur nicht in einer Einstellung zwei Bildschirme weiter. Trifft die Auswahl keine einzige Reihe, gilt sie jetzt als wirkungslos. Das deckt auch den Fall ab, dass eine gespeicherte Kennung nach einem Serverwechsel ins Leere zeigt. Und damit solche Kennungen nicht unsichtbar weiterwirken, werden sie beim Öffnen des Bildschirms gegen die vorhandenen Mediatheken abgeglichen — vorher standen sie in keiner Zeile und liessen sich nicht abwählen. Geprüft im Simulator: „Gesehenes ausblenden" an, „Filme" angehakt — der Aufmacher ist da und zeigt einen Film. Vorher: nichts. iOS und tvOS bauen, 469 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
8.5 KiB
Swift
212 lines
8.5 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
|
struct HomeLayoutView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.flixyAccent) private var accent
|
|
@Environment(HomeLayoutSettings.self) private var layout
|
|
|
|
@State private var rows: [HomeRow] = []
|
|
@State private var isLoading = true
|
|
|
|
var body: some View {
|
|
list
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(Text("homeLayout.title"))
|
|
.flixyInlineTitle()
|
|
.task { await load() }
|
|
}
|
|
|
|
private var list: some View {
|
|
List {
|
|
@Bindable var layout = layout
|
|
|
|
Section {
|
|
Picker("home.heroSource", selection: $layout.heroSource) {
|
|
ForEach(HomeLayoutSettings.HeroSource.allCases) { source in
|
|
Text(source.label).tag(source)
|
|
}
|
|
}
|
|
Picker("home.heroCount", selection: $layout.heroCount) {
|
|
ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in
|
|
Text(count.formatted()).tag(count)
|
|
}
|
|
}
|
|
|
|
// Ein Regler und keine Auswahl aus festen Stufen: es gibt hier
|
|
// keinen richtigen Wert, nur den, bei dem die erste Reihe
|
|
// gerade so weit hereinlugt, wie man es mag.
|
|
LabeledContent {
|
|
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.monospacedDigit()
|
|
} label: {
|
|
Text("home.heroHeight")
|
|
}
|
|
Slider(
|
|
value: $layout.heroHeight,
|
|
in: HomeLayoutSettings.heroHeightRange,
|
|
step: 0.05
|
|
)
|
|
.accessibilityLabel(Text("home.heroHeight"))
|
|
} header: {
|
|
Text("home.hero")
|
|
} footer: {
|
|
Text("home.heroExplanation")
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
|
|
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
|
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
|
// den man versehentlich trifft. Hier sind Haken: keiner gesetzt
|
|
// ist der Normalfall und bedeutet, dass alles infrage kommt.
|
|
if !session.libraries.isEmpty {
|
|
Section {
|
|
ForEach(session.libraries) { library in
|
|
Button {
|
|
toggleHeroLibrary(library.id)
|
|
} label: {
|
|
LabeledContent {
|
|
if layout.heroLibraries.contains(library.id) {
|
|
Image(systemName: "checkmark")
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(accent)
|
|
}
|
|
} label: {
|
|
Text(library.title)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
}
|
|
// Damit der ganze Streifen trifft und nicht nur
|
|
// die Schrift — eine Zeile, bei der nur ein Wort
|
|
// reagiert, liest sich als kaputt.
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityAddTraits(
|
|
layout.heroLibraries.contains(library.id) ? .isSelected : []
|
|
)
|
|
}
|
|
} header: {
|
|
Text("home.heroLibraries")
|
|
} footer: {
|
|
Text("home.heroLibrariesExplanation")
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
}
|
|
|
|
Section {
|
|
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
|
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
.listRowBackground(Color.clear)
|
|
} else if rows.isEmpty {
|
|
Text("homeLayout.empty")
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.listRowBackground(Color.flixySurface)
|
|
}
|
|
|
|
Section {
|
|
ForEach($rows) { $row in
|
|
Toggle(isOn: Binding(
|
|
get: { !row.isHidden },
|
|
set: {
|
|
row.isHidden = !$0
|
|
layout.setHidden(!$0, for: row.id)
|
|
}
|
|
)) {
|
|
Text(row.title)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
}
|
|
.onMove { indices, destination in
|
|
rows.move(fromOffsets: indices, toOffset: destination)
|
|
layout.setOrder(rows.map(\.id))
|
|
}
|
|
} header: {
|
|
// **Beim Abschnitt, auf den er wirkt.** In der Werkzeugleiste
|
|
// stand er über allem — über dem Aufmacher, über den
|
|
// Mediatheken — und tat dort nichts. Ein Knopf, der nur für
|
|
// einen Teil des Bildschirms gilt, gehört an diesen Teil.
|
|
//
|
|
// Auf dem Mac gibt es ihn nicht: dort zieht man Zeilen ohne
|
|
// vorherigen Moduswechsel.
|
|
HStack {
|
|
Text("home.rowsHeader")
|
|
#if os(iOS)
|
|
if !rows.isEmpty {
|
|
Spacer()
|
|
EditButton()
|
|
.font(.flixyCaption)
|
|
.textCase(nil)
|
|
}
|
|
#endif
|
|
}
|
|
} footer: {
|
|
Text("homeLayout.explanation")
|
|
}
|
|
}
|
|
// Ausdrücklich, nicht nur geerbt.
|
|
//
|
|
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
|
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
|
.tint(accent)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
|
|
/// Kreuzt eine Mediathek für den Aufmacher an oder ab.
|
|
private func toggleHeroLibrary(_ identifier: String) {
|
|
if layout.heroLibraries.contains(identifier) {
|
|
layout.heroLibraries.remove(identifier)
|
|
} else {
|
|
layout.heroLibraries.insert(identifier)
|
|
}
|
|
}
|
|
|
|
/// Wirft gespeicherte Kennungen weg, die es nicht mehr gibt.
|
|
///
|
|
/// **Sonst wirken sie unsichtbar weiter.** Nach einem Serverwechsel
|
|
/// bleibt in den Voreinstellungen eine Mediathekskennung stehen, die zu
|
|
/// nichts mehr passt — sie steht in keiner Zeile, lässt sich also nicht
|
|
/// abwählen, und schränkt den Aufmacher trotzdem ein.
|
|
///
|
|
/// Nur, wenn überhaupt Mediatheken geladen sind: eine leere Liste heisst
|
|
/// „noch nicht da", nicht „gibt es nicht".
|
|
private func pruneHeroLibraries() {
|
|
guard !session.libraries.isEmpty else { return }
|
|
let vorhanden = Set(session.libraries.map(\.id))
|
|
let bereinigt = layout.heroLibraries.intersection(vorhanden)
|
|
guard bereinigt != layout.heroLibraries else { return }
|
|
layout.heroLibraries = bereinigt
|
|
}
|
|
|
|
private func load() async {
|
|
defer { isLoading = false }
|
|
pruneHeroLibraries()
|
|
guard let backend = session.backend else { return }
|
|
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
|
|
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
|
|
// Mediatheken und Sammlungen angelegt sind.
|
|
let hubs = (try? await backend.homeRows()) ?? []
|
|
rows = layout.rows(from: hubs)
|
|
}
|
|
}
|
|
|
|
extension HomeLayoutSettings.HeroSource {
|
|
var label: LocalizedStringKey {
|
|
switch self {
|
|
case .continueFirst: "home.heroSource.continueFirst"
|
|
case .latestOnly: "home.heroSource.latestOnly"
|
|
}
|
|
}
|
|
}
|