From df9378a308e52f99fcf2af17419939e5db1c63f8 Mon Sep 17 00:00:00 2001 From: David Rodger Date: Tue, 12 May 2026 19:01:26 -0400 Subject: [PATCH] Make iOS song Recordings section per-group accordions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outer DisclosureGroup around the whole Recordings section is gone — the section is always visible, no chevron at the top. In its place, each grouping shelf (decade when sorted by year, artist name when sorted by name) is now its own collapsible row that starts closed. First view of a song now shows a scannable list of "1960s (12)", "1970s (8)", … or "Miles Davis (4)", "John Coltrane (3)", … with the items hidden until the user taps to open a shelf. Expansion state lives in a Set keyed by group name, so filter changes preserve per-shelf state for keys that still exist and naturally drop keys that don't. Switching sort order rebuilds the keyspace entirely (decades to artist names), so the set is cleared in that case. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/iOS/Components/RecordingsSection.swift | 333 +++++++++++--------- 1 file changed, 179 insertions(+), 154 deletions(-) diff --git a/apps/iOS/Components/RecordingsSection.swift b/apps/iOS/Components/RecordingsSection.swift index ffe1d65..8461a4f 100644 --- a/apps/iOS/Components/RecordingsSection.swift +++ b/apps/iOS/Components/RecordingsSection.swift @@ -2,10 +2,9 @@ // RecordingsSection.swift // Approach Note // -// Collapsible section displaying filtered recordings with filter chips + sheet pattern -// UPDATED: Replaced nested disclosure groups with filter chips and bottom sheet -// UPDATED: Sort options changed from Authority/Year/Canonical to Name/Year -// UPDATED: Grouping changes based on sort order (by year or by artist name) +// Section displaying filtered recordings with filter chips + per-group accordions. +// The outer section is always expanded; each group (decade or artist) starts +// collapsed and can be opened individually. // import SwiftUI @@ -38,161 +37,67 @@ struct RecordingsSection: View { @State private var selectedVocalFilter: VocalFilter = .all @State private var selectedInstrument: InstrumentFamily? = nil @State private var showFilterSheet: Bool = false - @State private var isSectionExpanded: Bool = true + + // Per-group expansion state. Groups not in the set are collapsed. + // Default is empty: all shelves start collapsed so users see a + // scannable list of decades / artist names before drilling in. + @State private var expandedGroups: Set = [] var body: some View { - // HStack with explicit spacers ensures DisclosureGroup chevron is properly inset - HStack(spacing: 0) { - Spacer().frame(width: 16) - - VStack(alignment: .leading, spacing: 0) { - DisclosureGroup( - isExpanded: $isSectionExpanded, - content: { - VStack(alignment: .leading, spacing: 0) { - - // MARK: - FILTER CHIPS BAR - if hasActiveFilters || !availableInstruments.isEmpty { - filterChipsBar - .padding(.vertical, 8) - .padding(.horizontal, 4) - .background(ApproachNoteTheme.cardBackground) - .cornerRadius(8) - .padding(.horizontal) - } + VStack(alignment: .leading, spacing: 0) { + sectionHeader + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if hasActiveFilters || !availableInstruments.isEmpty { + filterChipsBar + .padding(.vertical, 8) + .padding(.horizontal, 4) + .background(ApproachNoteTheme.cardBackground) + .cornerRadius(8) + .padding(.horizontal, 16) + } - // Recordings List (lazy-loaded for performance) - LazyVStack(alignment: .leading, spacing: 12) { - if !filteredRecordings.isEmpty { - ForEach(groupedRecordings, id: \.groupKey) { group in - VStack(alignment: .leading, spacing: 8) { - Text("\(group.groupKey) (\(group.recordings.count))") - .font(ApproachNoteTheme.headline()) - .foregroundColor(ApproachNoteTheme.burgundy) - .padding(.horizontal) - .padding(.top, 8) - - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(alignment: .top, spacing: 0) { - ForEach(Array(group.recordings.enumerated()), id: \.element.id) { index, recording in - HStack(alignment: .top, spacing: 0) { - // Divider before item (except first) - if index > 0 { - Rectangle() - .fill(ApproachNoteTheme.burgundy.opacity(0.4)) - .frame(width: 2, height: 150) - .padding(.horizontal, 8) - } - - NavigationLink(destination: RecordingDetailView( - recordingId: recording.id, - onCommunityDataChanged: onCommunityDataChanged - )) { - RecordingRowView( - recording: recording, - showArtistName: recordingSortOrder == .year || group.groupKey == "More Recordings", - onVisible: onRequestHydration - ) - } - .buttonStyle(.plain) - } - } - } - .padding(.horizontal) - } - } - } - } else { - VStack(spacing: 12) { - Image(systemName: "music.note") - .font(.system(size: 48)) - .foregroundColor(ApproachNoteTheme.smokeGray.opacity(0.5)) - Text("No recordings match the current filters") - .font(ApproachNoteTheme.subheadline()) - .foregroundColor(ApproachNoteTheme.smokeGray) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } - } - .padding(.top, 8) - .overlay(alignment: .top) { - if isReloading { - HStack(spacing: 8) { - ProgressView() - .tint(ApproachNoteTheme.burgundy) - Text("Reloading...") - .font(ApproachNoteTheme.subheadline()) - .foregroundColor(ApproachNoteTheme.smokeGray) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.ultraThinMaterial) - .cornerRadius(8) - .shadow(color: .black.opacity(0.1), radius: 4, y: 2) - .padding(.top, 40) - } - } - .opacity(isReloading ? 0.5 : 1.0) - .animation(.easeInOut(duration: 0.2), value: isReloading) - } - }, - label: { - HStack(alignment: .center) { - Image(systemName: "music.note.list") - .foregroundColor(ApproachNoteTheme.burgundy) - - Text("Recordings") - .font(ApproachNoteTheme.title2()) - .bold() - .foregroundColor(ApproachNoteTheme.charcoal) - - // Recording count in header - Text("(\(filteredRecordings.count))") - .font(ApproachNoteTheme.subheadline()) - .foregroundColor(ApproachNoteTheme.smokeGray) - - Spacer() - - // Sort menu - Menu { - ForEach(RecordingSortOrder.allCases) { sortOrder in - Button(action: { - if recordingSortOrder != sortOrder { - recordingSortOrder = sortOrder - onSortOrderChanged?(sortOrder) - } - }) { - HStack { - Text(sortOrder.displayName) - if recordingSortOrder == sortOrder { - Image(systemName: "checkmark") - } - } - } - } - } label: { - HStack(spacing: 3) { - Text(recordingSortOrder.displayName) - .font(ApproachNoteTheme.caption()) - Image(systemName: "chevron.down") - .font(.caption2) - } - .foregroundColor(ApproachNoteTheme.burgundy) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(ApproachNoteTheme.burgundy.opacity(0.1)) - .cornerRadius(6) - } - } - .padding(.vertical, 12) + LazyVStack(alignment: .leading, spacing: 8) { + if !filteredRecordings.isEmpty { + ForEach(groupedRecordings, id: \.groupKey) { group in + groupAccordion(group: group) } - ) - .tint(ApproachNoteTheme.burgundy) + } else { + VStack(spacing: 12) { + Image(systemName: "music.note") + .font(.system(size: 48)) + .foregroundColor(ApproachNoteTheme.smokeGray.opacity(0.5)) + Text("No recordings match the current filters") + .font(ApproachNoteTheme.subheadline()) + .foregroundColor(ApproachNoteTheme.smokeGray) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } } - - Spacer().frame(width: 16) + .padding(.horizontal, 16) + .padding(.top, 8) + .overlay(alignment: .top) { + if isReloading { + HStack(spacing: 8) { + ProgressView() + .tint(ApproachNoteTheme.burgundy) + Text("Reloading...") + .font(ApproachNoteTheme.subheadline()) + .foregroundColor(ApproachNoteTheme.smokeGray) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .cornerRadius(8) + .shadow(color: .black.opacity(0.1), radius: 4, y: 2) + .padding(.top, 40) + } + } + .opacity(isReloading ? 0.5 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isReloading) } .background(ApproachNoteTheme.backgroundLight) .sheet(isPresented: $showFilterSheet) { @@ -205,6 +110,126 @@ struct RecordingsSection: View { } } + // MARK: - Section Header (no expand/collapse — section is always visible) + + @ViewBuilder + private var sectionHeader: some View { + HStack(alignment: .center) { + Image(systemName: "music.note.list") + .foregroundColor(ApproachNoteTheme.burgundy) + + Text("Recordings") + .font(ApproachNoteTheme.title2()) + .bold() + .foregroundColor(ApproachNoteTheme.charcoal) + + Text("(\(filteredRecordings.count))") + .font(ApproachNoteTheme.subheadline()) + .foregroundColor(ApproachNoteTheme.smokeGray) + + Spacer() + + Menu { + ForEach(RecordingSortOrder.allCases) { sortOrder in + Button(action: { + if recordingSortOrder != sortOrder { + // Sort change rebuilds group keys entirely + // (decades ↔ artist names), so previous + // expansion state no longer applies. + expandedGroups.removeAll() + recordingSortOrder = sortOrder + onSortOrderChanged?(sortOrder) + } + }) { + HStack { + Text(sortOrder.displayName) + if recordingSortOrder == sortOrder { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 3) { + Text(recordingSortOrder.displayName) + .font(ApproachNoteTheme.caption()) + Image(systemName: "chevron.down") + .font(.caption2) + } + .foregroundColor(ApproachNoteTheme.burgundy) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(ApproachNoteTheme.burgundy.opacity(0.1)) + .cornerRadius(6) + } + } + } + + // MARK: - Group Accordion Row + + @ViewBuilder + private func groupAccordion(group: (groupKey: String, recordings: [Recording])) -> some View { + let isExpanded = expandedGroups.contains(group.groupKey) + + VStack(alignment: .leading, spacing: 0) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + if isExpanded { + expandedGroups.remove(group.groupKey) + } else { + expandedGroups.insert(group.groupKey) + } + } + }) { + HStack { + Text("\(group.groupKey) (\(group.recordings.count))") + .font(ApproachNoteTheme.headline()) + .foregroundColor(ApproachNoteTheme.burgundy) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(ApproachNoteTheme.brass) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 0) { + ForEach(Array(group.recordings.enumerated()), id: \.element.id) { index, recording in + HStack(alignment: .top, spacing: 0) { + if index > 0 { + Rectangle() + .fill(ApproachNoteTheme.burgundy.opacity(0.4)) + .frame(width: 2, height: 150) + .padding(.horizontal, 8) + } + + NavigationLink(destination: RecordingDetailView( + recordingId: recording.id, + onCommunityDataChanged: onCommunityDataChanged + )) { + RecordingRowView( + recording: recording, + showArtistName: recordingSortOrder == .year || group.groupKey == "More Recordings", + onVisible: onRequestHydration + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 12) + } + .padding(.bottom, 8) + } + } + .background(ApproachNoteTheme.cardBackground) + .cornerRadius(8) + } + // MARK: - Filter Chips Bar @ViewBuilder