Pure-Native SwiftUI-App für Moodlit. Pendant zur SvelteKit-Web-App auf moodlit.mana.how; konsumiert ManaCore + ManaTokens + ManaAuthUI aus den Schwester-Repos. Stack: - SwiftUI Universal (iOS 18 / macOS 15), Swift 6 strict concurrency - mana-swift-core + mana-swift-ui (lokale SPM-Pakete via XcodeGen) - Bundle ev.mana.moodlit, Team QP3GLU8PH3, App-Group group.ev.mana.moodlit Features: - 24 Mood-Presets als Swift-Konstanten (Port von default-moods.ts) - Custom-Moods + Sequenzen via MoodlitAPI (Actor mit JWT-Bearer-Calls über AuthenticatedTransport, automatischer 401-Retry) - MoodPlayerView mit Idle-Timer-Off, Status-Bar-Hidden, Timer-Auto- Close, Favorite-Toggle, Play/Pause, Auto-Hide-Controls - SequencePlayerView mit Crossfade-Rotation durch alle Sequence-Moods (Net new ggü. Web — dort ist Sequence-Playback nicht verkabelt) - AnimatedMoodView rendert alle 21 AnimationTypes als 30-fps Timeline- View mit sin/cos-modulierten Filter-Effekten - Cards-Pattern Auth-Gate: Presets ohne Login sichtbar, Custom- Creation triggert ManaAuthGate.require → Login-Sheet - Theme: ManaTheme.twilight Forward (Violett #7c3aed) Build verified: - xcodebuild iOS Simulator (iPhone 17) → BUILD SUCCEEDED - xcodebuild macOS → BUILD SUCCEEDED Offen (μ-7.1+): Apple-Dev-Portal-Setup (Bundle, Capabilities), TestFlight, Widget, Settings-UI (Brightness/Speed), Hex-Color-Picker mit Text-Input, Visual-Polish der per-Animation Effekte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
4.4 KiB
Swift
176 lines
4.4 KiB
Swift
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
/// Spielt eine Sequence im Vollbild: rotiert durch alle `moods`, jedes
|
|
/// für `sequence.durationSec` Sekunden, mit `sequence.transitionSec`-
|
|
/// Sekunden Crossfade. Loop, bis User schließt.
|
|
///
|
|
/// **Net new gegenüber Web** — die SvelteKit-App hat keinen Sequence-
|
|
/// Player. Hier ist's der Killer-Use-Case für die Native-App.
|
|
public struct SequencePlayerView: View {
|
|
let sequence: MoodSequence
|
|
let moods: [Mood]
|
|
let onClose: () -> Void
|
|
|
|
@State private var currentIndex: Int = 0
|
|
@State private var rotationTask: Task<Void, Never>?
|
|
@State private var showControls = true
|
|
@State private var controlsTask: Task<Void, Never>?
|
|
@State private var isPaused = false
|
|
|
|
public init(sequence: MoodSequence, moods: [Mood], onClose: @escaping () -> Void) {
|
|
self.sequence = sequence
|
|
self.moods = moods
|
|
self.onClose = onClose
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack(alignment: .top) {
|
|
// Crossfade durch ZStack mit opacity-Transition zwischen
|
|
// zwei Layers.
|
|
ForEach(Array(moods.enumerated()), id: \.element.id) { idx, mood in
|
|
AnimatedMoodView(mood: mood, isPaused: isPaused)
|
|
.opacity(idx == currentIndex ? 1 : 0)
|
|
.animation(
|
|
.easeInOut(duration: Double(sequence.transitionSec)),
|
|
value: currentIndex
|
|
)
|
|
}
|
|
|
|
if showControls {
|
|
controlsOverlay
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { revealControls() }
|
|
.onAppear {
|
|
revealControls()
|
|
disableIdleTimer(true)
|
|
startRotation()
|
|
}
|
|
.onDisappear {
|
|
rotationTask?.cancel()
|
|
controlsTask?.cancel()
|
|
disableIdleTimer(false)
|
|
}
|
|
.onChange(of: isPaused) { _, paused in
|
|
if paused {
|
|
rotationTask?.cancel()
|
|
} else {
|
|
startRotation()
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.statusBarHidden(true)
|
|
.persistentSystemOverlays(.hidden)
|
|
#endif
|
|
}
|
|
|
|
private var controlsOverlay: some View {
|
|
VStack {
|
|
HStack {
|
|
Button(action: onClose) {
|
|
Image(systemName: "xmark")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(10)
|
|
.background(.white.opacity(0.20), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(sequence.name)
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.shadow(radius: 4)
|
|
if moods.indices.contains(currentIndex) {
|
|
Text("\(currentIndex + 1) / \(moods.count) · \(moods[currentIndex].name)")
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.75))
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 24) {
|
|
Button { advance(-1) } label: {
|
|
Image(systemName: "backward.fill")
|
|
.font(.title)
|
|
.foregroundStyle(.white)
|
|
.padding(16)
|
|
.background(.white.opacity(0.20), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button { isPaused.toggle(); revealControls() } label: {
|
|
Image(systemName: isPaused ? "play.fill" : "pause.fill")
|
|
.font(.system(size: 36, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(24)
|
|
.background(.white.opacity(0.20), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button { advance(1) } label: {
|
|
Image(systemName: "forward.fill")
|
|
.font(.title)
|
|
.foregroundStyle(.white)
|
|
.padding(16)
|
|
.background(.white.opacity(0.20), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.bottom, 32)
|
|
}
|
|
}
|
|
|
|
private func startRotation() {
|
|
rotationTask?.cancel()
|
|
guard moods.count > 1, !isPaused else { return }
|
|
rotationTask = Task { @MainActor in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(sequence.durationSec))
|
|
if Task.isCancelled { return }
|
|
currentIndex = (currentIndex + 1) % moods.count
|
|
}
|
|
}
|
|
}
|
|
|
|
private func advance(_ delta: Int) {
|
|
guard !moods.isEmpty else { return }
|
|
currentIndex = (currentIndex + delta + moods.count) % moods.count
|
|
startRotation() // Timer-Reset
|
|
revealControls()
|
|
}
|
|
|
|
private func revealControls() {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
showControls = true
|
|
}
|
|
controlsTask?.cancel()
|
|
controlsTask = Task {
|
|
try? await Task.sleep(for: .seconds(3))
|
|
guard !Task.isCancelled else { return }
|
|
await MainActor.run {
|
|
if !isPaused {
|
|
withAnimation(.easeInOut(duration: 0.4)) {
|
|
showControls = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func disableIdleTimer(_ disabled: Bool) {
|
|
#if os(iOS)
|
|
UIApplication.shared.isIdleTimerDisabled = disabled
|
|
#endif
|
|
}
|
|
}
|