Phase 0: Projektgerüst, Design-Tokens und Navigation
Legt das Fundament für den Plex-Client an: - XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices, strict concurrency, Projektdatei nicht eingecheckt) - FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback, FlixyDownloads. macOS steht als Plattform mit drin, damit die Logik-Tests ohne Simulator laufen. - FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die Umgebung und wird per WCAG-Rechnung lesbar gehalten. - PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und die Medienmodelle samt nachsichtigem Decoder. - FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die eine Funktion, an der Phase 2 das Transcoding andockt. - Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad. - 45 Tests, alle grün. VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig, deshalb bekommt sie in Phase 1 ein eigenes Target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
|
||||
/// Die ausfahrbare Seitenleiste auf dem iPhone.
|
||||
///
|
||||
/// Sie legt sich über den Inhalt und dunkelt ihn ab, statt ihn zur Seite zu
|
||||
/// schieben — dadurch bleibt die Position im Inhalt erhalten und das Zumachen
|
||||
/// fühlt sich an wie das Wegwischen eines Blattes.
|
||||
struct DrawerContainer<Sidebar: View, Content: View>: View {
|
||||
@Binding var isOpen: Bool
|
||||
|
||||
@ViewBuilder let sidebar: Sidebar
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
/// Breit genug für lange Mediatheksnamen, schmal genug, dass vom Inhalt
|
||||
/// dahinter noch etwas zu sehen bleibt.
|
||||
private let width: CGFloat = 280
|
||||
/// Ab wie weit gezogen die Leiste offen bleibt, statt zurückzuschnappen.
|
||||
private let commitThreshold: CGFloat = 0.4
|
||||
|
||||
@GestureState private var drag: CGFloat = 0
|
||||
@Environment(\.layoutDirection) private var layoutDirection
|
||||
|
||||
/// 0 = zu, 1 = offen. Fasst Zustand und laufende Geste zusammen, damit
|
||||
/// Abdunklung und Position immer zueinander passen.
|
||||
private var openness: CGFloat {
|
||||
let base = isOpen ? width : 0
|
||||
return ((base + drag) / width).clamped()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
content
|
||||
.accessibilityHidden(isOpen)
|
||||
|
||||
if openness > 0 {
|
||||
Color.flixyScrim
|
||||
.opacity(openness)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { setOpen(false) }
|
||||
// Ohne diese Beschriftung ist die Fläche für VoiceOver ein
|
||||
// stummer Block, der den Inhalt verdeckt.
|
||||
.accessibilityLabel(Text("sidebar.close"))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityAction { setOpen(false) }
|
||||
}
|
||||
|
||||
sidebar
|
||||
.frame(width: width)
|
||||
.frame(maxHeight: .infinity)
|
||||
.background(Color.flixySurface)
|
||||
.offset(x: (openness - 1) * width)
|
||||
.accessibilityHidden(!isOpen)
|
||||
}
|
||||
.gesture(dragGesture)
|
||||
.animation(.snappy(duration: 0.28), value: isOpen)
|
||||
}
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 12, coordinateSpace: .global)
|
||||
.updating($drag) { value, state, _ in
|
||||
// Bei geschlossener Leiste zählt nur ein Zug, der am linken
|
||||
// Rand beginnt. Sonst würde jedes waagerechte Wischen in einer
|
||||
// Reihe die Seitenleiste hervorziehen.
|
||||
if !isOpen {
|
||||
guard value.startLocation.x < 24 else { return }
|
||||
state = max(0, value.translation.width)
|
||||
} else {
|
||||
state = min(0, value.translation.width)
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
guard isOpen || value.startLocation.x < 24 else { return }
|
||||
let projected = openness + (value.predictedEndTranslation.width - value.translation.width) / width
|
||||
setOpen(projected > commitThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
private func setOpen(_ open: Bool) {
|
||||
guard open != isOpen else { return }
|
||||
withAnimation(.snappy(duration: 0.28)) { isOpen = open }
|
||||
}
|
||||
}
|
||||
|
||||
extension CGFloat {
|
||||
fileprivate func clamped(to range: ClosedRange<CGFloat> = 0...1) -> CGFloat {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user