feat: add SmartPlaylistBuilderSheet with ConditionRowView

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
Laurent 1 month ago
parent 1dff7cb5d1
commit 9eb47b61e1
  1. 35
      Music/ViewModels/PlaylistViewModel.swift
  2. 149
      Music/Views/SmartPlaylistBuilderSheet.swift

@ -102,6 +102,10 @@ final class PlaylistViewModel {
_ = try db.createSmartPlaylist(name: name, searchQuery: searchQuery) _ = try db.createSmartPlaylist(name: name, searchQuery: searchQuery)
} }
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws {
_ = try db.createSmartPlaylist(name: name, conditions: conditions)
}
func renameSmartPlaylist(_ smartPlaylist: SmartPlaylist, to name: String) throws { func renameSmartPlaylist(_ smartPlaylist: SmartPlaylist, to name: String) throws {
guard let id = smartPlaylist.id else { return } guard let id = smartPlaylist.id else { return }
try db.renameSmartPlaylist(id: id, name: name) try db.renameSmartPlaylist(id: id, name: name)
@ -111,7 +115,20 @@ final class PlaylistViewModel {
guard let id = smartPlaylist.id else { return } guard let id = smartPlaylist.id else { return }
try db.updateSmartPlaylistQuery(id: id, searchQuery: query) try db.updateSmartPlaylistQuery(id: id, searchQuery: query)
if selectedSmartPlaylist?.id == id { if selectedSmartPlaylist?.id == id {
observeSmartPlaylistTracks(searchQuery: query) var updated = smartPlaylist
updated.searchQuery = query
updated.conditions = nil
observeSmartPlaylistTracks(for: updated)
}
}
func updateSmartPlaylistConditions(_ smartPlaylist: SmartPlaylist, to conditions: [SmartPlaylistCondition]) throws {
guard let id = smartPlaylist.id else { return }
try db.updateSmartPlaylistConditions(id: id, conditions: conditions)
if selectedSmartPlaylist?.id == id {
var updated = smartPlaylist
updated.conditions = conditions
observeSmartPlaylistTracks(for: updated)
} }
} }
@ -130,7 +147,7 @@ final class PlaylistViewModel {
if item is Playlist { if item is Playlist {
observePlaylistTracks() observePlaylistTracks()
} else if let smart = item as? SmartPlaylist { } else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery) observeSmartPlaylistTracks(for: smart)
} }
} }
@ -150,7 +167,7 @@ final class PlaylistViewModel {
sortAscending = true sortAscending = true
} }
if let smart = selectedSmartPlaylist { if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery) observeSmartPlaylistTracks(for: smart)
} }
} }
@ -215,14 +232,22 @@ final class PlaylistViewModel {
) )
} }
private func observeSmartPlaylistTracks(searchQuery: String) { private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) {
tracksCancellable?.cancel() tracksCancellable?.cancel()
let col = sortColumn let col = sortColumn
let asc = sortAscending let asc = sortAscending
let observation = ValueObservation.tracking { [db] dbAccess in let observation: ValueObservation<ValueReducers.Fetch<[Track]>>
if let conditions = smartPlaylist.conditions {
observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc)
}
} else {
let searchQuery = smartPlaylist.searchQuery
observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc) try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc)
} }
}
tracksCancellable = observation.start( tracksCancellable = observation.start(
in: db.dbPool, in: db.dbPool,
onError: { error in print("Smart playlist tracks observation error: \(error)") }, onError: { error in print("Smart playlist tracks observation error: \(error)") },

@ -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…
Cancel
Save