You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1143 lines
40 KiB
1143 lines
40 KiB
# Smart Playlist Conditions Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Extend smart playlists to support structured metadata-based conditions (e.g. artist = "Alicia Keys", year > 2015) with a new condition builder sheet, while keeping the existing FTS search-based smart playlists working unchanged.
|
|
|
|
**Architecture:** Add a nullable `conditions: [SmartPlaylistCondition]?` column (stored as JSON TEXT) to the `smart_playlists` table via a new DB migration. When `conditions` is nil the existing FTS path runs; when non-nil a SQL WHERE clause is generated from the conditions. A new `SmartPlaylistBuilderSheet` view handles create and edit, wired into the app menu and playlist context menu.
|
|
|
|
**Tech Stack:** Swift, SwiftUI, GRDB 7.10.0, Swift Testing
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | File | Purpose |
|
|
|--------|------|---------|
|
|
| Create | `Music/Models/SmartPlaylistCondition.swift` | TrackField, ConditionOperator, ConditionValue, SmartPlaylistCondition |
|
|
| Modify | `Music/Models/SmartPlaylist.swift` | Add `conditions` property, update fixture |
|
|
| Modify | `Music/Services/DatabaseService.swift` | Migration v5, buildWhereClause, fetchTracks(conditions:), createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions |
|
|
| Modify | `Music/ViewModels/PlaylistViewModel.swift` | Branch observeSmartPlaylistTracks, add createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions |
|
|
| Create | `Music/Views/SmartPlaylistBuilderSheet.swift` | Condition builder sheet UI + ConditionRowView |
|
|
| Modify | `Music/Views/PlaylistBarView.swift` | Add onEditConditions callback, update context menu |
|
|
| Modify | `Music/ContentView.swift` | Add showSmartPlaylistBuilder binding, sheet, onEditConditions wiring |
|
|
| Modify | `Music/MusicApp.swift` | Add showSmartPlaylistBuilder state + "New Smart Playlist…" menu item |
|
|
| Modify | `MusicTests/SmartPlaylistTests.swift` | Tests for conditions model, query evaluation, JSON round-trip |
|
|
|
|
---
|
|
|
|
## Task 1: SmartPlaylistCondition Model
|
|
|
|
**Files:**
|
|
- Create: `Music/Models/SmartPlaylistCondition.swift`
|
|
- Test: `MusicTests/SmartPlaylistTests.swift`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `MusicTests/SmartPlaylistTests.swift`:
|
|
|
|
```swift
|
|
// Encodes and decodes a SmartPlaylistCondition to/from JSON,
|
|
// verifying that all fields survive the round-trip.
|
|
@Test func conditionCodableRoundTrip() throws {
|
|
let condition = SmartPlaylistCondition(
|
|
field: .artist,
|
|
op: .equals,
|
|
value: .string("Miles Davis")
|
|
)
|
|
let data = try JSONEncoder().encode(condition)
|
|
let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data)
|
|
#expect(decoded.field == .artist)
|
|
#expect(decoded.op == .equals)
|
|
if case .string(let s) = decoded.value {
|
|
#expect(s == "Miles Davis")
|
|
} else {
|
|
Issue.record("Expected string value")
|
|
}
|
|
}
|
|
|
|
// Encodes and decodes an array of conditions with mixed value types.
|
|
@Test func conditionsArrayCodableRoundTrip() throws {
|
|
let conditions: [SmartPlaylistCondition] = [
|
|
SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")),
|
|
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)),
|
|
SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0)))
|
|
]
|
|
let data = try JSONEncoder().encode(conditions)
|
|
let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data)
|
|
#expect(decoded.count == 3)
|
|
#expect(decoded[0].field == .artist)
|
|
#expect(decoded[1].op == .greaterThan)
|
|
if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") }
|
|
if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED" | head -20`
|
|
|
|
Expected: compile error — `SmartPlaylistCondition` not defined.
|
|
|
|
- [ ] **Step 3: Create `Music/Models/SmartPlaylistCondition.swift`**
|
|
|
|
```swift
|
|
import Foundation
|
|
|
|
// Classifies a track field for operator and UI purposes.
|
|
enum FieldType {
|
|
case string, int, double, date
|
|
}
|
|
|
|
// Represents a track column that can be filtered on.
|
|
// Raw value matches the SQLite column name in the "tracks" table.
|
|
enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable {
|
|
case title, artist, albumArtist, album, genre, composer, fileFormat
|
|
case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate
|
|
case fileSize, duration
|
|
case dateAdded, dateModified, lastPlayedAt
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .title: return "Title"
|
|
case .artist: return "Artist"
|
|
case .albumArtist: return "Album Artist"
|
|
case .album: return "Album"
|
|
case .genre: return "Genre"
|
|
case .composer: return "Composer"
|
|
case .fileFormat: return "File Format"
|
|
case .year: return "Year"
|
|
case .bpm: return "BPM"
|
|
case .rating: return "Rating"
|
|
case .playCount: return "Play Count"
|
|
case .trackNumber: return "Track Number"
|
|
case .discNumber: return "Disc Number"
|
|
case .bitrate: return "Bitrate"
|
|
case .sampleRate: return "Sample Rate"
|
|
case .fileSize: return "File Size"
|
|
case .duration: return "Duration"
|
|
case .dateAdded: return "Date Added"
|
|
case .dateModified: return "Date Modified"
|
|
case .lastPlayedAt: return "Last Played"
|
|
}
|
|
}
|
|
|
|
var fieldType: FieldType {
|
|
switch self {
|
|
case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat:
|
|
return .string
|
|
case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize:
|
|
return .int
|
|
case .duration:
|
|
return .double
|
|
case .dateAdded, .dateModified, .lastPlayedAt:
|
|
return .date
|
|
}
|
|
}
|
|
|
|
var validOperators: [ConditionOperator] {
|
|
switch fieldType {
|
|
case .string: return [.equals, .startsWith]
|
|
case .int, .double, .date: return [.equals, .greaterThan, .lessThan]
|
|
}
|
|
}
|
|
|
|
var defaultValue: ConditionValue {
|
|
switch fieldType {
|
|
case .string: return .string("")
|
|
case .int: return .int(0)
|
|
case .double: return .double(0)
|
|
case .date: return .date(Date())
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ConditionOperator: String, Codable, Identifiable, Sendable {
|
|
case equals
|
|
case startsWith
|
|
case greaterThan
|
|
case lessThan
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .equals: return "is"
|
|
case .startsWith: return "starts with"
|
|
case .greaterThan: return "is greater than"
|
|
case .lessThan: return "is less than"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tagged union storing the actual filter value with its type.
|
|
// Uses custom Codable to survive JSON round-trips cleanly.
|
|
enum ConditionValue: Equatable, Sendable {
|
|
case string(String)
|
|
case int(Int)
|
|
case double(Double)
|
|
case date(Date)
|
|
|
|
var isEmpty: Bool {
|
|
if case .string(let s) = self {
|
|
return s.trimmingCharacters(in: .whitespaces).isEmpty
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
extension ConditionValue: Codable {
|
|
private enum CodingKeys: String, CodingKey { case type, value }
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
switch self {
|
|
case .string(let s):
|
|
try container.encode("string", forKey: .type)
|
|
try container.encode(s, forKey: .value)
|
|
case .int(let i):
|
|
try container.encode("int", forKey: .type)
|
|
try container.encode(i, forKey: .value)
|
|
case .double(let d):
|
|
try container.encode("double", forKey: .type)
|
|
try container.encode(d, forKey: .value)
|
|
case .date(let date):
|
|
try container.encode("date", forKey: .type)
|
|
try container.encode(date.timeIntervalSince1970, forKey: .value)
|
|
}
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let type = try container.decode(String.self, forKey: .type)
|
|
switch type {
|
|
case "string":
|
|
self = .string(try container.decode(String.self, forKey: .value))
|
|
case "int":
|
|
self = .int(try container.decode(Int.self, forKey: .value))
|
|
case "double":
|
|
self = .double(try container.decode(Double.self, forKey: .value))
|
|
case "date":
|
|
self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value)))
|
|
default:
|
|
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)")
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable {
|
|
var field: TrackField
|
|
var op: ConditionOperator
|
|
var value: ConditionValue
|
|
|
|
var isEmpty: Bool { value.isEmpty }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip -only-testing:MusicTests/SmartPlaylistTests/conditionsArrayCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"`
|
|
|
|
Expected: both tests PASSED.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add Music/Models/SmartPlaylistCondition.swift MusicTests/SmartPlaylistTests.swift
|
|
git commit -m "feat: add SmartPlaylistCondition model with Codable types"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Extend SmartPlaylist Model
|
|
|
|
**Files:**
|
|
- Modify: `Music/Models/SmartPlaylist.swift`
|
|
- Modify: `MusicTests/SmartPlaylistTests.swift`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `MusicTests/SmartPlaylistTests.swift`:
|
|
|
|
```swift
|
|
// Creates a SmartPlaylist fixture with conditions and verifies the conditions
|
|
// field is preserved and the isSmartPlaylist flag is true.
|
|
@Test func smartPlaylistWithConditions() throws {
|
|
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
|
|
let sp = SmartPlaylist.fixture(conditions: conditions)
|
|
#expect(sp.conditions?.count == 1)
|
|
#expect(sp.conditions?[0].field == .artist)
|
|
#expect(sp.isSmartPlaylist == true)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/smartPlaylistWithConditions 2>&1 | grep -E "error:|FAILED|PASSED" | head -10`
|
|
|
|
Expected: compile error — fixture doesn't accept `conditions` parameter.
|
|
|
|
- [ ] **Step 3: Modify `Music/Models/SmartPlaylist.swift`**
|
|
|
|
Add `conditions` property to the struct and update the fixture. Replace the entire file content:
|
|
|
|
```swift
|
|
import Foundation
|
|
import GRDB
|
|
|
|
nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Sendable {
|
|
var id: Int64?
|
|
var name: String
|
|
var searchQuery: String
|
|
var createdAt: Date
|
|
var conditions: [SmartPlaylistCondition]?
|
|
}
|
|
|
|
nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord {
|
|
static let databaseTableName = "smart_playlists"
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
extension SmartPlaylist: PlaylistRepresentable {
|
|
var isSmartPlaylist: Bool { true }
|
|
}
|
|
|
|
#if DEBUG
|
|
extension SmartPlaylist {
|
|
static func fixture(
|
|
id: Int64? = nil,
|
|
name: String = "Test Smart Playlist",
|
|
searchQuery: String = "test query",
|
|
createdAt: Date = Date(),
|
|
conditions: [SmartPlaylistCondition]? = nil
|
|
) -> SmartPlaylist {
|
|
SmartPlaylist(
|
|
id: id, name: name, searchQuery: searchQuery,
|
|
createdAt: createdAt, conditions: conditions
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
```
|
|
|
|
> **Note on GRDB Codable synthesis:** GRDB 7 automatically encodes/decodes `[SmartPlaylistCondition]?` as a JSON TEXT column named "conditions" — no custom `init(row:)` or `encode(to:)` needed. Existing rows where the column is NULL decode as `nil`.
|
|
|
|
- [ ] **Step 4: Update the memberwise init call in DatabaseService**
|
|
|
|
In `Music/Services/DatabaseService.swift`, find the `createSmartPlaylist(name:searchQuery:)` method (line ~447) and update the init call:
|
|
|
|
Old:
|
|
```swift
|
|
var smartPlaylist = SmartPlaylist(
|
|
id: nil, name: name, searchQuery: searchQuery, createdAt: Date()
|
|
)
|
|
```
|
|
|
|
New:
|
|
```swift
|
|
var smartPlaylist = SmartPlaylist(
|
|
id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 5: Update the memberwise init call in SmartPlaylistTests**
|
|
|
|
In `MusicTests/SmartPlaylistTests.swift`, find `SmartPlaylistTests.smartPlaylistProperties()` (line ~8) and update:
|
|
|
|
Old:
|
|
```swift
|
|
let sp = SmartPlaylist(
|
|
id: nil,
|
|
name: "Miles Davis",
|
|
searchQuery: "miles davis",
|
|
createdAt: Date()
|
|
)
|
|
```
|
|
|
|
New:
|
|
```swift
|
|
let sp = SmartPlaylist(
|
|
id: nil,
|
|
name: "Miles Davis",
|
|
searchQuery: "miles davis",
|
|
createdAt: Date(),
|
|
conditions: nil
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 6: Run all SmartPlaylistTests to verify they pass**
|
|
|
|
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"`
|
|
|
|
Expected: all tests PASSED.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add Music/Models/SmartPlaylist.swift Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift
|
|
git commit -m "feat: add conditions field to SmartPlaylist model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: DB Migration + Query Evaluation
|
|
|
|
**Files:**
|
|
- Modify: `Music/Services/DatabaseService.swift`
|
|
- Test: `MusicTests/SmartPlaylistTests.swift`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Add to `MusicTests/SmartPlaylistTests.swift`:
|
|
|
|
```swift
|
|
// Creates an in-memory DB and verifies existing smart playlists (conditions = nil)
|
|
// still load correctly after the v5 migration adds the conditions column.
|
|
@Test func existingFTSPlaylistSurvivesMigration() throws {
|
|
// Step 1: Create DB (migration runs automatically, including v5)
|
|
// Step 2: Create a FTS smart playlist using the old searchQuery path
|
|
// Step 3: Fetch it back and verify conditions is nil
|
|
let db = try DatabaseService(inMemory: true)
|
|
let sp = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz")
|
|
let all = try db.fetchSmartPlaylists()
|
|
#expect(all.count == 1)
|
|
#expect(all[0].searchQuery == "jazz")
|
|
#expect(all[0].conditions == nil)
|
|
_ = sp
|
|
}
|
|
|
|
// Inserts tracks and verifies that fetchTracks(conditions:) with an equals
|
|
// condition on artist returns only the matching track.
|
|
@Test func fetchTracksWithEqualsCondition() throws {
|
|
// Step 1: Insert two tracks with different artists
|
|
// Step 2: Fetch with artist equals "Miles Davis"
|
|
// Step 3: Verify only one track is returned
|
|
let db = try DatabaseService(inMemory: true)
|
|
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis")
|
|
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles")
|
|
try db.insert(&t1)
|
|
try db.insert(&t2)
|
|
|
|
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
|
|
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
|
|
#expect(results.count == 1)
|
|
#expect(results[0].artist == "Miles Davis")
|
|
}
|
|
|
|
// Verifies that equals is case-insensitive for string fields.
|
|
@Test func fetchTracksEqualsIsCaseInsensitive() throws {
|
|
// Step 1: Insert a track with mixed-case artist
|
|
// Step 2: Fetch using lowercase artist value
|
|
// Step 3: Verify it matches
|
|
let db = try DatabaseService(inMemory: true)
|
|
var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis")
|
|
try db.insert(&t)
|
|
|
|
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))]
|
|
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
|
|
#expect(results.count == 1)
|
|
}
|
|
|
|
// Verifies that startsWith matches tracks whose artist begins with the given prefix,
|
|
// regardless of case.
|
|
@Test func fetchTracksWithStartsWithCondition() throws {
|
|
// Step 1: Insert tracks — one starting with "Miles", one not
|
|
// Step 2: Fetch with artist startsWith "miles"
|
|
// Step 3: Verify only the Miles Davis track is returned
|
|
let db = try DatabaseService(inMemory: true)
|
|
var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis")
|
|
var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles")
|
|
try db.insert(&t1)
|
|
try db.insert(&t2)
|
|
|
|
let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))]
|
|
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
|
|
#expect(results.count == 1)
|
|
#expect(results[0].artist == "Miles Davis")
|
|
}
|
|
|
|
// Verifies that greaterThan on an integer field returns tracks where the value
|
|
// strictly exceeds the condition value.
|
|
@Test func fetchTracksWithGreaterThanCondition() throws {
|
|
// Step 1: Insert tracks with years 1990, 2010, 2020
|
|
// Step 2: Fetch with year > 2000
|
|
// Step 3: Verify 2010 and 2020 are returned; 1990 is not
|
|
let db = try DatabaseService(inMemory: true)
|
|
var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990)
|
|
var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010)
|
|
var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020)
|
|
try db.insert(&t1)
|
|
try db.insert(&t2)
|
|
try db.insert(&t3)
|
|
|
|
let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))]
|
|
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
|
|
#expect(results.count == 2)
|
|
#expect(results.allSatisfy { ($0.year ?? 0) > 2000 })
|
|
}
|
|
|
|
// Verifies that multiple AND conditions filter correctly — only tracks matching
|
|
// all conditions are returned.
|
|
@Test func fetchTracksWithMultipleAndConditions() throws {
|
|
// Step 1: Insert three tracks: two Miles Davis (years 1959, 1970), one Eagles (1975)
|
|
// Step 2: Fetch with artist = "Miles Davis" AND year > 1960
|
|
// Step 3: Verify only the 1970 Miles Davis track is returned
|
|
let db = try DatabaseService(inMemory: true)
|
|
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959)
|
|
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970)
|
|
var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975)
|
|
try db.insert(&t1)
|
|
try db.insert(&t2)
|
|
try db.insert(&t3)
|
|
|
|
let conditions = [
|
|
SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")),
|
|
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960))
|
|
]
|
|
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
|
|
#expect(results.count == 1)
|
|
#expect(results[0].title == "Bitches Brew")
|
|
}
|
|
|
|
// Creates a smart playlist with structured conditions, fetches it back, and
|
|
// verifies the conditions survive the JSON round-trip through the database.
|
|
@Test func createSmartPlaylistWithConditionsPersists() throws {
|
|
// Step 1: Create DB and build a conditions-based smart playlist
|
|
// Step 2: Fetch all smart playlists
|
|
// Step 3: Verify conditions, field, operator, and value round-tripped correctly
|
|
let db = try DatabaseService(inMemory: true)
|
|
let conditions = [
|
|
SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")),
|
|
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960))
|
|
]
|
|
_ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions)
|
|
|
|
let all = try db.fetchSmartPlaylists()
|
|
#expect(all.count == 1)
|
|
#expect(all[0].conditions?.count == 2)
|
|
#expect(all[0].conditions?[0].field == .artist)
|
|
#expect(all[0].conditions?[1].op == .greaterThan)
|
|
if case .int(let y) = all[0].conditions?[1].value {
|
|
#expect(y == 1960)
|
|
} else {
|
|
Issue.record("Expected int value")
|
|
}
|
|
}
|
|
|
|
// Updates the conditions of a structured smart playlist and verifies the new
|
|
// conditions are persisted.
|
|
@Test func updateSmartPlaylistConditions() throws {
|
|
// Step 1: Create a conditions-based playlist
|
|
// Step 2: Update its conditions to a different set
|
|
// Step 3: Fetch and verify the updated conditions
|
|
let db = try DatabaseService(inMemory: true)
|
|
let sp = try db.createSmartPlaylist(
|
|
name: "Test",
|
|
conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))]
|
|
)
|
|
let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))]
|
|
try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions)
|
|
|
|
let all = try db.fetchSmartPlaylists()
|
|
#expect(all[0].conditions?.count == 1)
|
|
#expect(all[0].conditions?[0].field == .genre)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/fetchTracksWithEqualsCondition 2>&1 | grep -E "error:|FAILED|PASSED" | head -10`
|
|
|
|
Expected: compile error — `fetchTracks(conditions:)` not defined.
|
|
|
|
- [ ] **Step 3: Add migration v5 and query methods to `Music/Services/DatabaseService.swift`**
|
|
|
|
**3a — Add migration v5** after the `"v4-drop-artworkData"` migration (before `try migrator.migrate(db)`):
|
|
|
|
```swift
|
|
migrator.registerMigration("v5-add-smart-playlist-conditions") { db in
|
|
try db.alter(table: "smart_playlists") { t in
|
|
t.add(column: "conditions", .text)
|
|
}
|
|
}
|
|
```
|
|
|
|
**3b — Add `buildWhereClause` private method** to `DatabaseService` (place in the `// MARK: - Smart Playlists` section):
|
|
|
|
```swift
|
|
private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) {
|
|
guard !conditions.isEmpty else { return ("", StatementArguments()) }
|
|
var fragments: [String] = []
|
|
var args: [DatabaseValueConvertible?] = []
|
|
|
|
for condition in conditions {
|
|
let col = condition.field.rawValue
|
|
switch (condition.op, condition.value) {
|
|
case (.equals, .string(let s)):
|
|
fragments.append("LOWER(\(col)) = LOWER(?)")
|
|
args.append(s)
|
|
case (.startsWith, .string(let s)):
|
|
fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%'")
|
|
args.append(s)
|
|
case (.equals, .int(let i)):
|
|
fragments.append("\(col) = ?"); args.append(i)
|
|
case (.greaterThan, .int(let i)):
|
|
fragments.append("\(col) > ?"); args.append(i)
|
|
case (.lessThan, .int(let i)):
|
|
fragments.append("\(col) < ?"); args.append(i)
|
|
case (.equals, .double(let d)):
|
|
fragments.append("\(col) = ?"); args.append(d)
|
|
case (.greaterThan, .double(let d)):
|
|
fragments.append("\(col) > ?"); args.append(d)
|
|
case (.lessThan, .double(let d)):
|
|
fragments.append("\(col) < ?"); args.append(d)
|
|
case (.equals, .date(let date)):
|
|
fragments.append("\(col) = ?"); args.append(date)
|
|
case (.greaterThan, .date(let date)):
|
|
fragments.append("\(col) > ?"); args.append(date)
|
|
case (.lessThan, .date(let date)):
|
|
fragments.append("\(col) < ?"); args.append(date)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return (fragments.joined(separator: " AND "), StatementArguments(args))
|
|
}
|
|
```
|
|
|
|
**3c — Add public `fetchTracks(conditions:)` methods** after the existing `fetchTracks(db:search:sortColumn:ascending:)` method:
|
|
|
|
```swift
|
|
func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
|
|
try dbPool.read { db in
|
|
try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending)
|
|
}
|
|
}
|
|
|
|
func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
|
|
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title"
|
|
let order = ascending ? "ASC" : "DESC"
|
|
let (whereSQL, args) = buildWhereClause(conditions)
|
|
if whereSQL.isEmpty {
|
|
return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)")
|
|
}
|
|
return try Track.fetchAll(
|
|
db,
|
|
sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)",
|
|
arguments: args
|
|
)
|
|
}
|
|
```
|
|
|
|
**3d — Add `createSmartPlaylist(name:conditions:)` overload** after the existing `createSmartPlaylist(name:searchQuery:)` method:
|
|
|
|
```swift
|
|
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist {
|
|
try dbPool.write { db in
|
|
var smartPlaylist = SmartPlaylist(
|
|
id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions
|
|
)
|
|
try smartPlaylist.insert(db)
|
|
return smartPlaylist
|
|
}
|
|
}
|
|
```
|
|
|
|
**3e — Add `updateSmartPlaylistConditions(id:conditions:)`** after `updateSmartPlaylistQuery`:
|
|
|
|
```swift
|
|
func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws {
|
|
let json: String?
|
|
if let data = try? JSONEncoder().encode(conditions) {
|
|
json = String(data: data, encoding: .utf8)
|
|
} else {
|
|
json = nil
|
|
}
|
|
try dbPool.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?",
|
|
arguments: [json, id]
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run all new tests**
|
|
|
|
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"`
|
|
|
|
Expected: all tests PASSED.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift
|
|
git commit -m "feat: add migration v5 and structured condition query support to DatabaseService"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: PlaylistViewModel — Branch on Conditions
|
|
|
|
**Files:**
|
|
- Modify: `Music/ViewModels/PlaylistViewModel.swift`
|
|
|
|
No separate test — the DB-level tests in Task 3 cover the query logic; the ViewModel wiring is validated by running the app in Task 7.
|
|
|
|
- [ ] **Step 1: Rename and update `observeSmartPlaylistTracks`**
|
|
|
|
Replace the existing `observeSmartPlaylistTracks(searchQuery:)` private method with a new signature that takes the full `SmartPlaylist`:
|
|
|
|
Old signature: `private func observeSmartPlaylistTracks(searchQuery: String)`
|
|
|
|
New implementation (replace the entire method):
|
|
|
|
```swift
|
|
private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) {
|
|
tracksCancellable?.cancel()
|
|
let col = sortColumn
|
|
let asc = sortAscending
|
|
|
|
if let conditions = smartPlaylist.conditions {
|
|
let observation = ValueObservation.tracking { [db] dbAccess in
|
|
try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc)
|
|
}
|
|
tracksCancellable = observation.start(
|
|
in: db.dbPool,
|
|
onError: { error in print("Smart playlist tracks observation error: \(error)") },
|
|
onChange: { [weak self] tracks in self?.playlistTracks = tracks }
|
|
)
|
|
} else {
|
|
let searchQuery = smartPlaylist.searchQuery
|
|
let observation = ValueObservation.tracking { [db] dbAccess in
|
|
try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc)
|
|
}
|
|
tracksCancellable = observation.start(
|
|
in: db.dbPool,
|
|
onError: { error in print("Smart playlist tracks observation error: \(error)") },
|
|
onChange: { [weak self] tracks in self?.playlistTracks = tracks }
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update all call sites of `observeSmartPlaylistTracks`**
|
|
|
|
**In `selectItem`**, change:
|
|
```swift
|
|
} else if let smart = item as? SmartPlaylist {
|
|
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
|
|
}
|
|
```
|
|
to:
|
|
```swift
|
|
} else if let smart = item as? SmartPlaylist {
|
|
observeSmartPlaylistTracks(for: smart)
|
|
}
|
|
```
|
|
|
|
**In `updateSmartPlaylistQuery`**, change:
|
|
```swift
|
|
if selectedSmartPlaylist?.id == id {
|
|
observeSmartPlaylistTracks(searchQuery: query)
|
|
}
|
|
```
|
|
to:
|
|
```swift
|
|
if selectedSmartPlaylist?.id == id {
|
|
var updated = smartPlaylist
|
|
updated.searchQuery = query
|
|
updated.conditions = nil
|
|
observeSmartPlaylistTracks(for: updated)
|
|
}
|
|
```
|
|
|
|
**In `sort`**, change:
|
|
```swift
|
|
if let smart = selectedSmartPlaylist {
|
|
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
|
|
}
|
|
```
|
|
to:
|
|
```swift
|
|
if let smart = selectedSmartPlaylist {
|
|
observeSmartPlaylistTracks(for: smart)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `createSmartPlaylist(name:conditions:)` to PlaylistViewModel**
|
|
|
|
Add after the existing `createSmartPlaylist(searchQuery:)` method:
|
|
|
|
```swift
|
|
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws {
|
|
_ = try db.createSmartPlaylist(name: name, conditions: conditions)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add `updateSmartPlaylistConditions(_:to:)` to PlaylistViewModel**
|
|
|
|
Add after `updateSmartPlaylistQuery`:
|
|
|
|
```swift
|
|
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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify the project still builds**
|
|
|
|
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
|
|
|
|
Expected: `BUILD SUCCEEDED`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add Music/ViewModels/PlaylistViewModel.swift
|
|
git commit -m "feat: branch PlaylistViewModel on conditions for structured smart playlist observation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: SmartPlaylistBuilderSheet UI
|
|
|
|
**Files:**
|
|
- Create: `Music/Views/SmartPlaylistBuilderSheet.swift`
|
|
|
|
- [ ] **Step 1: Create `Music/Views/SmartPlaylistBuilderSheet.swift`**
|
|
|
|
```swift
|
|
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(.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(canRemove ? .secondary : .secondary.opacity(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()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the project builds**
|
|
|
|
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
|
|
|
|
Expected: `BUILD SUCCEEDED`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add Music/Views/SmartPlaylistBuilderSheet.swift
|
|
git commit -m "feat: add SmartPlaylistBuilderSheet with ConditionRowView"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: PlaylistBarView — Context Menu Update
|
|
|
|
**Files:**
|
|
- Modify: `Music/Views/PlaylistBarView.swift`
|
|
|
|
- [ ] **Step 1: Add `onEditConditions` callback and update the context menu**
|
|
|
|
In `Music/Views/PlaylistBarView.swift`, add the new callback property after `onEditQuery`:
|
|
|
|
```swift
|
|
var onEditConditions: (SmartPlaylist) -> Void
|
|
```
|
|
|
|
Then update the context menu block (currently lines 38-46):
|
|
|
|
Old:
|
|
```swift
|
|
.contextMenu {
|
|
if !isRemoteMode {
|
|
Button("Rename...") { onRename(item) }
|
|
if let smart = item as? SmartPlaylist {
|
|
Button("Edit Search Query...") { onEditQuery(smart) }
|
|
}
|
|
Button("Delete") { onDelete(item) }
|
|
}
|
|
}
|
|
```
|
|
|
|
New:
|
|
```swift
|
|
.contextMenu {
|
|
if !isRemoteMode {
|
|
Button("Rename...") { onRename(item) }
|
|
if let smart = item as? SmartPlaylist {
|
|
if smart.conditions != nil {
|
|
Button("Edit...") { onEditConditions(smart) }
|
|
} else {
|
|
Button("Edit Search Query...") { onEditQuery(smart) }
|
|
}
|
|
}
|
|
Button("Delete") { onDelete(item) }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the project builds (PlaylistBarView call site in ContentView will fail — expected)**
|
|
|
|
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
|
|
|
|
Expected: BUILD FAILED with a missing `onEditConditions` argument error in `ContentView.swift` — this is the compile error that Task 7 will fix.
|
|
|
|
- [ ] **Step 3: Do NOT commit yet — commit together with Task 7**
|
|
|
|
---
|
|
|
|
## Task 7: ContentView + MusicApp Wiring
|
|
|
|
**Files:**
|
|
- Modify: `Music/ContentView.swift`
|
|
- Modify: `Music/MusicApp.swift`
|
|
|
|
- [ ] **Step 1: Add binding and sheet states to ContentView**
|
|
|
|
In `Music/ContentView.swift`, add a new `@Binding` parameter for the smart playlist builder alongside the existing `showNewPlaylistAlert` binding:
|
|
|
|
Add after `@Binding var showNewPlaylistAlert: Bool`:
|
|
```swift
|
|
@Binding var showSmartPlaylistBuilder: Bool
|
|
```
|
|
|
|
Add to the `@State` block (after `showEditQueryAlert`):
|
|
```swift
|
|
@State private var smartPlaylistBuilderEditing: SmartPlaylist?
|
|
```
|
|
|
|
- [ ] **Step 2: Pass `onEditConditions` to PlaylistBarView in ContentView**
|
|
|
|
Find the `PlaylistBarView(...)` call in `ContentView.body` and add the missing `onEditConditions` argument after `onEditQuery`:
|
|
|
|
```swift
|
|
onEditConditions: { smart in
|
|
smartPlaylistBuilderEditing = smart
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add sheets to ContentView**
|
|
|
|
Add two `.sheet` modifiers after the existing `.alert` modifiers at the bottom of `ContentView.body`:
|
|
|
|
```swift
|
|
.sheet(isPresented: $showSmartPlaylistBuilder) {
|
|
SmartPlaylistBuilderSheet(
|
|
editingPlaylist: nil,
|
|
onSave: { name, conditions in
|
|
try? playlist.createSmartPlaylist(name: name, conditions: conditions)
|
|
showSmartPlaylistBuilder = false
|
|
},
|
|
onCancel: { showSmartPlaylistBuilder = false }
|
|
)
|
|
}
|
|
.sheet(item: $smartPlaylistBuilderEditing) { smart in
|
|
SmartPlaylistBuilderSheet(
|
|
editingPlaylist: smart,
|
|
onSave: { name, conditions in
|
|
if name != smart.name {
|
|
try? playlist.renameSmartPlaylist(smart, to: name)
|
|
}
|
|
try? playlist.updateSmartPlaylistConditions(smart, to: conditions)
|
|
smartPlaylistBuilderEditing = nil
|
|
},
|
|
onCancel: { smartPlaylistBuilderEditing = nil }
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add `showSmartPlaylistBuilder` state and menu item to MusicApp**
|
|
|
|
In `Music/MusicApp.swift`:
|
|
|
|
Add state after `showNewPlaylistAlert`:
|
|
```swift
|
|
@State private var showSmartPlaylistBuilder = false
|
|
```
|
|
|
|
Pass it to ContentView — in the `ContentView(...)` initializer call, add after `showNewPlaylistAlert: $showNewPlaylistAlert`:
|
|
```swift
|
|
showSmartPlaylistBuilder: $showSmartPlaylistBuilder,
|
|
```
|
|
|
|
Add menu item after `"New Playlist..."` button in the `.commands` block:
|
|
```swift
|
|
Button("New Smart Playlist...") {
|
|
showSmartPlaylistBuilder = true
|
|
}
|
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
|
.disabled(remoteClient.connectionState.isConnected)
|
|
```
|
|
|
|
- [ ] **Step 5: Verify the project builds**
|
|
|
|
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
|
|
|
|
Expected: `BUILD SUCCEEDED`
|
|
|
|
- [ ] **Step 6: Run all tests**
|
|
|
|
Run: `xcodebuild test -scheme Music 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed" | tail -20`
|
|
|
|
Expected: all tests PASSED.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add Music/Views/PlaylistBarView.swift Music/ContentView.swift Music/MusicApp.swift
|
|
git commit -m "feat: wire SmartPlaylistBuilderSheet into menu and context menu"
|
|
```
|
|
|