Version one on the App Store

This commit is contained in:
2026-04-05 19:44:30 +02:00
parent c780be089d
commit 3ebf1763ed
26 changed files with 2088 additions and 842 deletions
@@ -22,7 +22,9 @@ struct ArtistsView: View {
}
private let columns = [
GridItem(.adaptive(minimum: 80), spacing: 8)
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
]
/// Artists grouped by first letter; non-alphabetic names go under "#"
@@ -42,55 +44,60 @@ struct ArtistsView: View {
artistsByLetter.map { $0.0 }
}
// Always show AZ plus # so the sidebar has a consistent full height
private let allLetters: [String] = (65...90).map { String(UnicodeScalar($0)!) } + ["#"]
var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .trailing) {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(artistsByLetter, id: \.0) { letter, letterArtists in
// Section header
Text(letter)
.font(.headline)
.fontWeight(.bold)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 4)
.id(letter)
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(artistsByLetter, id: \.0) { letter, letterArtists in
// Section header scroll target
Text(letter)
.font(.headline)
.fontWeight(.bold)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 4)
.id(letter)
// Grid of artists in this section
LazyVGrid(columns: columns, spacing: 8) {
ForEach(letterArtists) { artist in
NavigationLink(value: artist) {
ArtistGridItem(artist: artist)
}
.buttonStyle(.plain)
.task {
await loadMoreIfNeeded(currentItem: artist)
}
// Grid of artists in this section
LazyVGrid(columns: columns, spacing: 8) {
ForEach(letterArtists) { artist in
NavigationLink(value: artist) {
ArtistGridItem(artist: artist)
}
.buttonStyle(.plain)
.task {
await loadMoreIfNeeded(currentItem: artist)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
// Right padding leaves room for the alphabet index
.padding(.trailing, 24)
}
// Floating alphabet index on the right edge
if !availableLetters.isEmpty {
AlphabetIndexView(letters: availableLetters) { letter in
proxy.scrollTo(letter, anchor: .top)
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
.padding(.trailing, 2)
}
.padding(.trailing, 28)
}
.overlay(alignment: .trailing) {
AlphabetIndexView(
letters: allLetters,
itemHeight: 17,
onSelect: { letter in
// Scroll to this letter's section, or the nearest one after it
let target = availableLetters.first { $0 >= letter } ?? availableLetters.last
if let target { proxy.scrollTo(target, anchor: .top) }
}
)
.padding(.vertical, 8)
.padding(.trailing, 2)
}
}
.refreshable {
@@ -140,50 +147,46 @@ struct ArtistsView: View {
struct AlphabetIndexView: View {
let letters: [String]
let itemHeight: CGFloat
let onSelect: (String) -> Void
@State private var activeLetter: String?
var body: some View {
GeometryReader { geometry in
let itemHeight = geometry.size.height / CGFloat(letters.count)
ZStack {
// Touch-responsive column
VStack(spacing: 0) {
ForEach(letters, id: \.self) { letter in
Text(letter)
.font(.system(size: 11, weight: .bold))
.frame(width: 20, height: itemHeight)
.foregroundStyle(activeLetter == letter ? .white : .accentColor)
.background {
if activeLetter == letter {
Circle()
.fill(Color.accentColor)
.frame(width: 18, height: 18)
}
}
VStack(spacing: 0) {
ForEach(letters, id: \.self) { letter in
Text(letter)
.font(.system(size: min(13, itemHeight * 0.65), weight: .bold))
.frame(width: 20, height: itemHeight)
.padding(.top, letter == "#" ? 6 : 0)
.foregroundStyle(activeLetter == letter ? .white : .accentColor)
.background {
if activeLetter == letter {
Circle()
.fill(Color.accentColor)
.frame(width: min(18, itemHeight - 2), height: min(18, itemHeight - 2))
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let index = Int(value.location.y / itemHeight)
let clamped = max(0, min(letters.count - 1, index))
let letter = letters[clamped]
if activeLetter != letter {
activeLetter = letter
onSelect(letter)
UISelectionFeedbackGenerator().selectionChanged()
}
}
.onEnded { _ in
activeLetter = nil
}
)
}
}
.frame(width: 20)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let safeHeight = max(1, itemHeight)
let index = Int(value.location.y / safeHeight)
let clamped = max(0, min(letters.count - 1, index))
let letter = letters[clamped]
if activeLetter != letter {
activeLetter = letter
onSelect(letter)
UISelectionFeedbackGenerator().selectionChanged()
}
}
.onEnded { _ in
activeLetter = nil
}
)
}
}
@@ -195,7 +198,7 @@ struct ArtistGridItem: View {
var body: some View {
VStack(spacing: 4) {
CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 128)) { image in
CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 256)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
@@ -204,11 +207,11 @@ struct ArtistGridItem: View {
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: "music.mic")
.font(.system(size: 22))
.font(.system(size: 30))
.foregroundStyle(.secondary)
}
}
.frame(width: 76, height: 76)
.aspectRatio(1, contentMode: .fit)
.clipShape(Circle())
Text(artist.name)