Files
yattee/Yattee/Views/Settings/LegacyDataImportView.swift
Arkadiusz Fal 9e2b0fa095 Extend opaque background to settings and other pushed pages on macOS
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.

Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-07 22:16:09 +02:00

422 lines
14 KiB
Swift

//
// LegacyDataImportView.swift
// Yattee
//
// View for reviewing and re-creating accounts and sources from the legacy v1 app.
//
import SwiftUI
struct LegacyAccountsImportView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
var showsDoneButton = true
@State private var accountItems: [LegacyAccountImportItem] = []
@State private var instanceItems: [LegacyInstanceImportItem] = []
@State private var rowStates: [String: LegacyAccountRowState] = [:]
@State private var isLoading = true
@State private var pendingRemoval: PendingRemoval?
@State private var importSuccessTitle = ""
@State private var importedInstanceName = ""
@State private var showingImportSuccess = false
private var legacyMigrationService: LegacyDataMigrationService? {
appEnvironment?.legacyMigrationService
}
private var isEmpty: Bool {
accountItems.isEmpty && instanceItems.isEmpty
}
var body: some View {
Group {
if isLoading {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if isEmpty {
emptyState
} else {
contentList
}
}
.navigationTitle(String(localized: "migration.accounts.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.opaqueSettingsFormBackground()
.toolbar {
#if !os(tvOS)
if showsDoneButton {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.done")) {
dismiss()
}
}
}
#endif
}
.task {
loadLegacyData()
}
.confirmationDialog(
pendingRemoval?.confirmationTitle ?? "",
item: $pendingRemoval,
titleVisibility: .visible
) { removal in
Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) {
confirmRemoval(removal)
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: { removal in
Text(String(localized: "migration.accounts.remove.message \(removal.displayName)"))
}
.alert(importSuccessTitle, isPresented: $showingImportSuccess) {
Button(String(localized: "common.ok"), role: .cancel) {}
} message: {
Text(importedInstanceName)
}
}
private var contentList: some View {
Form {
if !accountItems.isEmpty {
Section {
ForEach(accountItems) { item in
LegacyAccountImportRow(
item: item,
state: stateBinding(for: item)
) {
importLegacyAccount(item)
} onRemove: {
pendingRemoval = .account(item)
}
}
} header: {
Text(String(localized: "migration.accounts.section"))
} footer: {
Text(String(localized: "migration.accounts.footer"))
}
}
if !instanceItems.isEmpty {
Section {
ForEach(instanceItems) { item in
LegacyInstanceImportRow(item: item) {
importLegacyInstance(item)
} onRemove: {
pendingRemoval = .instance(item)
}
}
} header: {
Text(String(localized: "migration.sources.section"))
} footer: {
Text(String(localized: "migration.sources.footer"))
}
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
private var emptyState: some View {
ContentUnavailableView(
String(localized: "migration.accounts.empty.title"),
systemImage: "person.badge.key",
description: Text(String(localized: "migration.accounts.empty.description"))
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func loadLegacyData() {
let loadedAccounts = legacyMigrationService?.parseLegacyAccountsForImport() ?? []
accountItems = loadedAccounts
instanceItems = legacyMigrationService?.parseLegacyInstancesForImport() ?? []
for item in loadedAccounts where rowStates[item.legacyAccountID] == nil {
rowStates[item.legacyAccountID] = LegacyAccountRowState(username: item.username)
}
isLoading = false
}
private func stateBinding(for item: LegacyAccountImportItem) -> Binding<LegacyAccountRowState> {
Binding {
rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username)
} set: { newValue in
rowStates[item.legacyAccountID] = newValue
}
}
private func importLegacyAccount(_ item: LegacyAccountImportItem) {
guard let service = legacyMigrationService else { return }
var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username)
state.isImporting = true
state.errorMessage = nil
rowStates[item.legacyAccountID] = state
Task {
do {
let importedInstance = try await service.importLegacyAccount(
item,
username: state.username,
password: state.password
)
importSuccessTitle = String(localized: "migration.accounts.imported.title")
importedInstanceName = importedInstance.displayName
showingImportSuccess = true
accountItems.removeAll { $0.legacyAccountID == item.legacyAccountID }
rowStates.removeValue(forKey: item.legacyAccountID)
} catch APIError.unauthorized {
setImportError(String(localized: "login.error.invalidCredentials"), for: item)
} catch {
setImportError(error.localizedDescription, for: item)
}
}
}
private func importLegacyInstance(_ item: LegacyInstanceImportItem) {
guard let service = legacyMigrationService else { return }
let importedInstance = service.importLegacyInstance(item)
importSuccessTitle = String(localized: "migration.sources.imported.title")
importedInstanceName = importedInstance.displayName
showingImportSuccess = true
instanceItems.removeAll { $0.legacyInstanceID == item.legacyInstanceID }
}
private func setImportError(_ message: String, for item: LegacyAccountImportItem) {
var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username)
state.isImporting = false
state.errorMessage = message
rowStates[item.legacyAccountID] = state
}
private func confirmRemoval(_ removal: PendingRemoval) {
switch removal {
case .account(let item):
legacyMigrationService?.removeLegacyAccount(item)
accountItems.removeAll { $0.legacyAccountID == item.legacyAccountID }
rowStates.removeValue(forKey: item.legacyAccountID)
case .instance(let item):
legacyMigrationService?.removeLegacyInstance(item)
instanceItems.removeAll { $0.legacyInstanceID == item.legacyInstanceID }
}
}
}
// MARK: - Pending Removal
private enum PendingRemoval: Identifiable {
case account(LegacyAccountImportItem)
case instance(LegacyInstanceImportItem)
var id: String {
switch self {
case .account(let item):
return "account:\(item.id)"
case .instance(let item):
return "instance:\(item.id)"
}
}
var displayName: String {
switch self {
case .account(let item):
return item.displayName
case .instance(let item):
return item.instanceDisplayName
}
}
var confirmationTitle: String {
switch self {
case .account:
return String(localized: "migration.accounts.remove.title")
case .instance:
return String(localized: "migration.sources.remove.title")
}
}
}
// MARK: - Account Row
private struct LegacyAccountImportRow: View {
let item: LegacyAccountImportItem
@Binding var state: LegacyAccountRowState
let onImport: () -> Void
let onRemove: () -> Void
private var canImport: Bool {
!state.username.isEmpty && !state.password.isEmpty && !state.isImporting
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(item.displayName)
.font(.headline)
Text(item.instanceDisplayName)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(item.url.host ?? item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
credentialsFields
if let errorMessage = state.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
}
.disabled(state.isImporting)
.foregroundStyle(.red)
.tint(.red)
Spacer()
Button(action: onImport) {
if state.isImporting {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text(String(localized: "migration.importing"))
}
} else {
Text(String(localized: "migration.import"))
}
}
.buttonStyle(.borderedProminent)
.disabled(!canImport)
}
}
.padding(.vertical, 8)
}
@ViewBuilder
private var credentialsFields: some View {
#if os(tvOS)
TVSettingsTextField(title: usernameLabel, text: $state.username)
TVSettingsTextField(title: String(localized: "login.password"), text: $state.password, isSecure: true)
#else
TextField(usernameLabel, text: $state.username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
SecureField(String(localized: "login.password"), text: $state.password)
.textContentType(.password)
#endif
}
private var usernameLabel: String {
switch item.instanceType {
case .invidious:
return String(localized: "login.email")
default:
return String(localized: "login.username")
}
}
}
// MARK: - Source Row
private struct LegacyInstanceImportRow: View {
let item: LegacyInstanceImportItem
let onImport: () -> Void
let onRemove: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(item.instanceDisplayName)
.font(.headline)
Text(item.url.host ?? item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
HStack {
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
}
.foregroundStyle(.red)
.tint(.red)
Spacer()
Button(action: onImport) {
Text(String(localized: "migration.import"))
}
.buttonStyle(.borderedProminent)
}
}
.padding(.vertical, 8)
}
}
private func legacyInstanceIcon(for type: InstanceType) -> String {
switch type {
case .invidious:
return "server.rack"
case .piped:
return "cloud"
default:
return "globe"
}
}
private struct LegacyAccountRowState: Equatable {
var username: String
var password = ""
var errorMessage: String?
var isImporting = false
}
struct LegacyDataImportView: View {
var body: some View {
LegacyAccountsImportView()
}
}
// MARK: - Preview
#Preview {
NavigationStack {
LegacyAccountsImportView()
}
.appEnvironment(.preview)
}