mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
Replace onboarding flow with silent v1 import and iCloud alert
Delete the multi-page onboarding sheet. On first launch the app now silently imports any v1 instances from UserDefaults (splitting embedded basic-auth credentials out of the URL and into the Keychain) and then, if the device is signed in to iCloud, shows a single alert offering to enable sync. Accepting shows a blocking progress overlay until the initial upload completes.
This commit is contained in:
@@ -277,6 +277,7 @@ final class AppEnvironment {
|
|||||||
// Initialize Legacy Migration Service
|
// Initialize Legacy Migration Service
|
||||||
self.legacyMigrationService = LegacyDataMigrationService(
|
self.legacyMigrationService = LegacyDataMigrationService(
|
||||||
instancesManager: instances,
|
instancesManager: instances,
|
||||||
|
basicAuthCredentialsManager: basicAuthCreds,
|
||||||
httpClient: client
|
httpClient: client
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
13
Yattee/Core/Notifications.swift
Normal file
13
Yattee/Core/Notifications.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -5532,6 +5532,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"migration.skip" : {
|
"migration.skip" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5542,6 +5543,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"migration.subtitle" : {
|
"migration.subtitle" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5552,6 +5554,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"migration.title" : {
|
"migration.title" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5733,6 +5736,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.complete.description" : {
|
"onboarding.cloud.complete.description" : {
|
||||||
"comment" : "iCloud sync complete description on onboarding",
|
"comment" : "iCloud sync complete description on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5744,6 +5748,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.complete.title" : {
|
"onboarding.cloud.complete.title" : {
|
||||||
"comment" : "iCloud sync complete title on onboarding",
|
"comment" : "iCloud sync complete title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5755,6 +5760,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.description" : {
|
"onboarding.cloud.description" : {
|
||||||
"comment" : "iCloud sync description on onboarding",
|
"comment" : "iCloud sync description on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5766,6 +5772,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.enable" : {
|
"onboarding.cloud.enable" : {
|
||||||
"comment" : "Enable iCloud button on onboarding",
|
"comment" : "Enable iCloud button on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5777,6 +5784,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.error.title" : {
|
"onboarding.cloud.error.title" : {
|
||||||
"comment" : "iCloud sync error title on onboarding",
|
"comment" : "iCloud sync error title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5788,6 +5796,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.skip" : {
|
"onboarding.cloud.skip" : {
|
||||||
"comment" : "Skip iCloud button on onboarding",
|
"comment" : "Skip iCloud button on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5799,6 +5808,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.syncing.downloading" : {
|
"onboarding.cloud.syncing.downloading" : {
|
||||||
"comment" : "Download progress text on iCloud onboarding",
|
"comment" : "Download progress text on iCloud onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5832,6 +5842,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.title" : {
|
"onboarding.cloud.title" : {
|
||||||
"comment" : "iCloud sync title on onboarding",
|
"comment" : "iCloud sync title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5843,6 +5854,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.cloud.unavailable" : {
|
"onboarding.cloud.unavailable" : {
|
||||||
"comment" : "iCloud unavailable message on onboarding",
|
"comment" : "iCloud unavailable message on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5854,6 +5866,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.continue" : {
|
"onboarding.continue" : {
|
||||||
"comment" : "Continue button on onboarding",
|
"comment" : "Continue button on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5865,6 +5878,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.skip" : {
|
"onboarding.skip" : {
|
||||||
"comment" : "Skip button on onboarding",
|
"comment" : "Skip button on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5876,6 +5890,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.sources.description" : {
|
"onboarding.sources.description" : {
|
||||||
"comment" : "Settings description on onboarding",
|
"comment" : "Settings description on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5887,6 +5902,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.sources.goToSettings" : {
|
"onboarding.sources.goToSettings" : {
|
||||||
"comment" : "Explore settings button on onboarding",
|
"comment" : "Explore settings button on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5898,6 +5914,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.sources.later" : {
|
"onboarding.sources.later" : {
|
||||||
"comment" : "Get started button on onboarding",
|
"comment" : "Get started button on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5909,6 +5926,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.sources.title" : {
|
"onboarding.sources.title" : {
|
||||||
"comment" : "Settings title on onboarding",
|
"comment" : "Settings title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5920,6 +5938,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.privacy.description" : {
|
"onboarding.title.privacy.description" : {
|
||||||
"comment" : "Privacy feature description on onboarding",
|
"comment" : "Privacy feature description on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5931,6 +5950,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.privacy.title" : {
|
"onboarding.title.privacy.title" : {
|
||||||
"comment" : "Privacy feature title on onboarding",
|
"comment" : "Privacy feature title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5942,6 +5962,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.sources.description" : {
|
"onboarding.title.sources.description" : {
|
||||||
"comment" : "Sources feature description on onboarding",
|
"comment" : "Sources feature description on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5953,6 +5974,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.sources.title" : {
|
"onboarding.title.sources.title" : {
|
||||||
"comment" : "Sources feature title on onboarding",
|
"comment" : "Sources feature title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5964,6 +5986,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.sync.description" : {
|
"onboarding.title.sync.description" : {
|
||||||
"comment" : "Sync feature description on onboarding",
|
"comment" : "Sync feature description on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5975,6 +5998,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.sync.title" : {
|
"onboarding.title.sync.title" : {
|
||||||
"comment" : "Sync feature title on onboarding",
|
"comment" : "Sync feature title on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5986,6 +6010,7 @@
|
|||||||
},
|
},
|
||||||
"onboarding.title.tagline" : {
|
"onboarding.title.tagline" : {
|
||||||
"comment" : "App tagline on onboarding",
|
"comment" : "App tagline on onboarding",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"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" : {
|
"settings.advanced.storage.cleanupComplete" : {
|
||||||
"comment" : "Alert title",
|
"comment" : "Alert title",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class LegacyDataMigrationService {
|
|||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
private let instancesManager: InstancesManager
|
private let instancesManager: InstancesManager
|
||||||
|
private let basicAuthCredentialsManager: BasicAuthCredentialsManager
|
||||||
private let httpClient: HTTPClient
|
private let httpClient: HTTPClient
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
@@ -38,9 +39,11 @@ final class LegacyDataMigrationService {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
instancesManager: InstancesManager,
|
instancesManager: InstancesManager,
|
||||||
|
basicAuthCredentialsManager: BasicAuthCredentialsManager,
|
||||||
httpClient: HTTPClient = HTTPClient()
|
httpClient: HTTPClient = HTTPClient()
|
||||||
) {
|
) {
|
||||||
self.instancesManager = instancesManager
|
self.instancesManager = instancesManager
|
||||||
|
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
||||||
self.httpClient = httpClient
|
self.httpClient = httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,32 +181,74 @@ final class LegacyDataMigrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Imports a single item into the v2 system.
|
/// 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 {
|
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(
|
let instance = Instance(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
type: item.instanceType,
|
type: item.instanceType,
|
||||||
url: item.url,
|
url: cleanURL,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
proxiesVideos: item.proxiesVideos
|
proxiesVideos: item.proxiesVideos
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to instances manager
|
|
||||||
instancesManager.add(instance)
|
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.
|
/// Checks if an import item would be a duplicate of an existing instance.
|
||||||
private func isDuplicate(_ item: LegacyImportItem) -> Bool {
|
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 {
|
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 true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
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
|
// MARK: - Cleanup
|
||||||
|
|
||||||
/// Deletes the legacy v1 data from UserDefaults.
|
/// Deletes the legacy v1 data from UserDefaults.
|
||||||
|
|||||||
49
Yattee/Views/ICloudSyncProgressView.swift
Normal file
49
Yattee/Views/ICloudSyncProgressView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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: {})
|
|
||||||
}
|
|
||||||
@@ -176,15 +176,6 @@ struct DeveloperSettingsView: View {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
notificationTestingSection
|
notificationTestingSection
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
Section {
|
|
||||||
Button {
|
|
||||||
NotificationCenter.default.post(name: .showOnboarding, object: nil)
|
|
||||||
appEnvironment?.navigationCoordinator.dismissSettings()
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "settings.advanced.showOnboarding"), systemImage: "hand.wave")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
import CloudKit
|
||||||
import Nuke
|
import Nuke
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -42,8 +43,9 @@ struct YatteeApp: App {
|
|||||||
@State private var showingDeepLinkDownloadSheet = false
|
@State private var showingDeepLinkDownloadSheet = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Onboarding state
|
// First-launch state
|
||||||
@State private var showingOnboarding = false
|
@State private var showingICloudAlert = false
|
||||||
|
@State private var showingICloudProgress = false
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingOpenLinkSheet = false
|
@State private var showingOpenLinkSheet = false
|
||||||
|
|
||||||
@@ -119,23 +121,33 @@ struct YatteeApp: App {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
// Onboarding sheet
|
// First-launch iCloud sync prompt
|
||||||
#if os(tvOS)
|
.alert(
|
||||||
.fullScreenCover(isPresented: $showingOnboarding) {
|
String(localized: "settings.icloud.enable.confirmation.title"),
|
||||||
NavigationStack {
|
isPresented: $showingICloudAlert
|
||||||
OnboardingSheetView()
|
) {
|
||||||
.appEnvironment(appEnvironment)
|
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
|
#else
|
||||||
.sheet(isPresented: $showingOnboarding) {
|
.fullScreenCover(isPresented: $showingICloudProgress) {
|
||||||
NavigationStack {
|
ICloudSyncProgressView()
|
||||||
OnboardingSheetView()
|
.appEnvironment(appEnvironment)
|
||||||
.appEnvironment(appEnvironment)
|
|
||||||
}
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.interactiveDismissDisabled()
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
.sheet(isPresented: $showingSettings) {
|
.sheet(isPresented: $showingSettings) {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.appEnvironment(appEnvironment)
|
.appEnvironment(appEnvironment)
|
||||||
@@ -145,9 +157,6 @@ struct YatteeApp: App {
|
|||||||
.appEnvironment(appEnvironment)
|
.appEnvironment(appEnvironment)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .showOnboarding)) { _ in
|
|
||||||
showingOnboarding = true
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in
|
||||||
showingSettings = true
|
showingSettings = true
|
||||||
}
|
}
|
||||||
@@ -276,15 +285,46 @@ struct YatteeApp: App {
|
|||||||
// Auto-delete old history entries based on retention setting
|
// Auto-delete old history entries based on retention setting
|
||||||
performHistoryCleanup()
|
performHistoryCleanup()
|
||||||
|
|
||||||
// Show onboarding on first launch
|
// Run first-launch tasks: silently import v1 data, then offer iCloud sync.
|
||||||
if !appEnvironment.settingsManager.onboardingCompleted {
|
if !appEnvironment.settingsManager.onboardingCompleted {
|
||||||
// Small delay to let the main UI settle
|
Task {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
await appEnvironment.legacyMigrationService.autoImportIfNeeded()
|
||||||
showingOnboarding = true
|
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.
|
/// Delete old watch history entries based on the retention setting.
|
||||||
private func performHistoryCleanup() {
|
private func performHistoryCleanup() {
|
||||||
let retentionDays = appEnvironment.settingsManager.historyRetentionDays
|
let retentionDays = appEnvironment.settingsManager.historyRetentionDays
|
||||||
|
|||||||
Reference in New Issue
Block a user