moodlit-native/Sources/Features/Settings/LIFXSettingsView.swift
till 78ef922ecb feat(smart-home): μ-9.5 LIFX-Adapter (Cloud-API)
Dritter Smart-Home-Treiber neben Hue + HomeKit. Cloud-API für V1
(simples Setup ohne Pair-Flow), LAN-UDP-Protocol als μ-9.5.1.

Was:
- Sources/Core/SmartHome/LIFXClient.swift Sendable struct:
  - listLights() gegen api.lifx.com/v1/lights/all mit Bearer-Token
  - setState(selector, on, hex, brightnessPct, durationSec) PUT
    /lights/:selector/state — LIFX-Cloud nimmt #rrggbb direkt + 0-1
    brightness float + duration in seconds
  - 401 → LIFXError.invalidToken (sauberer UX-Hint im SettingsView)
- LIFXController.swift @Observable @MainActor:
  - SetupState (notConfigured/validating/configured/failed)
  - Token-Storage im iOS-Keychain (analog Hue-AppKey,
    kSecAttrAccessibleAfterFirstUnlock)
  - Selected-Light-IDs in App-Group-UserDefaults
  - applyMood verteilt Farben zyklisch (analog HueController),
    durationSec=0.6 für smooth transitions
  - turnOffSelected() beim Player-Close
- LIFXSettingsView mit Token-Paste-Form (monospaced field),
  Validate-Button, Lampen-Picker gruppiert per Group, Reset
- SettingsView neuer NavigationLink LIFX in Smart-Home-Section
- MoodPlayerView pushToHue() ruft jetzt parallel hue+lifx+homekit;
  turnOffHomeLights analog

Trade-off explizit: ~200ms Cloud-Latenz vs. ~30ms Hue-LAN — Hue
bleibt der Beat-Sync-Pfad. LIFX nur für statische Mood-Wechsel
ausreichend; das ist im InfoSection vermerkt.

Build iOS+macOS BUILD SUCCEEDED, 18/18 Tests grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:19:28 +02:00

143 lines
3.7 KiB
Swift

import SwiftUI
/// Setup-UI für LIFX-Cloud-API. Token-Paste Validate Light-Picker.
public struct LIFXSettingsView: View {
@Environment(LIFXController.self) private var controller
@State private var tokenInput = ""
public init() {}
public var body: some View {
Form {
statusSection
if !isConfigured {
tokenSection
}
if !controller.availableLights.isEmpty {
lightsSection
}
if isConfigured {
resetSection
}
infoSection
}
.navigationTitle("LIFX")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
private var statusSection: some View {
Section {
switch controller.setupState {
case .notConfigured:
Label("Kein Token verbunden", systemImage: "lightbulb.slash")
.foregroundStyle(MoodlitTheme.mutedForeground)
case .validating:
HStack(spacing: 12) {
ProgressView()
Text("Token wird geprüft …")
}
case .configured(let count):
VStack(alignment: .leading, spacing: 4) {
Label("Verbunden", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(count) Lampe(n) ausgewählt")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
case .failed(let msg):
Label(msg, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.callout)
}
}
}
@ViewBuilder
private var tokenSection: some View {
Section("Personal Access Token") {
TextField("LIFX-Token einfügen", text: $tokenInput, axis: .vertical)
.lineLimit(2...4)
#if os(iOS)
.autocapitalization(.none)
#endif
.disableAutocorrection(true)
.font(.system(.body, design: .monospaced))
Button("Verbinden") {
Task { await controller.setToken(tokenInput) }
}
.disabled(tokenInput.trimmingCharacters(in: .whitespaces).isEmpty)
Text("Token erstellen auf cloud.lifx.com/settings — er bleibt im iOS-Keychain dieses Geräts, wird nicht an mana-Server geschickt.")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
@ViewBuilder
private var lightsSection: some View {
Section("Lampen") {
ForEach(controller.availableLights) { light in
Button {
controller.toggleSelected(lightId: light.id)
} label: {
HStack(spacing: 12) {
Image(systemName: controller.selectedLightIds.contains(light.id)
? "checkmark.circle.fill"
: "circle"
)
.foregroundStyle(
controller.selectedLightIds.contains(light.id)
? MoodlitTheme.primary
: MoodlitTheme.mutedForeground
)
VStack(alignment: .leading, spacing: 2) {
Text(light.label)
if let group = light.group {
Text(group.name)
.font(.caption2)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
Spacer()
Image(systemName: light.power == "on" ? "lightbulb.fill" : "lightbulb")
.foregroundStyle(light.power == "on" ? .yellow : MoodlitTheme.mutedForeground)
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
Button("Lampenliste aktualisieren") {
Task { await controller.refreshLights() }
}
.font(.caption)
}
}
@ViewBuilder
private var resetSection: some View {
Section {
Button(role: .destructive) {
controller.clearConfig()
tokenInput = ""
} label: {
Label("Token entfernen", systemImage: "trash")
}
}
}
@ViewBuilder
private var infoSection: some View {
Section {
Text("LIFX-Cloud-API hat ~200 ms Latenz (gegen ~30 ms Hue-LAN). Für Mood-Wechsel okay, für Beat-Sync nutz lieber Hue. LAN-Direkt-Steuerung kommt mit μ-9.5.1.")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
private var isConfigured: Bool {
if case .configured = controller.setupState { return true }
return false
}
}