mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
126
Yattee/Views/Onboarding/MigrationImportRow.swift
Normal file
126
Yattee/Views/Onboarding/MigrationImportRow.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// MigrationImportRow.swift
|
||||
// Yattee
|
||||
//
|
||||
// Reusable row component for displaying a legacy import item.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MigrationImportRow: View {
|
||||
let item: LegacyImportItem
|
||||
let onToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onToggle) {
|
||||
HStack(spacing: 12) {
|
||||
// Checkbox
|
||||
Image(systemName: item.isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(item.isSelected ? Color.accentColor : .secondary)
|
||||
|
||||
// Instance type icon
|
||||
instanceIcon
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 28)
|
||||
|
||||
// Instance details
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.displayName)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(item.url.host ?? item.url.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Reachability indicator
|
||||
reachabilityIndicator
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
@ViewBuilder
|
||||
private var instanceIcon: some View {
|
||||
switch item.instanceType {
|
||||
case .invidious:
|
||||
Image(systemName: "server.rack")
|
||||
case .piped:
|
||||
Image(systemName: "cloud")
|
||||
default:
|
||||
Image(systemName: "globe")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var reachabilityIndicator: some View {
|
||||
switch item.reachabilityStatus {
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
case .checking:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case .reachable:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .unreachable:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
MigrationImportRow(
|
||||
item: LegacyImportItem(
|
||||
id: UUID(),
|
||||
legacyInstanceID: "test",
|
||||
instanceType: .invidious,
|
||||
url: URL(string: "https://invidious.example.com")!,
|
||||
name: "My Invidious",
|
||||
isSelected: true,
|
||||
reachabilityStatus: .reachable
|
||||
),
|
||||
onToggle: {}
|
||||
)
|
||||
|
||||
MigrationImportRow(
|
||||
item: LegacyImportItem(
|
||||
id: UUID(),
|
||||
legacyInstanceID: "test2",
|
||||
instanceType: .piped,
|
||||
url: URL(string: "https://piped.example.com")!,
|
||||
name: nil,
|
||||
isSelected: false,
|
||||
reachabilityStatus: .unreachable
|
||||
),
|
||||
onToggle: {}
|
||||
)
|
||||
|
||||
MigrationImportRow(
|
||||
item: LegacyImportItem(
|
||||
id: UUID(),
|
||||
legacyInstanceID: "test3",
|
||||
instanceType: .invidious,
|
||||
url: URL(string: "https://another.invidious.com")!,
|
||||
name: "Another Instance",
|
||||
isSelected: true,
|
||||
reachabilityStatus: .checking
|
||||
),
|
||||
onToggle: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
347
Yattee/Views/Onboarding/OnboardingCloudScreen.swift
Normal file
347
Yattee/Views/Onboarding/OnboardingCloudScreen.swift
Normal file
@@ -0,0 +1,347 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
360
Yattee/Views/Onboarding/OnboardingMigrationScreen.swift
Normal file
360
Yattee/Views/Onboarding/OnboardingMigrationScreen.swift
Normal file
@@ -0,0 +1,360 @@
|
||||
//
|
||||
// OnboardingMigrationScreen.swift
|
||||
// Yattee
|
||||
//
|
||||
// Migration screen in the onboarding flow for importing v1 data.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingMigrationScreen: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@Binding var items: [LegacyImportItem]
|
||||
let onContinue: () -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
@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(
|
||||
items: $items,
|
||||
onContinue: {},
|
||||
onSkip: {}
|
||||
)
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
128
Yattee/Views/Onboarding/OnboardingSheetView.swift
Normal file
128
Yattee/Views/Onboarding/OnboardingSheetView.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// 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(
|
||||
items: binding,
|
||||
onContinue: advanceToNextPage,
|
||||
onSkip: advanceToNextPage
|
||||
)
|
||||
.tag(2)
|
||||
}
|
||||
|
||||
OnboardingSourcesScreen(
|
||||
onGoToSources: goToSettings,
|
||||
onClose: completeOnboarding
|
||||
)
|
||||
.tag(hasLegacyData ? 3 : 2)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.tabViewStyle(.page)
|
||||
#elseif os(iOS)
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
#endif
|
||||
.interactiveDismissDisabled()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "onboarding.skip")) {
|
||||
completeOnboarding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
92
Yattee/Views/Onboarding/OnboardingSourcesScreen.swift
Normal file
92
Yattee/Views/Onboarding/OnboardingSourcesScreen.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// 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: {}
|
||||
)
|
||||
}
|
||||
114
Yattee/Views/Onboarding/OnboardingTitleScreen.swift
Normal file
114
Yattee/Views/Onboarding/OnboardingTitleScreen.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// OnboardingTitleScreen.swift
|
||||
// Yattee
|
||||
//
|
||||
// First onboarding screen with app logo, title, and feature highlights.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingTitleScreen: View {
|
||||
let onContinue: () -> Void
|
||||
|
||||
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("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()
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: {})
|
||||
}
|
||||
Reference in New Issue
Block a user