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