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>
179 lines
5.2 KiB
Swift
179 lines
5.2 KiB
Swift
import SwiftUI
|
|
|
|
/// Vollbild-Renderer für ein Mood. Rendert die Hex-Farben als
|
|
/// `LinearGradient` (135°, wie das Web-`linear-gradient(135deg, ...)`)
|
|
/// und überlagert je `AnimationType` einen TimelineView-getriebenen
|
|
/// Effekt.
|
|
///
|
|
/// Pendant zu `MoodFullscreen.svelte` + den CSS-Keyframes in
|
|
/// `Code/moodlit/apps/web/src/app.css`. Effekte sind in einer
|
|
/// generischen `effectModifier(...)`-Funktion gebündelt, damit das
|
|
/// File übersichtlich bleibt — neue Animation-Typen ergänzen ohne
|
|
/// die ganze View umzuschreiben.
|
|
public struct AnimatedMoodView: View {
|
|
let mood: Mood
|
|
let isPaused: Bool
|
|
let speedMultiplier: Double
|
|
|
|
public init(mood: Mood, isPaused: Bool = false, speedMultiplier: Double = 1.0) {
|
|
self.mood = mood
|
|
self.isPaused = isPaused
|
|
self.speedMultiplier = speedMultiplier
|
|
}
|
|
|
|
public var body: some View {
|
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: isPaused)) { timeline in
|
|
let elapsed = timeline.date.timeIntervalSinceReferenceDate * speedMultiplier
|
|
ZStack {
|
|
baseGradient
|
|
.modifier(
|
|
MoodEffectModifier(animation: mood.animation, elapsed: elapsed)
|
|
)
|
|
// Bottom-fade-Overlay für bessere Text-Lesbarkeit im
|
|
// Player (matchend zum Web-Overlay).
|
|
LinearGradient(
|
|
colors: [.black.opacity(0.55), .clear],
|
|
startPoint: .bottom,
|
|
endPoint: .center
|
|
)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var baseGradient: some View {
|
|
if mood.colors.count == 1 {
|
|
Color(hex: mood.colors[0])
|
|
} else {
|
|
LinearGradient(
|
|
colors: mood.swiftUIColors,
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SOS-Morse-Pattern (... --- ...). Index ungerade = aus, gerade = an.
|
|
/// 2s Pause am Ende des Zyklus.
|
|
private func sosOpacity(at elapsed: TimeInterval) -> Double {
|
|
let pattern: [TimeInterval] = [
|
|
0.2, 0.2, 0.2, 0.2, 0.2, 0.2, // 3 kurze Blitze + Pausen
|
|
0.6, 0.2, 0.6, 0.2, 0.6, 0.2, // 3 lange Blitze + Pausen
|
|
0.2, 0.2, 0.2, 0.2, 0.2, 0.2, // 3 kurze Blitze + Pausen
|
|
2.0 // Pause vor Loop
|
|
]
|
|
let total = pattern.reduce(0, +)
|
|
let t = elapsed.truncatingRemainder(dividingBy: total)
|
|
var acc: TimeInterval = 0
|
|
for (i, dur) in pattern.enumerated() {
|
|
if t < acc + dur { return i.isMultiple(of: 2) ? 1.0 : 0.05 }
|
|
acc += dur
|
|
}
|
|
return 1.0
|
|
}
|
|
|
|
/// Time-basierter Effekt-Modifier, dispatched auf AnimationType.
|
|
/// `elapsed` ist Sekunden seit Referenz — modulo-Cycle pro Effekt.
|
|
private struct MoodEffectModifier: ViewModifier {
|
|
let animation: AnimationType
|
|
let elapsed: TimeInterval
|
|
|
|
func body(content: Content) -> some View {
|
|
switch animation {
|
|
case .breath, .pulse, .forest:
|
|
// 4s Atem-Cycle: scale 0.95↔1.0, opacity 0.85↔1.0
|
|
let t = (sin(elapsed * .pi * 2 / 4) + 1) / 2
|
|
content
|
|
.scaleEffect(0.95 + 0.05 * t)
|
|
.opacity(0.85 + 0.15 * t)
|
|
|
|
case .wave, .ocean, .aurora:
|
|
// 6s wave — horizontal shift via mask offset (simuliert via brightness wave)
|
|
let t = (sin(elapsed * .pi * 2 / 6) + 1) / 2
|
|
content
|
|
.brightness(-0.05 + 0.10 * t)
|
|
.saturation(0.85 + 0.30 * t)
|
|
|
|
case .candle, .fire:
|
|
// 0.8s flicker via brightness jitter
|
|
let t = sin(elapsed * .pi * 2 / 0.8) * 0.5 + sin(elapsed * .pi * 2 / 0.3) * 0.3
|
|
content
|
|
.brightness(t * 0.10)
|
|
.opacity(0.92 + abs(t) * 0.08)
|
|
|
|
case .disco, .rave:
|
|
// 2s hue rotation
|
|
let degrees = (elapsed.truncatingRemainder(dividingBy: 2.0)) / 2.0 * 360
|
|
content
|
|
.hueRotation(.degrees(degrees))
|
|
.saturation(1.2)
|
|
|
|
case .thunder:
|
|
// 6s cycle: dark, dark, FLASH, dark...
|
|
let phase = elapsed.truncatingRemainder(dividingBy: 6.0)
|
|
let brightness = (phase > 3.0 && phase < 3.1) ? 0.6 : -0.3
|
|
content.brightness(brightness)
|
|
|
|
case .police:
|
|
// 0.6s red↔blue color shift via hueRotation
|
|
let t = sin(elapsed * .pi * 2 / 0.6)
|
|
content
|
|
.hueRotation(.degrees(t * 60))
|
|
.brightness(t * 0.15)
|
|
|
|
case .warning:
|
|
// 1s yellow/orange pulse
|
|
let t = (sin(elapsed * .pi * 2 / 1.0) + 1) / 2
|
|
content
|
|
.brightness(-0.1 + 0.2 * t)
|
|
|
|
case .flash:
|
|
// 0.5s strobe
|
|
let t = elapsed.truncatingRemainder(dividingBy: 0.5)
|
|
content.opacity(t < 0.05 ? 0.0 : 1.0)
|
|
|
|
case .sos:
|
|
// Morse-Pattern: ... --- ... (kurz-kurz-kurz lang-lang-lang kurz-kurz-kurz)
|
|
content.opacity(sosOpacity(at: elapsed))
|
|
|
|
case .scanner:
|
|
// 2s horizontal sweep via brightness gradient (vereinfacht: Pulse)
|
|
let t = (sin(elapsed * .pi * 2 / 2) + 1) / 2
|
|
content.brightness(-0.3 + 0.6 * t)
|
|
|
|
case .matrix:
|
|
// 1.2s steps brightness
|
|
let phase = Int(elapsed * 5) % 2
|
|
content.brightness(phase == 0 ? -0.3 : 0.3)
|
|
|
|
case .sunrise:
|
|
// 12s ease: brightness 0.3→1.0→1.2 + saturate 0.5→1.2
|
|
let t = (sin(elapsed * .pi * 2 / 12 - .pi / 2) + 1) / 2
|
|
content
|
|
.brightness(-0.7 + t * 0.9)
|
|
.saturation(0.5 + t * 0.7)
|
|
|
|
case .sunset:
|
|
// Inversion von sunrise
|
|
let t = (cos(elapsed * .pi * 2 / 12 - .pi / 2) + 1) / 2
|
|
content
|
|
.brightness(-0.7 + t * 0.9)
|
|
.saturation(0.5 + t * 0.7)
|
|
|
|
case .sparkle:
|
|
// 1.4s brightness puls
|
|
let t = (sin(elapsed * .pi * 2 / 1.4) + 1) / 2
|
|
content
|
|
.brightness(-0.05 + 0.45 * t)
|
|
.saturation(1.0 + 0.3 * t)
|
|
|
|
case .gradient:
|
|
// 8s subtle hue-rotation
|
|
let degrees = (elapsed.truncatingRemainder(dividingBy: 8.0)) / 8.0 * 30
|
|
content.hueRotation(.degrees(degrees))
|
|
}
|
|
}
|
|
}
|