diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 1eb8549b..dd95cb6b 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -277,6 +277,7 @@ final class AppEnvironment { // Initialize Legacy Migration Service self.legacyMigrationService = LegacyDataMigrationService( instancesManager: instances, + basicAuthCredentialsManager: basicAuthCreds, httpClient: client ) diff --git a/Yattee/Core/Notifications.swift b/Yattee/Core/Notifications.swift new file mode 100644 index 00000000..a3ef70ae --- /dev/null +++ b/Yattee/Core/Notifications.swift @@ -0,0 +1,13 @@ +// +// Notifications.swift +// Yattee +// +// App-wide notification names. +// + +import Foundation + +extension Notification.Name { + static let showSettings = Notification.Name("showSettings") + static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet") +} diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 5dc4eb8d..abe111a9 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -5532,6 +5532,7 @@ } }, "migration.skip" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5542,6 +5543,7 @@ } }, "migration.subtitle" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5552,6 +5554,7 @@ } }, "migration.title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5733,6 +5736,7 @@ }, "onboarding.cloud.complete.description" : { "comment" : "iCloud sync complete description on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5744,6 +5748,7 @@ }, "onboarding.cloud.complete.title" : { "comment" : "iCloud sync complete title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5755,6 +5760,7 @@ }, "onboarding.cloud.description" : { "comment" : "iCloud sync description on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5766,6 +5772,7 @@ }, "onboarding.cloud.enable" : { "comment" : "Enable iCloud button on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5777,6 +5784,7 @@ }, "onboarding.cloud.error.title" : { "comment" : "iCloud sync error title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5788,6 +5796,7 @@ }, "onboarding.cloud.skip" : { "comment" : "Skip iCloud button on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5799,6 +5808,7 @@ }, "onboarding.cloud.syncing.downloading" : { "comment" : "Download progress text on iCloud onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5832,6 +5842,7 @@ }, "onboarding.cloud.title" : { "comment" : "iCloud sync title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5843,6 +5854,7 @@ }, "onboarding.cloud.unavailable" : { "comment" : "iCloud unavailable message on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5854,6 +5866,7 @@ }, "onboarding.continue" : { "comment" : "Continue button on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5865,6 +5878,7 @@ }, "onboarding.skip" : { "comment" : "Skip button on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5876,6 +5890,7 @@ }, "onboarding.sources.description" : { "comment" : "Settings description on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5887,6 +5902,7 @@ }, "onboarding.sources.goToSettings" : { "comment" : "Explore settings button on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5898,6 +5914,7 @@ }, "onboarding.sources.later" : { "comment" : "Get started button on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5909,6 +5926,7 @@ }, "onboarding.sources.title" : { "comment" : "Settings title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5920,6 +5938,7 @@ }, "onboarding.title.privacy.description" : { "comment" : "Privacy feature description on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5931,6 +5950,7 @@ }, "onboarding.title.privacy.title" : { "comment" : "Privacy feature title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5942,6 +5962,7 @@ }, "onboarding.title.sources.description" : { "comment" : "Sources feature description on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5953,6 +5974,7 @@ }, "onboarding.title.sources.title" : { "comment" : "Sources feature title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5964,6 +5986,7 @@ }, "onboarding.title.sync.description" : { "comment" : "Sync feature description on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5975,6 +5998,7 @@ }, "onboarding.title.sync.title" : { "comment" : "Sync feature title on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5986,6 +6010,7 @@ }, "onboarding.title.tagline" : { "comment" : "App tagline on onboarding", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -9414,17 +9439,6 @@ } } }, - "settings.advanced.showOnboarding" : { - "comment" : "Button to show onboarding again", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show Onboarding" - } - } - } - }, "settings.advanced.storage.cleanupComplete" : { "comment" : "Alert title", "localizations" : { diff --git a/Yattee/Services/Migration/LegacyDataMigrationService.swift b/Yattee/Services/Migration/LegacyDataMigrationService.swift index 76111b8f..bda4b41a 100644 --- a/Yattee/Services/Migration/LegacyDataMigrationService.swift +++ b/Yattee/Services/Migration/LegacyDataMigrationService.swift @@ -24,6 +24,7 @@ final class LegacyDataMigrationService { // MARK: - Dependencies private let instancesManager: InstancesManager + private let basicAuthCredentialsManager: BasicAuthCredentialsManager private let httpClient: HTTPClient // MARK: - State @@ -38,9 +39,11 @@ final class LegacyDataMigrationService { init( instancesManager: InstancesManager, + basicAuthCredentialsManager: BasicAuthCredentialsManager, httpClient: HTTPClient = HTTPClient() ) { self.instancesManager = instancesManager + self.basicAuthCredentialsManager = basicAuthCredentialsManager self.httpClient = httpClient } @@ -178,32 +181,74 @@ final class LegacyDataMigrationService { } /// Imports a single item into the v2 system. + /// If the legacy URL contains embedded basic-auth credentials + /// (e.g. `https://user:pass@host`), they are stripped from the URL + /// and stored in the Keychain via `BasicAuthCredentialsManager`. private func importItem(_ item: LegacyImportItem) throws { - // Create the new Instance (without credentials - user needs to sign in again) + let (cleanURL, credentials) = Self.splitCredentials(from: item.url) + let instance = Instance( id: UUID(), type: item.instanceType, - url: item.url, + url: cleanURL, name: item.name, isEnabled: true, proxiesVideos: item.proxiesVideos ) - // Add to instances manager instancesManager.add(instance) + + if let credentials { + basicAuthCredentialsManager.setCredentials( + username: credentials.username, + password: credentials.password, + for: instance + ) + } } /// Checks if an import item would be a duplicate of an existing instance. private func isDuplicate(_ item: LegacyImportItem) -> Bool { - // Check if an instance with the same URL and type already exists + let (cleanURL, _) = Self.splitCredentials(from: item.url) for existing in instancesManager.instances { - if existing.url.host == item.url.host && existing.type == item.instanceType { + if existing.url.host == cleanURL.host && existing.type == item.instanceType { return true } } return false } + // MARK: - Credential Splitting + + /// Splits embedded basic-auth credentials out of a URL. + /// v1 supported credentials embedded directly in the URL (e.g. `https://user:pass@host`); + /// v2 stores them separately in the Keychain. + /// - Returns: The cleaned URL (no user/password) and the extracted credentials, if any. + static func splitCredentials(from url: URL) -> (cleanURL: URL, credentials: BasicAuthCredential?) { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let user = components.user, !user.isEmpty else { + return (url, nil) + } + + let password = components.password ?? "" + components.user = nil + components.password = nil + + let cleaned = components.url ?? url + return (cleaned, BasicAuthCredential(username: user, password: password)) + } + + // MARK: - Auto-Import + + /// Silently imports any legacy v1 data on first launch. + /// Skips unreachable-checks and UI; just imports everything and deletes the legacy keys. + /// Safe to call repeatedly — if there is no legacy data left, this is a no-op. + func autoImportIfNeeded() async { + guard let items = parseLegacyData() else { return } + _ = await importItems(items) + deleteLegacyData() + } + // MARK: - Cleanup /// Deletes the legacy v1 data from UserDefaults. diff --git a/Yattee/Views/ICloudSyncProgressView.swift b/Yattee/Views/ICloudSyncProgressView.swift new file mode 100644 index 00000000..e8317384 --- /dev/null +++ b/Yattee/Views/ICloudSyncProgressView.swift @@ -0,0 +1,49 @@ +// +// ICloudSyncProgressView.swift +// Yattee +// +// Blocking progress overlay shown during the first-launch iCloud sync. +// + +import SwiftUI + +struct ICloudSyncProgressView: View { + @Environment(\.appEnvironment) private var appEnvironment + + private var cloudKitSync: CloudKitSyncEngine? { appEnvironment?.cloudKitSync } + + var body: some View { + VStack(spacing: 24) { + ProgressView() + .controlSize(.large) + + Text(String(localized: "onboarding.cloud.syncing.title")) + .font(.title2) + .fontWeight(.semibold) + + Text(progressText) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + } + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + #if os(macOS) + .frame(minWidth: 420, minHeight: 260) + #endif + .interactiveDismissDisabled() + } + + private var progressText: String { + if let upload = cloudKitSync?.uploadProgress { + return upload.displayText + } + return String(localized: "onboarding.cloud.syncing.preparing") + } +} + +#Preview { + ICloudSyncProgressView() + .appEnvironment(.preview) +} diff --git a/Yattee/Views/Onboarding/OnboardingCloudScreen.swift b/Yattee/Views/Onboarding/OnboardingCloudScreen.swift deleted file mode 100644 index 967ebba2..00000000 --- a/Yattee/Views/Onboarding/OnboardingCloudScreen.swift +++ /dev/null @@ -1,347 +0,0 @@ -// -// OnboardingCloudScreen.swift -// Yattee -// -// Second onboarding screen for iCloud sync configuration. -// - -import CloudKit -import SwiftUI - -struct OnboardingCloudScreen: View { - @Environment(\.appEnvironment) private var appEnvironment - let onContinue: () -> Void - - private enum ScreenState: Equatable { - case initial // Show enable/skip buttons - case syncing // Show progress view - case complete // Sync finished, show continue - case error(String) // Show error with continue option - } - - @State private var screenState: ScreenState = .initial - @State private var iCloudAvailable: Bool? - @State private var isChecking = true - - private var settingsManager: SettingsManager? { - appEnvironment?.settingsManager - } - - private var cloudKitSync: CloudKitSyncEngine? { - appEnvironment?.cloudKitSync - } - - var body: some View { - VStack(spacing: 32) { - Spacer() - - // Content based on state - switch screenState { - case .initial: - initialView - case .syncing: - syncingView - case .complete: - completeView - case .error(let message): - errorView(message) - } - - Spacer() - - // Buttons based on state - buttonsForState - } - .padding() - .task { - await checkiCloudAvailability() - } - .onChange(of: cloudKitSync?.uploadProgress?.isComplete) { _, newValue in - if newValue == true { - withAnimation { - screenState = .complete - } - } - } - } - - // MARK: - Initial View - - @ViewBuilder - private var initialView: some View { - // iCloud icon - Image(systemName: "icloud") - .font(.system(size: 80)) - .foregroundStyle(Color.accentColor) - - // Title and description - VStack(spacing: 12) { - Text(String(localized: "onboarding.cloud.title")) - .font(.title) - .fontWeight(.bold) - - Text(String(localized: "onboarding.cloud.description")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - // iCloud status indicator - VStack(spacing: 16) { - if isChecking { - ProgressView() - .controlSize(.large) - } else if iCloudAvailable == false { - // iCloud unavailable - VStack(spacing: 12) { - Image(systemName: "exclamationmark.icloud") - .font(.title) - .foregroundStyle(.orange) - - Text(String(localized: "onboarding.cloud.unavailable")) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .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: 12)) - .padding(.horizontal) - } - } - } - - // MARK: - Syncing View - - @ViewBuilder - private var syncingView: some View { - // Animated iCloud icon - Image(systemName: "icloud") - .font(.system(size: 80)) - .foregroundStyle(Color.accentColor) - .symbolEffect(.pulse) - - // Syncing title and progress - VStack(spacing: 12) { - Text(String(localized: "onboarding.cloud.syncing.title")) - .font(.title) - .fontWeight(.bold) - - // Show appropriate progress text based on sync phase - if cloudKitSync?.isReceivingChanges == true { - Text(String(localized: "onboarding.cloud.syncing.downloading")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } else if let progress = cloudKitSync?.uploadProgress { - Text(progress.displayText) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } else { - Text(String(localized: "onboarding.cloud.syncing.preparing")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - ProgressView() - .controlSize(.large) - .padding(.top, 8) - } - } - - // MARK: - Complete View - - @ViewBuilder - private var completeView: some View { - // Checkmark iCloud icon - Image(systemName: "checkmark.icloud") - .font(.system(size: 80)) - .foregroundStyle(.green) - - // Complete title and description - VStack(spacing: 12) { - Text(String(localized: "onboarding.cloud.complete.title")) - .font(.title) - .fontWeight(.bold) - - Text(String(localized: "onboarding.cloud.complete.description")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - // MARK: - Error View - - @ViewBuilder - private func errorView(_ message: String) -> some View { - // Warning iCloud icon - Image(systemName: "exclamationmark.icloud") - .font(.system(size: 80)) - .foregroundStyle(.orange) - - // Error title and message - VStack(spacing: 12) { - Text(String(localized: "onboarding.cloud.error.title")) - .font(.title) - .fontWeight(.bold) - - Text(message) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - - // MARK: - Buttons - - @ViewBuilder - private var buttonsForState: some View { - switch screenState { - case .initial: - if isChecking { - EmptyView() - } else if iCloudAvailable == true { - // Two buttons: Enable iCloud (primary) and Skip (secondary) - VStack(spacing: 12) { - // Primary: Enable iCloud - Button(action: enableAndStartSync) { - Text(String(localized: "onboarding.cloud.enable")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(Color.accentColor) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - #endif - - // Secondary: Skip for now - Button(action: onContinue) { - Text(String(localized: "onboarding.cloud.skip")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color(.systemGray).opacity(0.2)) - #elseif os(macOS) - .background(Color(nsColor: .controlBackgroundColor)) - #else - .background(Color(uiColor: .secondarySystemBackground)) - #endif - .foregroundStyle(.primary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - #endif - } - .padding(.horizontal) - .padding(.bottom) - } else { - // iCloud unavailable - just show Continue - Button(action: onContinue) { - Text(String(localized: "onboarding.continue")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(Color.accentColor) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - #endif - .padding(.horizontal) - .padding(.bottom) - } - - case .syncing: - // No continue button during sync - user must wait - // (toolbar Skip still available to exit onboarding) - EmptyView() - - case .complete, .error: - // Continue button - Button(action: onContinue) { - Text(String(localized: "onboarding.continue")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(Color.accentColor) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - #endif - .padding(.horizontal) - .padding(.bottom) - } - } - - // MARK: - iCloud - - private func checkiCloudAvailability() async { - do { - let status = try await CKContainer.default().accountStatus() - iCloudAvailable = (status == .available) - } catch { - iCloudAvailable = false - } - isChecking = false - } - - private func enableAndStartSync() { - // Transition to syncing state - withAnimation { - screenState = .syncing - } - - settingsManager?.iCloudSyncEnabled = true - settingsManager?.enableAllSyncCategories() - - // Enable CloudKit sync engine then trigger initial upload - Task { - await appEnvironment?.cloudKitSync.enable() - await appEnvironment?.cloudKitSync.performInitialUpload() - } - - // Sync non-CloudKit data - settingsManager?.replaceWithiCloudData() - appEnvironment?.instancesManager.replaceWithiCloudData() - appEnvironment?.mediaSourcesManager.replaceWithiCloudData() - } -} - -// MARK: - Preview - -#Preview { - OnboardingCloudScreen(onContinue: {}) - .appEnvironment(.preview) -} diff --git a/Yattee/Views/Onboarding/OnboardingMigrationScreen.swift b/Yattee/Views/Onboarding/OnboardingMigrationScreen.swift deleted file mode 100644 index 1e830b8f..00000000 --- a/Yattee/Views/Onboarding/OnboardingMigrationScreen.swift +++ /dev/null @@ -1,360 +0,0 @@ -// -// OnboardingMigrationScreen.swift -// Yattee -// -// Migration screen in the onboarding flow for importing v1 data. -// - -import SwiftUI - -struct OnboardingMigrationScreen: View { - @Environment(\.appEnvironment) private var appEnvironment - - let onContinue: () -> Void - let onSkip: () -> Void - - @Binding var items: [LegacyImportItem] - @State private var isImporting = false - @State private var importProgress: Double = 0.0 - @State private var showingResultSheet = false - @State private var lastResult: MigrationResult? - @State private var showingUnreachableAlert = false - @State private var pendingUnreachableItem: LegacyImportItem? - - private var legacyMigrationService: LegacyDataMigrationService? { - appEnvironment?.legacyMigrationService - } - - private var selectedCount: Int { - items.filter(\.isSelected).count - } - - var body: some View { - VStack(spacing: 24) { - Spacer() - - // Header - VStack(spacing: 12) { - Image(systemName: "arrow.up.doc") - .font(.system(size: 50)) - .foregroundStyle(Color.accentColor) - - Text(String(localized: "migration.title")) - .font(.title) - .fontWeight(.bold) - - Text(String(localized: "migration.subtitle")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - // List of items - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "migration.selectToImport")) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal) - - ScrollView { - LazyVStack(spacing: 0) { - ForEach(items) { item in - MigrationImportRow(item: item) { - toggleItem(item) - } - .padding(.horizontal) - - if item.id != items.last?.id { - Divider() - .padding(.leading, 56) - } - } - } - } - .frame(maxHeight: 280) - #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: 12)) - .padding(.horizontal) - - // Hint about re-adding accounts - Text(String(localized: "migration.accountsHint")) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .padding(.horizontal) - } - - Spacer() - - // Buttons - VStack(spacing: 12) { - Button(action: performImport) { - if isImporting { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text(String(localized: "migration.importing")) - } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(Color.accentColor) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } else { - Text(String(localized: "migration.import")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(selectedCount > 0 ? Color.accentColor : Color.gray) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - } - #if os(tvOS) - .buttonStyle(.card) - #endif - .disabled(selectedCount == 0 || isImporting) - - Button(action: onSkip) { - Text(String(localized: "migration.skip")) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .disabled(isImporting) - } - .padding(.horizontal) - .padding(.bottom) - } - .padding() - .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) { - // Deselect the unreachable item - if let item = pendingUnreachableItem, - let index = items.firstIndex(where: { $0.id == item.id }) { - items[index].isSelected = false - } - } - } message: { - Text(String(localized: "migration.unreachableMessage")) - } - } - - // MARK: - Result Sheet - - @ViewBuilder - private var resultSheet: some View { - NavigationStack { - VStack(spacing: 24) { - if let result = lastResult { - Spacer() - - // Icon based on result - 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() - } - } - - // MARK: - Actions - - 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 selecting and not yet checked, trigger reachability check - if !wasSelected && items[index].reachabilityStatus == .unknown { - checkReachability(for: items[index]) - } - } - - 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 - - // Show alert if unreachable and still selected - if !isReachable && items[currentIndex].isSelected { - pendingUnreachableItem = items[currentIndex] - showingUnreachableAlert = true - } - } - } - - private func performImport() { - guard let service = legacyMigrationService else { return } - - isImporting = true - - Task { - let result = await service.importItems(items) - lastResult = result - - isImporting = false - - if result.isFullSuccess { - onContinue() - } else { - // Show result sheet for partial failures - showingResultSheet = true - } - } - } - - private func retryFailed() { - guard let result = lastResult, legacyMigrationService != nil else { return } - - // Update items to only have failed items selected - for item in items { - if let index = items.firstIndex(where: { $0.id == item.id }) { - let isFailed = result.failed.contains(where: { $0.item.id == item.id }) - items[index].isSelected = isFailed - } - } - - showingResultSheet = false - - // Re-run import - Task { - try? await Task.sleep(for: .milliseconds(300)) - performImport() - } - } - - private func finishImport() { - showingResultSheet = false - onContinue() - } -} - -// MARK: - Preview - -#Preview { - @Previewable @State var items: [LegacyImportItem] = [ - LegacyImportItem( - id: UUID(), - legacyInstanceID: "1", - instanceType: .invidious, - url: URL(string: "https://invidious.example.com")!, - name: "My Invidious" - ), - LegacyImportItem( - id: UUID(), - legacyInstanceID: "2", - instanceType: .piped, - url: URL(string: "https://piped.example.com")!, - name: nil - ) - ] - - OnboardingMigrationScreen( - onContinue: {}, - onSkip: {}, - items: $items - ) - .appEnvironment(.preview) -} diff --git a/Yattee/Views/Onboarding/OnboardingSheetView.swift b/Yattee/Views/Onboarding/OnboardingSheetView.swift deleted file mode 100644 index ad0d8940..00000000 --- a/Yattee/Views/Onboarding/OnboardingSheetView.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// OnboardingSheetView.swift -// Yattee -// -// Container view for the onboarding flow with TabView and page dots. -// - -import SwiftUI - -struct OnboardingSheetView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appEnvironment) private var appEnvironment - - @State private var currentPage = 0 - @State private var legacyItems: [LegacyImportItem]? - @State private var hasCheckedLegacyData = false - - private var totalPages: Int { - hasLegacyData ? 4 : 3 - } - - private var hasLegacyData: Bool { - legacyItems?.isEmpty == false - } - - private var settingsManager: SettingsManager? { - appEnvironment?.settingsManager - } - - private var legacyMigrationService: LegacyDataMigrationService? { - appEnvironment?.legacyMigrationService - } - - var body: some View { - TabView(selection: $currentPage) { - OnboardingTitleScreen(onContinue: advanceToNextPage) - .tag(0) - - // Cloud screen is now always page 1 (before migration) - // This ensures iCloud instances are synced before migration runs, - // so isDuplicate() correctly detects duplicates against iCloud-synced instances - OnboardingCloudScreen(onContinue: advanceToNextPage) - .tag(1) - - // Migration screen is now page 2 (when present) - if hasLegacyData, let binding = Binding($legacyItems) { - OnboardingMigrationScreen( - onContinue: advanceToNextPage, - onSkip: advanceToNextPage, - items: binding - ) - .tag(2) - } - - OnboardingSourcesScreen( - onGoToSources: goToSettings, - onClose: completeOnboarding - ) - .tag(hasLegacyData ? 3 : 2) - } - #if os(tvOS) - .tabViewStyle(.page) - #elseif os(iOS) - .tabViewStyle(.page(indexDisplayMode: .never)) - #endif - #if os(tvOS) - .background(Color.black.ignoresSafeArea()) - .overlay(alignment: .topLeading) { - Button(String(localized: "onboarding.skip")) { - completeOnboarding() - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .padding(40) - } - #endif - .interactiveDismissDisabled() - #if !os(tvOS) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "onboarding.skip")) { - completeOnboarding() - } - } - } - #endif - .task { - guard !hasCheckedLegacyData else { return } - hasCheckedLegacyData = true - legacyItems = legacyMigrationService?.parseLegacyData() - } - } - - // MARK: - Navigation - - private func advanceToNextPage() { - withAnimation { - if currentPage < totalPages - 1 { - currentPage += 1 - } else { - completeOnboarding() - } - } - } - - private func completeOnboarding() { - settingsManager?.onboardingCompleted = true - dismiss() - } - - private func goToSettings() { - settingsManager?.onboardingCompleted = true - dismiss() - - // Navigate to settings after dismiss completes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - NotificationCenter.default.post(name: .showSettings, object: nil) - } - } -} - -// MARK: - Notification - -extension Notification.Name { - static let showOnboarding = Notification.Name("showOnboarding") - static let showSettings = Notification.Name("showSettings") - static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet") -} - -// MARK: - Preview - -#Preview { - @Previewable @State var sheetPresented: Bool = true - - VStack { - - }.sheet(isPresented: $sheetPresented) { - OnboardingSheetView() - .appEnvironment(.preview) - } -} diff --git a/Yattee/Views/Onboarding/OnboardingSourcesScreen.swift b/Yattee/Views/Onboarding/OnboardingSourcesScreen.swift deleted file mode 100644 index c01476a7..00000000 --- a/Yattee/Views/Onboarding/OnboardingSourcesScreen.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// OnboardingSourcesScreen.swift -// Yattee -// -// Third onboarding screen prompting users to explore settings. -// - -import SwiftUI - -struct OnboardingSourcesScreen: View { - let onGoToSources: () -> Void - let onClose: () -> Void - - var body: some View { - VStack(spacing: 32) { - Spacer() - - // Settings icon - Image(systemName: "gearshape") - .font(.system(size: 80)) - .foregroundStyle(Color.accentColor) - - // Title and description - VStack(spacing: 12) { - Text(String(localized: "onboarding.sources.title")) - .font(.title) - .fontWeight(.bold) - - Text(String(localized: "onboarding.sources.description")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - Spacer() - - // Buttons - VStack(spacing: 12) { - // Primary: Explore Settings - Button(action: onGoToSources) { - Text(String(localized: "onboarding.sources.goToSettings")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(Color.accentColor) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - #endif - - // Secondary: Get Started - Button(action: onClose) { - Text(String(localized: "onboarding.sources.later")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color(.systemGray).opacity(0.2)) - #elseif os(macOS) - .background(Color(nsColor: .controlBackgroundColor)) - #else - .background(Color(uiColor: .secondarySystemBackground)) - #endif - .foregroundStyle(.primary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - #endif - } - .padding(.horizontal) - .padding(.bottom) - } - .padding() - } -} - -// MARK: - Preview - -#Preview { - OnboardingSourcesScreen( - onGoToSources: {}, - onClose: {} - ) -} diff --git a/Yattee/Views/Onboarding/OnboardingTitleScreen.swift b/Yattee/Views/Onboarding/OnboardingTitleScreen.swift deleted file mode 100644 index 8d1b63fd..00000000 --- a/Yattee/Views/Onboarding/OnboardingTitleScreen.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// OnboardingTitleScreen.swift -// Yattee -// -// First onboarding screen with app logo, title, and feature highlights. -// - -import SwiftUI - -struct OnboardingTitleScreen: View { - let onContinue: () -> Void - - #if os(tvOS) - @FocusState private var continueButtonFocused: Bool - #endif - - var body: some View { - GeometryReader { geometry in - let iconSize = min(max(geometry.size.height * 0.13, 60), 140) - let cornerRadius = iconSize * 0.23 - - VStack(spacing: 32) { - Spacer() - - // App icon and title - VStack { - Image("AppIconPreview") - .resizable() - .scaledToFit() - .frame(width: iconSize, height: iconSize) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - - Text(verbatim: "Yattee") - .font(.largeTitle) - .fontWeight(.bold) - - Text(String(localized: "onboarding.title.tagline")) - .font(.title3) - .foregroundStyle(.secondary) - } - - Spacer() - - // Feature highlights - VStack(alignment: .leading, spacing: 20) { - FeatureRow( - icon: "lock.shield", - title: String(localized: "onboarding.title.privacy.title"), - description: String(localized: "onboarding.title.privacy.description") - ) - - FeatureRow( - icon: "server.rack", - title: String(localized: "onboarding.title.sources.title"), - description: String(localized: "onboarding.title.sources.description") - ) - - FeatureRow( - icon: "icloud", - title: String(localized: "onboarding.title.sync.title"), - description: String(localized: "onboarding.title.sync.description") - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - - - // Continue button - Button(action: onContinue) { - Text(String(localized: "onboarding.continue")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - #if os(tvOS) - .background(Color.accentColor.opacity(0.2)) - #else - .background(Color.accentColor) - .foregroundStyle(.white) - #endif - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - #if os(tvOS) - .buttonStyle(.card) - .focused($continueButtonFocused) - #endif - .padding(.horizontal) - .padding(.bottom) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - #if os(tvOS) - .onAppear { continueButtonFocused = true } - #endif - } - } -} - -// MARK: - Feature Row - -private struct FeatureRow: View { - let icon: String - let title: String - let description: String - - var body: some View { - HStack(alignment: .top, spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.headline) - - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -// MARK: - Preview - -#Preview { - OnboardingTitleScreen(onContinue: {}) -} diff --git a/Yattee/Views/Settings/DeveloperSettingsView.swift b/Yattee/Views/Settings/DeveloperSettingsView.swift index 958e185f..abb87c79 100644 --- a/Yattee/Views/Settings/DeveloperSettingsView.swift +++ b/Yattee/Views/Settings/DeveloperSettingsView.swift @@ -176,15 +176,6 @@ struct DeveloperSettingsView: View { #if !os(tvOS) notificationTestingSection #endif - - Section { - Button { - NotificationCenter.default.post(name: .showOnboarding, object: nil) - appEnvironment?.navigationCoordinator.dismissSettings() - } label: { - Label(String(localized: "settings.advanced.showOnboarding"), systemImage: "hand.wave") - } - } } @ViewBuilder diff --git a/Yattee/Views/Onboarding/MigrationImportRow.swift b/Yattee/Views/Settings/MigrationImportRow.swift similarity index 100% rename from Yattee/Views/Onboarding/MigrationImportRow.swift rename to Yattee/Views/Settings/MigrationImportRow.swift diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 7ea32ef5..73f73324 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import CloudKit import Nuke #if canImport(UIKit) import UIKit @@ -42,8 +43,9 @@ struct YatteeApp: App { @State private var showingDeepLinkDownloadSheet = false #endif - // Onboarding state - @State private var showingOnboarding = false + // First-launch state + @State private var showingICloudAlert = false + @State private var showingICloudProgress = false @State private var showingSettings = false @State private var showingOpenLinkSheet = false @@ -119,23 +121,33 @@ struct YatteeApp: App { } #endif #endif - // Onboarding sheet - #if os(tvOS) - .fullScreenCover(isPresented: $showingOnboarding) { - NavigationStack { - OnboardingSheetView() - .appEnvironment(appEnvironment) + // First-launch iCloud sync prompt + .alert( + String(localized: "settings.icloud.enable.confirmation.title"), + isPresented: $showingICloudAlert + ) { + Button(String(localized: "settings.icloud.enable.confirmation.action")) { + enableICloudAndWait() } + Button(String(localized: "common.cancel"), role: .cancel) { + appEnvironment.settingsManager.onboardingCompleted = true + } + } message: { + Text(String(localized: "settings.icloud.enable.confirmation.message")) + } + #if os(macOS) + .sheet(isPresented: $showingICloudProgress) { + ICloudSyncProgressView() + .appEnvironment(appEnvironment) + .interactiveDismissDisabled() } #else - .sheet(isPresented: $showingOnboarding) { - NavigationStack { - OnboardingSheetView() - .appEnvironment(appEnvironment) - } - .presentationDetents([.large]) - .interactiveDismissDisabled() + .fullScreenCover(isPresented: $showingICloudProgress) { + ICloudSyncProgressView() + .appEnvironment(appEnvironment) } + #endif + #if !os(tvOS) .sheet(isPresented: $showingSettings) { SettingsView() .appEnvironment(appEnvironment) @@ -145,9 +157,6 @@ struct YatteeApp: App { .appEnvironment(appEnvironment) } #endif - .onReceive(NotificationCenter.default.publisher(for: .showOnboarding)) { _ in - showingOnboarding = true - } .onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in showingSettings = true } @@ -276,15 +285,46 @@ struct YatteeApp: App { // Auto-delete old history entries based on retention setting performHistoryCleanup() - // Show onboarding on first launch + // Run first-launch tasks: silently import v1 data, then offer iCloud sync. if !appEnvironment.settingsManager.onboardingCompleted { - // Small delay to let the main UI settle - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - showingOnboarding = true + Task { + await appEnvironment.legacyMigrationService.autoImportIfNeeded() + await appEnvironment.cloudKitSync.refreshAccountStatus() + + if appEnvironment.cloudKitSync.accountStatus == .available { + // Small delay so the main UI has time to settle before the alert. + try? await Task.sleep(nanoseconds: 500_000_000) + showingICloudAlert = true + } else { + appEnvironment.settingsManager.onboardingCompleted = true + } } } } + /// Enables iCloud sync and waits for the initial upload to complete, + /// showing a blocking progress overlay while sync runs. + private func enableICloudAndWait() { + showingICloudProgress = true + Task { + let settings = appEnvironment.settingsManager + let cloudKit = appEnvironment.cloudKitSync + + settings.iCloudSyncEnabled = true + settings.enableAllSyncCategories() + + await cloudKit.enable() + await cloudKit.performInitialUpload() + + settings.replaceWithiCloudData() + appEnvironment.instancesManager.replaceWithiCloudData() + appEnvironment.mediaSourcesManager.replaceWithiCloudData() + + settings.onboardingCompleted = true + showingICloudProgress = false + } + } + /// Delete old watch history entries based on the retention setting. private func performHistoryCleanup() { let retentionDays = appEnvironment.settingsManager.historyRetentionDays