Navigation: present search as a sheet from library (avoids nested NavigationStack), use view-based NavigationLink for song rows (fixes double-push from duplicate navigationDestination). Translation: Apple Translation inserts a blank line after every translated line. Strip all blanks from the EN output, then re-insert them at the same positions where the original ES has blanks. Result is 1:1 line pairing between Spanish and English. Store reset: revert localStoreResetVersion bump — adding SavedSong is a lightweight SwiftData migration, no store nuke needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
180 lines
5.5 KiB
Swift
180 lines
5.5 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
import Translation
|
|
|
|
struct LyricsSearchView: View {
|
|
var onSaved: (() -> Void)?
|
|
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var artist = ""
|
|
@State private var songTitle = ""
|
|
@State private var isSearching = false
|
|
@State private var searchResults: [LyricsSearchResult] = []
|
|
@State private var errorMessage: String?
|
|
@State private var selectedResult: LyricsSearchResult?
|
|
|
|
private let service = LyricsSearchService()
|
|
|
|
var body: some View {
|
|
List {
|
|
searchSection
|
|
if isSearching {
|
|
loadingSection
|
|
}
|
|
if let errorMessage {
|
|
errorSection(errorMessage)
|
|
}
|
|
if !searchResults.isEmpty {
|
|
resultsSection
|
|
}
|
|
}
|
|
.navigationTitle("Search Lyrics")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationDestination(item: $selectedResult) { result in
|
|
LyricsConfirmationView(result: result) {
|
|
if let onSaved {
|
|
onSaved()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var searchSection: some View {
|
|
Section {
|
|
TextField("Artist", text: $artist)
|
|
.textInputAutocapitalization(.words)
|
|
.autocorrectionDisabled()
|
|
|
|
TextField("Song Title", text: $songTitle)
|
|
.textInputAutocapitalization(.words)
|
|
.autocorrectionDisabled()
|
|
|
|
Button {
|
|
performSearch()
|
|
} label: {
|
|
HStack {
|
|
Spacer()
|
|
Label("Search", systemImage: "magnifyingglass")
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
}
|
|
.disabled(artist.trimmingCharacters(in: .whitespaces).isEmpty ||
|
|
songTitle.trimmingCharacters(in: .whitespaces).isEmpty ||
|
|
isSearching)
|
|
} header: {
|
|
Text("Find a Song")
|
|
}
|
|
}
|
|
|
|
private var loadingSection: some View {
|
|
Section {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView("Searching...")
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
private func errorSection(_ message: String) -> some View {
|
|
Section {
|
|
Label(message, systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
private var resultsSection: some View {
|
|
Section {
|
|
ForEach(Array(searchResults.prefix(5).enumerated()), id: \.offset) { _, result in
|
|
Button {
|
|
selectedResult = result
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
|
AsyncImage(url: url) { image in
|
|
image.resizable().scaledToFill()
|
|
} placeholder: {
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(.fill.quaternary)
|
|
}
|
|
.frame(width: 50, height: 50)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
} else {
|
|
Image(systemName: "music.note")
|
|
.font(.title2)
|
|
.frame(width: 50, height: 50)
|
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(result.title)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(result.artist)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.tint(.primary)
|
|
}
|
|
} header: {
|
|
Text("Results")
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func performSearch() {
|
|
isSearching = true
|
|
errorMessage = nil
|
|
searchResults = []
|
|
|
|
Task {
|
|
do {
|
|
let results = try await service.searchLyrics(artist: artist, title: songTitle)
|
|
searchResults = results
|
|
if results.isEmpty {
|
|
errorMessage = "No lyrics found. Try a different spelling."
|
|
}
|
|
} catch {
|
|
errorMessage = "Search failed. Check your connection."
|
|
}
|
|
isSearching = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Identifiable conformance for navigation
|
|
|
|
extension LyricsSearchResult: Equatable {
|
|
static func == (lhs: LyricsSearchResult, rhs: LyricsSearchResult) -> Bool {
|
|
lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.lyricsES == rhs.lyricsES
|
|
}
|
|
}
|
|
|
|
extension LyricsSearchResult: Hashable {
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(title)
|
|
hasher.combine(artist)
|
|
}
|
|
}
|
|
|
|
extension LyricsSearchResult: Identifiable {
|
|
var id: String { "\(artist)—\(title)" }
|
|
}
|