mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Add manual legacy account import
This commit is contained in:
@@ -2,300 +2,294 @@
|
||||
// LegacyDataImportView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Full-screen view for importing legacy v1 data from Advanced Settings.
|
||||
// View for reviewing and re-creating accounts from the legacy v1 app.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LegacyDataImportView: View {
|
||||
struct LegacyAccountsImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var items: [LegacyImportItem] = []
|
||||
var showsDoneButton = true
|
||||
|
||||
@State private var items: [LegacyAccountImportItem] = []
|
||||
@State private var rowStates: [String: LegacyAccountRowState] = [:]
|
||||
@State private var isLoading = true
|
||||
@State private var isImporting = false
|
||||
@State private var showingResultSheet = false
|
||||
@State private var lastResult: MigrationResult?
|
||||
@State private var showingUnreachableAlert = false
|
||||
@State private var pendingUnreachableItem: LegacyImportItem?
|
||||
@State private var pendingRemoval: LegacyAccountImportItem?
|
||||
@State private var importedInstanceName = ""
|
||||
@State private var showingImportSuccess = false
|
||||
|
||||
private var legacyMigrationService: LegacyDataMigrationService? {
|
||||
appEnvironment?.legacyMigrationService
|
||||
}
|
||||
|
||||
private var selectedCount: Int {
|
||||
items.filter(\.isSelected).count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
String(localized: "migration.noDataFound"),
|
||||
systemImage: "doc.questionmark",
|
||||
description: Text(String(localized: "migration.noDataFoundDescription"))
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
emptyState
|
||||
} else {
|
||||
importContent
|
||||
accountList
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "settings.advanced.data.importLegacy"))
|
||||
.navigationTitle(String(localized: "migration.accounts.title"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.task {
|
||||
loadLegacyData()
|
||||
}
|
||||
.sheet(isPresented: $showingResultSheet) {
|
||||
resultSheet
|
||||
}
|
||||
.alert(String(localized: "migration.unreachableTitle"), isPresented: $showingUnreachableAlert) {
|
||||
Button(String(localized: "migration.unreachableImport"), role: .destructive) {
|
||||
// Keep the item selected
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||
if let item = pendingUnreachableItem,
|
||||
let index = items.firstIndex(where: { $0.id == item.id }) {
|
||||
items[index].isSelected = false
|
||||
.toolbar {
|
||||
#if !os(tvOS)
|
||||
if showsDoneButton {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "common.done")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
loadLegacyAccounts()
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "migration.accounts.remove.title"),
|
||||
item: $pendingRemoval,
|
||||
titleVisibility: .visible
|
||||
) { item in
|
||||
Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) {
|
||||
removeLegacyAccount(item)
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: { item in
|
||||
Text(String(localized: "migration.accounts.remove.message \(item.displayName)"))
|
||||
}
|
||||
.alert(String(localized: "migration.accounts.imported.title"), isPresented: $showingImportSuccess) {
|
||||
Button(String(localized: "common.ok"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(String(localized: "migration.unreachableMessage"))
|
||||
Text(importedInstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Content
|
||||
|
||||
@ViewBuilder
|
||||
private var importContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
SettingsFormContainer {
|
||||
SettingsFormSection("migration.selectToImport") {
|
||||
ForEach(items) { item in
|
||||
MigrationImportRow(item: item) {
|
||||
toggleItem(item)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "migration.accountsHint"))
|
||||
Text(String(localized: "migration.settingsFooter"))
|
||||
private var accountList: some View {
|
||||
Form {
|
||||
Section {
|
||||
ForEach(items) { item in
|
||||
LegacyAccountImportRow(
|
||||
item: item,
|
||||
state: stateBinding(for: item)
|
||||
) {
|
||||
importLegacyAccount(item)
|
||||
} onRemove: {
|
||||
pendingRemoval = item
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "migration.accounts.section"))
|
||||
} footer: {
|
||||
Text(String(localized: "migration.accounts.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 loadLegacyAccounts() {
|
||||
let loadedItems = legacyMigrationService?.parseLegacyAccountsForImport() ?? []
|
||||
items = loadedItems
|
||||
for item in loadedItems 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
|
||||
)
|
||||
appEnvironment?.toastManager.showSuccess(
|
||||
String(localized: "migration.accounts.imported.title"),
|
||||
subtitle: importedInstance.displayName
|
||||
)
|
||||
importedInstanceName = importedInstance.displayName
|
||||
showingImportSuccess = true
|
||||
removeResolvedItem(item)
|
||||
} catch APIError.unauthorized {
|
||||
setImportError(String(localized: "login.error.invalidCredentials"), for: item)
|
||||
} catch {
|
||||
setImportError(error.localizedDescription, for: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 removeLegacyAccount(_ item: LegacyAccountImportItem) {
|
||||
legacyMigrationService?.removeLegacyAccount(item)
|
||||
removeResolvedItem(item)
|
||||
}
|
||||
|
||||
private func removeResolvedItem(_ item: LegacyAccountImportItem) {
|
||||
items.removeAll { $0.legacyAccountID == item.legacyAccountID }
|
||||
rowStates.removeValue(forKey: item.legacyAccountID)
|
||||
}
|
||||
}
|
||||
|
||||
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: iconName)
|
||||
.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()
|
||||
}
|
||||
|
||||
// Bottom bar with import button
|
||||
VStack(spacing: 12) {
|
||||
Divider()
|
||||
credentialsFields
|
||||
|
||||
Button(action: performImport) {
|
||||
if isImporting {
|
||||
HStack(spacing: 8) {
|
||||
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"))
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
Text(String(localized: "migration.import"))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(selectedCount > 0 ? Color.accentColor : Color.gray)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.disabled(selectedCount == 0 || isImporting)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canImport)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.background(Color(.systemGray).opacity(0.2))
|
||||
#elseif os(macOS)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
#else
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
#endif
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Result Sheet
|
||||
|
||||
@ViewBuilder
|
||||
private var resultSheet: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
if let result = lastResult {
|
||||
Spacer()
|
||||
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()
|
||||
|
||||
Image(systemName: result.isFullSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(result.isFullSuccess ? .green : .orange)
|
||||
|
||||
Text(String(localized: "migration.partialTitle"))
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text(String(
|
||||
format: NSLocalizedString("migration.partialMessage %lld %lld", comment: "Import result count"),
|
||||
result.succeeded.count,
|
||||
result.totalProcessed
|
||||
))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if !result.failed.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "migration.failedItems"))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
ForEach(result.failed, id: \.item.id) { failure in
|
||||
HStack {
|
||||
Text(failure.item.displayName)
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text(failure.error.localizedDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
#if os(tvOS)
|
||||
.background(Color(.systemGray).opacity(0.2))
|
||||
#elseif os(macOS)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
#else
|
||||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
#endif
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
if !result.failed.isEmpty {
|
||||
Button(action: retryFailed) {
|
||||
Text(String(localized: "migration.retry"))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: finishImport) {
|
||||
Text(String(localized: "migration.continue"))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(result.failed.isEmpty ? Color.accentColor : Color.secondary)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 500, minHeight: 450)
|
||||
SecureField(String(localized: "login.password"), text: $state.password)
|
||||
.textContentType(.password)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadLegacyData() {
|
||||
items = legacyMigrationService?.parseLegacyData() ?? []
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func toggleItem(_ item: LegacyImportItem) {
|
||||
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
|
||||
let wasSelected = items[index].isSelected
|
||||
items[index].isSelected.toggle()
|
||||
|
||||
if !wasSelected && items[index].reachabilityStatus == .unknown {
|
||||
checkReachability(for: items[index])
|
||||
private var usernameLabel: String {
|
||||
switch item.instanceType {
|
||||
case .invidious:
|
||||
return String(localized: "login.email")
|
||||
default:
|
||||
return String(localized: "login.username")
|
||||
}
|
||||
}
|
||||
|
||||
private func checkReachability(for item: LegacyImportItem) {
|
||||
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
|
||||
items[index].reachabilityStatus = .checking
|
||||
|
||||
Task {
|
||||
let isReachable = await legacyMigrationService?.checkReachability(for: item) ?? false
|
||||
|
||||
guard let currentIndex = items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
items[currentIndex].reachabilityStatus = isReachable ? .reachable : .unreachable
|
||||
|
||||
if !isReachable && items[currentIndex].isSelected {
|
||||
pendingUnreachableItem = items[currentIndex]
|
||||
showingUnreachableAlert = true
|
||||
}
|
||||
private var iconName: String {
|
||||
switch item.instanceType {
|
||||
case .invidious:
|
||||
return "server.rack"
|
||||
case .piped:
|
||||
return "cloud"
|
||||
default:
|
||||
return "globe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performImport() {
|
||||
guard let service = legacyMigrationService else { return }
|
||||
private struct LegacyAccountRowState: Equatable {
|
||||
var username: String
|
||||
var password = ""
|
||||
var errorMessage: String?
|
||||
var isImporting = false
|
||||
}
|
||||
|
||||
isImporting = true
|
||||
|
||||
Task {
|
||||
let result = await service.importItems(items)
|
||||
lastResult = result
|
||||
|
||||
isImporting = false
|
||||
|
||||
if result.isFullSuccess {
|
||||
dismiss()
|
||||
} else {
|
||||
showingResultSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retryFailed() {
|
||||
guard let result = lastResult else { return }
|
||||
|
||||
for index in items.indices {
|
||||
let isFailed = result.failed.contains(where: { $0.item.id == items[index].id })
|
||||
items[index].isSelected = isFailed
|
||||
}
|
||||
|
||||
showingResultSheet = false
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
performImport()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishImport() {
|
||||
showingResultSheet = false
|
||||
dismiss()
|
||||
struct LegacyDataImportView: View {
|
||||
var body: some View {
|
||||
LegacyAccountsImportView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +297,7 @@ struct LegacyDataImportView: View {
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
LegacyDataImportView()
|
||||
LegacyAccountsImportView()
|
||||
}
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user