Files
yattee/Yattee/Views/Settings/LegacyDataImportView.swift
Arkadiusz Fal a673968f1e Fix build error with confirmationDialog on CI Xcode
Replace the item-based confirmationDialog overload, which is unavailable
in the Xcode 26.5 SDK used on CI, with the isPresented/presenting
variant.
2026-07-18 17:54:49 +02:00

426 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 ?? "",
isPresented: Binding(
get: { pendingRemoval != nil },
set: { if !$0 { pendingRemoval = nil } }
),
titleVisibility: .visible,
presenting: pendingRemoval
) { 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)
}