moodlit-native/Sources/Features/Moods/CreateMoodSheet.swift
till 07bc650170 μ-7.1: Funktionierende Hex-Color-Picker + 11 Unit-Tests
CreateMoodSheet's Hex-Picker war broken — `hexColors`-Getter retournierte
hardcoded `#7c3aed` für jede Farbe statt der gewählten. Custom-Moods
landeten so alle als 3× lila in der DB.

Fix:
- HexColor.swift: `Color.toHexString()` über PlatformColor (UIColor
  iOS / NSColor macOS) — sRGB-Roundtrip mit Komponenten-Extraktion.
- CreateMoodSheet: `[String]` (Hex) als SOT statt `[Color]`. ColorPicker
  binded über konvertierende `Binding<Color>` (Get: Hex→Color, Set:
  Color→Hex). Zusätzlich TextField pro Farbe für direkten Hex-Input
  (lowercase, mit/ohne `#`, validiert auf 6/8 Hex-Chars).
- `+ Farbe hinzufügen` generiert random Hex statt fixem Lila.

Tests/UnitTests/ neu:
- Decode-Roundtrip Mood (mit + ohne userId), MoodSequence, Preferences
  gegen API-Wire-Format aus moodlit-api Routen.
- AnimationSpeed-Enum akzeptiert alle 3 Wire-Werte (slow/normal/fast).
- DefaultMoods.all hat 24 Einträge, alle isPreset, unique IDs, deckt
  bekannte Slugs ab (fire, breath, ocean, sunrise, sunset, matrix),
  alle referenzierten Animation-Werte sind im Enum.
- HexColor.Color(hex:) smoke (#prefix, lowercase/uppercase, 8-char).

11/11 Tests grün via xcodebuild iOS-Sim. project.yml um
MoodlitNativeUnitTests-Target (bundle.unit-test, iOS-only, Bundle-ID
ev.mana.moodlit.tests) erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:10:31 +02:00

157 lines
4.4 KiB
Swift

import SwiftUI
/// Form zum Erstellen eines neuen Custom-Mood. Pendant zum
/// Inline-Create-Form in `Code/moodlit/apps/web/src/routes/+page.svelte`.
///
/// Speichert Farben als `[String]` (Hex-Strings) als SOT SwiftUI
/// `ColorPicker` binded über eine konvertierende `Binding<Color>`,
/// damit der gerade gewählte Hex-Wert wirklich an die API geht
/// (Web-Parity: `["#7c3aed", "#a78bfa", "#c4b5fd"]` etc).
public struct CreateMoodSheet: View {
let onCreate: (_ name: String, _ colors: [String], _ animation: AnimationType) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var hexColors: [String] = ["#7c3aed", "#a78bfa", "#c4b5fd"]
@State private var animation: AnimationType = .gradient
public init(onCreate: @escaping (_ name: String, _ colors: [String], _ animation: AnimationType) -> Void) {
self.onCreate = onCreate
}
public var body: some View {
NavigationStack {
Form {
Section("Name") {
nameField
}
Section("Animation") {
Picker("Animation", selection: $animation) {
ForEach(AnimationType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
.pickerStyle(.menu)
}
Section("Farben (\(hexColors.count))") {
ForEach(hexColors.indices, id: \.self) { index in
colorRow(index: index)
}
if hexColors.count < 8 {
Button("Farbe hinzufügen", systemImage: "plus.circle") {
hexColors.append(randomHex())
}
}
}
Section("Vorschau") {
previewBar
.frame(height: 80)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.listRowInsets(EdgeInsets())
}
}
.navigationTitle("Neues Mood")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Erstellen") {
onCreate(name.trimmingCharacters(in: .whitespacesAndNewlines), hexColors, animation)
}
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
@ViewBuilder
private var nameField: some View {
#if os(iOS)
TextField("Stimmungs-Name", text: $name)
.textInputAutocapitalization(.words)
#else
TextField("Stimmungs-Name", text: $name)
#endif
}
@ViewBuilder
private func colorRow(index: Int) -> some View {
HStack {
ColorPicker("", selection: colorBinding(for: index), supportsOpacity: false)
.labelsHidden()
.frame(width: 36, height: 36)
TextField("Hex", text: hexFieldBinding(for: index))
.font(.system(.body, design: .monospaced))
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
.frame(maxWidth: .infinity, alignment: .leading)
if hexColors.count > 1 {
Button {
hexColors.remove(at: index)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
}
/// SwiftUI-`ColorPicker` Hex-String-SOT.
/// Get: Hex Color; Set: Color Hex (Fallback: alten Hex behalten).
private func colorBinding(for index: Int) -> Binding<Color> {
Binding<Color>(
get: { Color(hex: hexColors[index]) },
set: { newColor in
if let hex = newColor.toHexString() {
hexColors[index] = hex
}
}
)
}
/// Direkt-Eingabe für Power-User. Validiert auf 6 oder 8 Hex-Chars
/// (mit oder ohne `#`) bei Commit; Zwischenzustände bleiben im
/// TextField sichtbar.
private func hexFieldBinding(for index: Int) -> Binding<String> {
Binding<String>(
get: { hexColors[index] },
set: { newValue in
var s = newValue.trimmingCharacters(in: .whitespaces)
if !s.hasPrefix("#") { s = "#" + s }
// Nur committen wenn parsebar; sonst Anzeige im Feld
// behalten aber State nicht zerschießen.
let body = s.dropFirst()
let valid = (body.count == 6 || body.count == 8)
&& body.allSatisfy { $0.isHexDigit }
hexColors[index] = valid ? s.lowercased() : s
}
)
}
private var previewBar: some View {
LinearGradient(
colors: hexColors.map { Color(hex: $0) },
startPoint: .leading,
endPoint: .trailing
)
}
private func randomHex() -> String {
let r = Int.random(in: 0...255)
let g = Int.random(in: 0...255)
let b = Int.random(in: 0...255)
return String(format: "#%02x%02x%02x", r, g, b)
}
}