Add instrument family filtering
This commit is contained in:
@@ -5,6 +5,7 @@ struct InstrumentBrowserView: View {
|
|||||||
private let catalog: LocalInstrumentCatalog
|
private let catalog: LocalInstrumentCatalog
|
||||||
|
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
|
@State private var selectedFamilyCategory: String?
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
init(catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()) {
|
init(catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()) {
|
||||||
@@ -13,23 +14,44 @@ struct InstrumentBrowserView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List(catalog.instruments(matching: searchText), id: \.id) { instrument in
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Picker("Family", selection: $selectedFamilyCategory) {
|
||||||
Text(instrument.name)
|
Text("All families").tag(String?.none)
|
||||||
.font(.body.weight(.medium))
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
ForEach(catalog.familyCategories, id: \.self) { familyCategory in
|
||||||
Text(instrument.familyCategory)
|
Text(familyCategory).tag(Optional(familyCategory))
|
||||||
|
|
||||||
if let regionOrigin = instrument.regionOrigin {
|
|
||||||
Text(regionOrigin)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.pickerStyle(.menu)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
List(
|
||||||
|
catalog.instruments(
|
||||||
|
matching: searchText,
|
||||||
|
familyCategory: selectedFamilyCategory
|
||||||
|
),
|
||||||
|
id: \.id
|
||||||
|
) { instrument in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(instrument.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(instrument.familyCategory)
|
||||||
|
|
||||||
|
if let regionOrigin = instrument.regionOrigin {
|
||||||
|
Text(regionOrigin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Instrument Catalog")
|
.navigationTitle("Instrument Catalog")
|
||||||
.searchable(text: $searchText, prompt: "Search instruments")
|
.searchable(text: $searchText, prompt: "Search instruments")
|
||||||
|
|||||||
@@ -7,14 +7,25 @@ public struct LocalInstrumentCatalog: Sendable {
|
|||||||
self.items = items
|
self.items = items
|
||||||
}
|
}
|
||||||
|
|
||||||
public func instruments(matching query: String) -> [InstrumentCatalogItem] {
|
public var familyCategories: [String] {
|
||||||
|
Array(Set(items.map(\.familyCategory)))
|
||||||
|
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func instruments(
|
||||||
|
matching query: String,
|
||||||
|
familyCategory: String? = nil
|
||||||
|
) -> [InstrumentCatalogItem] {
|
||||||
let searchTokens = Self.searchTokens(for: query)
|
let searchTokens = Self.searchTokens(for: query)
|
||||||
|
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.filter { item in
|
.filter { item in
|
||||||
searchTokens.isEmpty || searchTokens.allSatisfy { token in
|
let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in
|
||||||
Self.searchableTerms(for: item).contains { $0.contains(token) }
|
Self.searchableTerms(for: item).contains { $0.contains(token) }
|
||||||
}
|
}
|
||||||
|
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
|
||||||
|
return matchesSearch && matchesFamily
|
||||||
}
|
}
|
||||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,17 @@ final class LocalInstrumentCatalogTests: XCTestCase {
|
|||||||
XCTAssertEqual(catalog.instruments(matching: "nylon string").map(\.id), ["classical-guitar"])
|
XCTAssertEqual(catalog.instruments(matching: "nylon string").map(\.id), ["classical-guitar"])
|
||||||
XCTAssertEqual(catalog.instruments(matching: "عود").map(\.id), ["oud"])
|
XCTAssertEqual(catalog.instruments(matching: "عود").map(\.id), ["oud"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFamilyCategoriesAndFilteringComeFromCatalogMetadata() {
|
||||||
|
let catalog = LocalInstrumentCatalog()
|
||||||
|
let pluckedStrings = catalog.instruments(matching: "", familyCategory: "Plucked strings")
|
||||||
|
|
||||||
|
XCTAssertTrue(catalog.familyCategories.contains("Plucked strings"))
|
||||||
|
XCTAssertTrue(pluckedStrings.contains { $0.id == "oud" })
|
||||||
|
XCTAssertTrue(pluckedStrings.allSatisfy { $0.familyCategory == "Plucked strings" })
|
||||||
|
XCTAssertTrue(
|
||||||
|
catalog.instruments(matching: "guitar", familyCategory: "Plucked strings")
|
||||||
|
.allSatisfy { $0.familyCategory == "Plucked strings" }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
instruments.
|
instruments.
|
||||||
- [x] Keep catalog data out of SwiftUI views.
|
- [x] Keep catalog data out of SwiftUI views.
|
||||||
- [x] Build searchable instrument browser.
|
- [x] Build searchable instrument browser.
|
||||||
- [ ] Add browsing/filtering by family/category.
|
- [x] Add browsing/filtering by family/category.
|
||||||
- [ ] Add browsing/filtering by region/origin where useful.
|
- [ ] Add browsing/filtering by region/origin where useful.
|
||||||
- [ ] Add checkbox-based multi-select and deselect behavior.
|
- [ ] Add checkbox-based multi-select and deselect behavior.
|
||||||
- [ ] Persist selected instruments on the current Song Project.
|
- [ ] Persist selected instruments on the current Song Project.
|
||||||
|
|||||||
Reference in New Issue
Block a user