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>
157 lines
4.4 KiB
Swift
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)
|
|
}
|
|
}
|