Files
Spanish/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsConfirmationView.swift
T
Trey t 282cd1b3a3 Fix lyrics wiped on schema reset by moving SavedSong to cloud container
SavedSong was in the local container alongside reference data, so it
got deleted whenever localStoreResetVersion was bumped. Move it to the
cloud container (CloudKit-synced) so saved songs persist across schema
changes. Update lyrics views to use cloudModelContextProvider.

Closes #4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:27:21 -05:00

217 lines
8.1 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
import Translation
struct LyricsConfirmationView: View {
let result: LyricsSearchResult
let onSave: () -> Void
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var translatedEN = ""
@State private var isTranslating = true
@State private var translationError = false
@State private var translationConfig: TranslationSession.Configuration?
var body: some View {
ScrollView {
VStack(spacing: 20) {
headerSection
lyricsPreview
actionButtons
}
.padding()
.adaptiveContainer()
}
.navigationTitle("Confirm Lyrics")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
translationConfig = .init(
source: Locale.Language(identifier: "es"),
target: Locale.Language(identifier: "en")
)
}
.translationTask(translationConfig) { session in
await translateLyrics(session: session)
}
}
// MARK: - Sections
private var headerSection: some View {
VStack(spacing: 12) {
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.fill.quaternary)
.overlay {
ProgressView()
}
}
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(result.title)
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
Text(result.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var lyricsPreview: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Spanish Lyrics")
.font(.headline)
Text(result.lyricsES.prefix(500) + (result.lyricsES.count > 500 ? "\n..." : ""))
.font(.body)
.foregroundStyle(.primary)
Divider()
HStack {
Text("English Translation")
.font(.headline)
if isTranslating {
ProgressView()
.controlSize(.small)
}
}
if translationError {
Label("Translation unavailable. You can still save with Spanish only.",
systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
} else if translatedEN.isEmpty && isTranslating {
Text("Translating...")
.font(.body)
.foregroundStyle(.secondary)
} else {
Text(translatedEN.prefix(500) + (translatedEN.count > 500 ? "\n..." : ""))
.font(.body)
.foregroundStyle(.secondary)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
private var actionButtons: some View {
HStack(spacing: 16) {
Button(role: .cancel) {
dismiss()
} label: {
Text("Cancel")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
Button {
saveSong()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(isTranslating && translatedEN.isEmpty && !translationError)
}
}
// MARK: - Logic
private func translateLyrics(session: sending TranslationSession) async {
await MainActor.run { isTranslating = true }
let text = result.lyricsES
let esLines = text.components(separatedBy: "\n")
print("[LyricsTranslation] Spanish line count: \(esLines.count)")
print("[LyricsTranslation] Spanish blank lines: \(esLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
print("[LyricsTranslation] First 10 ES lines:")
for (i, line) in esLines.prefix(10).enumerated() {
print(" ES[\(i)]: \(line.isEmpty ? "(blank)" : line)")
}
do {
let response = try await session.translate(text)
let enLines = response.targetText.components(separatedBy: "\n")
print("[LyricsTranslation] English line count: \(enLines.count)")
print("[LyricsTranslation] English blank lines: \(enLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
print("[LyricsTranslation] First 10 EN lines:")
for (i, line) in enLines.prefix(10).enumerated() {
print(" EN[\(i)]: \(line.isEmpty ? "(blank)" : line)")
}
// The Translation framework often inserts a blank line after every
// translated line. Collapse consecutive blank lines into single blanks,
// then trim leading/trailing blanks so the EN structure matches the ES.
let normalized = Self.normalizeTranslationLineBreaks(
translated: response.targetText,
originalES: text
)
let normalizedLines = normalized.components(separatedBy: "\n")
print("[LyricsTranslation] After normalization: EN lines \(enLines.count)\(normalizedLines.count) (target: \(esLines.count))")
await MainActor.run { translatedEN = normalized }
} catch {
print("[LyricsTranslation] Translation error: \(error)")
await MainActor.run { translationError = true }
}
await MainActor.run { isTranslating = false }
}
/// Re-align translated line breaks to match the original Spanish structure.
/// The Translation framework often inserts a blank line after every translated
/// line. We strip all blanks from the EN output, then re-insert them at the
/// same positions where the original ES text has blank lines.
/// Re-align translated line breaks to match the original Spanish structure.
private static func normalizeTranslationLineBreaks(translated: String, originalES: String) -> String {
let esLines = originalES.components(separatedBy: "\n")
let enContentLines = translated.components(separatedBy: "\n")
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
// Walk ES lines. For each blank ES line, insert a blank in the result.
// For each content ES line, consume the next EN content line.
var result: [String] = []
var enIndex = 0
for esLine in esLines {
if esLine.trimmingCharacters(in: .whitespaces).isEmpty {
result.append("")
} else if enIndex < enContentLines.count {
result.append(enContentLines[enIndex])
enIndex += 1
} else {
result.append("")
}
}
print("[LyricsNormalize] ES lines: \(esLines.count), EN content: \(enContentLines.count), result: \(result.count), EN consumed: \(enIndex)")
return result.joined(separator: "\n")
}
private func saveSong() {
let song = SavedSong(
title: result.title,
artist: result.artist,
lyricsES: result.lyricsES,
lyricsEN: translatedEN,
albumArtURL: result.albumArtURL ?? "",
appleMusicURL: result.appleMusicURL ?? ""
)
cloudModelContext.insert(song)
try? cloudModelContext.save()
onSave()
}
}