Also fixes two pre-existing call sites in PlaylistViewModel that still used the old observeSmartPlaylistTracks(searchQuery:) label after it was renamed to observeSmartPlaylistTracks(for:) in a prior task.feat/music-streaming
parent
1dff7cb5d1
commit
9eb47b61e1
@ -0,0 +1,149 @@ |
|||||||
|
import SwiftUI |
||||||
|
|
||||||
|
struct SmartPlaylistBuilderSheet: View { |
||||||
|
var editingPlaylist: SmartPlaylist? |
||||||
|
var onSave: (String, [SmartPlaylistCondition]) -> Void |
||||||
|
var onCancel: () -> Void |
||||||
|
|
||||||
|
@State private var name: String |
||||||
|
@State private var conditions: [SmartPlaylistCondition] |
||||||
|
|
||||||
|
init( |
||||||
|
editingPlaylist: SmartPlaylist? = nil, |
||||||
|
onSave: @escaping (String, [SmartPlaylistCondition]) -> Void, |
||||||
|
onCancel: @escaping () -> Void |
||||||
|
) { |
||||||
|
self.editingPlaylist = editingPlaylist |
||||||
|
self.onSave = onSave |
||||||
|
self.onCancel = onCancel |
||||||
|
let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")) |
||||||
|
_name = State(initialValue: editingPlaylist?.name ?? "") |
||||||
|
_conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition]) |
||||||
|
} |
||||||
|
|
||||||
|
private var canSave: Bool { |
||||||
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && |
||||||
|
conditions.allSatisfy { !$0.isEmpty } |
||||||
|
} |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
VStack(alignment: .leading, spacing: 16) { |
||||||
|
Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist") |
||||||
|
.font(.headline) |
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) { |
||||||
|
Text("Name") |
||||||
|
.font(.caption) |
||||||
|
.foregroundStyle(.secondary) |
||||||
|
TextField("Playlist name", text: $name) |
||||||
|
.textFieldStyle(.roundedBorder) |
||||||
|
} |
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) { |
||||||
|
Text("Conditions (all must match)") |
||||||
|
.font(.caption) |
||||||
|
.foregroundStyle(.secondary) |
||||||
|
|
||||||
|
ForEach(conditions.indices, id: \.self) { index in |
||||||
|
ConditionRowView( |
||||||
|
condition: $conditions[index], |
||||||
|
canRemove: conditions.count > 1, |
||||||
|
onRemove: { conditions.remove(at: index) } |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
Button("+ Add Condition") { |
||||||
|
conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))) |
||||||
|
} |
||||||
|
.buttonStyle(.plain) |
||||||
|
.foregroundStyle(Color.accentColor) |
||||||
|
.font(.system(size: 12)) |
||||||
|
} |
||||||
|
|
||||||
|
Divider() |
||||||
|
|
||||||
|
HStack { |
||||||
|
Spacer() |
||||||
|
Button("Cancel", action: onCancel) |
||||||
|
Button("Save") { |
||||||
|
onSave(name.trimmingCharacters(in: .whitespaces), conditions) |
||||||
|
} |
||||||
|
.disabled(!canSave) |
||||||
|
.keyboardShortcut(.defaultAction) |
||||||
|
} |
||||||
|
} |
||||||
|
.padding(20) |
||||||
|
.frame(width: 540) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private struct ConditionRowView: View { |
||||||
|
@Binding var condition: SmartPlaylistCondition |
||||||
|
var canRemove: Bool |
||||||
|
var onRemove: () -> Void |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
HStack(spacing: 8) { |
||||||
|
Picker("", selection: $condition.field) { |
||||||
|
ForEach(TrackField.allCases) { field in |
||||||
|
Text(field.displayName).tag(field) |
||||||
|
} |
||||||
|
} |
||||||
|
.labelsHidden() |
||||||
|
.frame(maxWidth: 130) |
||||||
|
.onChange(of: condition.field) { _, newField in |
||||||
|
condition.op = newField.validOperators[0] |
||||||
|
condition.value = newField.defaultValue |
||||||
|
} |
||||||
|
|
||||||
|
Picker("", selection: $condition.op) { |
||||||
|
ForEach(condition.field.validOperators) { op in |
||||||
|
Text(op.displayName).tag(op) |
||||||
|
} |
||||||
|
} |
||||||
|
.labelsHidden() |
||||||
|
.frame(maxWidth: 130) |
||||||
|
|
||||||
|
valueField |
||||||
|
|
||||||
|
Button(action: onRemove) { |
||||||
|
Image(systemName: "minus.circle.fill") |
||||||
|
.foregroundStyle(Color.secondary.opacity(canRemove ? 1.0 : 0.3)) |
||||||
|
} |
||||||
|
.buttonStyle(.plain) |
||||||
|
.disabled(!canRemove) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@ViewBuilder |
||||||
|
private var valueField: some View { |
||||||
|
switch condition.field.fieldType { |
||||||
|
case .string: |
||||||
|
TextField("Value", text: Binding( |
||||||
|
get: { if case .string(let s) = condition.value { return s } else { return "" } }, |
||||||
|
set: { condition.value = .string($0) } |
||||||
|
)) |
||||||
|
.textFieldStyle(.roundedBorder) |
||||||
|
case .int: |
||||||
|
TextField("Value", text: Binding( |
||||||
|
get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } }, |
||||||
|
set: { condition.value = .int(Int($0) ?? 0) } |
||||||
|
)) |
||||||
|
.textFieldStyle(.roundedBorder) |
||||||
|
.frame(maxWidth: 100) |
||||||
|
case .double: |
||||||
|
TextField("Value", text: Binding( |
||||||
|
get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } }, |
||||||
|
set: { condition.value = .double(Double($0) ?? 0) } |
||||||
|
)) |
||||||
|
.textFieldStyle(.roundedBorder) |
||||||
|
.frame(maxWidth: 100) |
||||||
|
case .date: |
||||||
|
DatePicker("", selection: Binding( |
||||||
|
get: { if case .date(let d) = condition.value { return d } else { return Date() } }, |
||||||
|
set: { condition.value = .date($0) } |
||||||
|
), displayedComponents: .date) |
||||||
|
.labelsHidden() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue