Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View 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: {}
)
}
}

View 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)
}

View 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)
}

View 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)
}
}

View 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: {}
)
}

View 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: {})
}