mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
Shows an orange "DEV" capsule next to the iCloud row in Settings and a development environment notice at the top of iCloud settings, helping distinguish CloudKit dev environment from production during development.
628 lines
23 KiB
Swift
628 lines
23 KiB
Swift
//
|
|
// iCloudSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Settings for iCloud sync configuration.
|
|
//
|
|
|
|
import CloudKit
|
|
import SwiftUI
|
|
|
|
struct iCloudSettingsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@State private var showingEnableConfirmation = false
|
|
@State private var showingDisableConfirmation = false
|
|
@State private var showingCategoryEnableConfirmation: SyncCategory?
|
|
@State private var showingCategorySyncConfirmation: SyncCategory?
|
|
@State private var lastManualSyncTime: Date?
|
|
@State private var expandedError = false
|
|
@State private var expandedUpdateWarning = false
|
|
@State private var syncRotation: Double = 0
|
|
|
|
private var settingsManager: SettingsManager? {
|
|
appEnvironment?.settingsManager
|
|
}
|
|
|
|
private var instancesManager: InstancesManager? {
|
|
appEnvironment?.instancesManager
|
|
}
|
|
|
|
private var cloudKitSync: CloudKitSyncEngine? {
|
|
appEnvironment?.cloudKitSync
|
|
}
|
|
|
|
private var mediaSourcesManager: MediaSourcesManager? {
|
|
appEnvironment?.mediaSourcesManager
|
|
}
|
|
|
|
private var lastManualSyncRelative: String? {
|
|
guard let lastSync = lastManualSyncTime else { return nil }
|
|
return RelativeDateFormatter.string(for: lastSync, unitsStyle: .full)
|
|
}
|
|
|
|
private var syncNowIcon: String {
|
|
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, *) {
|
|
return "arrow.trianglehead.2.clockwise.rotate.90.icloud"
|
|
} else {
|
|
return "bolt.horizontal.icloud"
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
#if DEBUG
|
|
Section {
|
|
Label(String(localized: "settings.icloud.dev.title"), systemImage: "hammer.fill")
|
|
.foregroundStyle(.orange)
|
|
.font(.subheadline)
|
|
} footer: {
|
|
Text(String(localized: "settings.icloud.dev.footer"))
|
|
}
|
|
#endif
|
|
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.iCloudSyncEnabled ?? false },
|
|
set: { newValue in
|
|
if newValue {
|
|
showingEnableConfirmation = true
|
|
} else {
|
|
showingDisableConfirmation = true
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.enable"), systemImage: "icloud")
|
|
}
|
|
} footer: {
|
|
Text(String(localized: "settings.icloud.footer"))
|
|
}
|
|
|
|
if settingsManager?.iCloudSyncEnabled == true {
|
|
syncCategoriesSection
|
|
syncStatusSection
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "settings.icloud.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.task {
|
|
await cloudKitSync?.refreshAccountStatus()
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.icloud.enable.confirmation.title"),
|
|
isPresented: $showingEnableConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.icloud.enable.confirmation.action"), role: .destructive) {
|
|
enableiCloudSync()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
} message: {
|
|
Text(String(localized: "settings.icloud.enable.confirmation.message"))
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.icloud.disable.confirmation.title"),
|
|
isPresented: $showingDisableConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.icloud.disable.confirmation.action"), role: .destructive) {
|
|
disableiCloudSync()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
} message: {
|
|
Text(String(localized: "settings.icloud.disable.confirmation.message"))
|
|
}
|
|
// Confirmation for categories that replace local data (instances, settings, media sources)
|
|
.confirmationDialog(
|
|
String(localized: "settings.icloud.category.enable.title"),
|
|
isPresented: .init(
|
|
get: { showingCategoryEnableConfirmation != nil },
|
|
set: { if !$0 { showingCategoryEnableConfirmation = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.icloud.category.enable.action"), role: .destructive) {
|
|
if let category = showingCategoryEnableConfirmation {
|
|
enableCategory(category)
|
|
}
|
|
showingCategoryEnableConfirmation = nil
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
showingCategoryEnableConfirmation = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.icloud.category.enable.message"))
|
|
}
|
|
// Confirmation for categories that upload/merge local data (subscriptions, bookmarks, playlists, history)
|
|
.confirmationDialog(
|
|
String(localized: "settings.icloud.category.sync.title"),
|
|
isPresented: .init(
|
|
get: { showingCategorySyncConfirmation != nil },
|
|
set: { if !$0 { showingCategorySyncConfirmation = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.icloud.category.sync.action")) {
|
|
if let category = showingCategorySyncConfirmation {
|
|
enableCategory(category)
|
|
}
|
|
showingCategorySyncConfirmation = nil
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
showingCategorySyncConfirmation = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.icloud.category.sync.message"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Categories Section
|
|
|
|
@ViewBuilder
|
|
private var syncCategoriesSection: some View {
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncInstances ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .instances)
|
|
} else {
|
|
settingsManager?.syncInstances = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.instances"), systemImage: "server.rack")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncSubscriptions ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .subscriptions)
|
|
} else {
|
|
settingsManager?.syncSubscriptions = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.subscriptions"), systemImage: "person.2")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncBookmarks ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .bookmarks)
|
|
} else {
|
|
settingsManager?.syncBookmarks = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.bookmarks"), systemImage: "bookmark.fill")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncPlaylists ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .playlists)
|
|
} else {
|
|
settingsManager?.syncPlaylists = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.playlists"), systemImage: "list.bullet.rectangle")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncPlaybackHistory ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .playbackHistory)
|
|
} else {
|
|
settingsManager?.syncPlaybackHistory = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.playbackHistory"), systemImage: "clock.arrow.circlepath")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncSearchHistory ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .searchHistory)
|
|
} else {
|
|
settingsManager?.syncSearchHistory = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.searchHistory"), systemImage: "magnifyingglass.circle")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncSettings ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .settings)
|
|
} else {
|
|
settingsManager?.syncSettings = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.settings"), systemImage: "gearshape")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { settingsManager?.syncMediaSources ?? true },
|
|
set: { newValue in
|
|
if newValue {
|
|
showCategoryConfirmation(for: .mediaSources)
|
|
} else {
|
|
settingsManager?.syncMediaSources = false
|
|
}
|
|
}
|
|
)) {
|
|
Label(String(localized: "settings.icloud.category.mediaSources"), systemImage: "externaldrive.connected.to.line.below")
|
|
}
|
|
} footer: {
|
|
Text(String(localized: "settings.icloud.categories.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Status Section
|
|
|
|
@ViewBuilder
|
|
private var syncStatusSection: some View {
|
|
Section {
|
|
// iCloud Account Status
|
|
HStack {
|
|
Label(String(localized: "settings.icloud.account"), systemImage: "person.crop.circle")
|
|
Spacer()
|
|
HStack(spacing: 6) {
|
|
accountStatusIcon
|
|
Text(cloudKitSync?.accountStatusText ?? "Unknown")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// Sync Status
|
|
HStack {
|
|
Label(String(localized: "settings.icloud.status"), systemImage: "arrow.triangle.2.circlepath")
|
|
Spacer()
|
|
HStack(spacing: 6) {
|
|
syncStatusIcon
|
|
Text(cloudKitSync?.syncStatusText ?? "Unknown")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// Pending Changes (if any)
|
|
if let count = cloudKitSync?.pendingChangesCount, count > 0 {
|
|
HStack {
|
|
Label(String(localized: "settings.icloud.pending"), systemImage: "clock")
|
|
Spacer()
|
|
Text(verbatim: "\(count) item\(count == 1 ? "" : "s")")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// Last Sync (automatic)
|
|
if let lastSync = cloudKitSync?.lastSyncDate {
|
|
HStack {
|
|
Label(String(localized: "settings.icloud.lastSynced"), systemImage: "checkmark.circle")
|
|
Spacer()
|
|
Text(lastSync, style: .relative)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// Error Row (expandable)
|
|
if case .error(let error) = cloudKitSync?.syncStatus {
|
|
errorRow(error)
|
|
}
|
|
|
|
// Update available warning (newer schema detected)
|
|
if cloudKitSync?.hasNewerSchemaRecords == true {
|
|
updateAvailableRow
|
|
}
|
|
|
|
// Upload Progress (initial sync)
|
|
if let progress = cloudKitSync?.uploadProgress {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
if !progress.isComplete {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
}
|
|
Text(progress.displayText)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync Now Button
|
|
Button {
|
|
syncNow()
|
|
} label: {
|
|
Label(String(localized: "settings.icloud.syncNow"), systemImage: syncNowIcon)
|
|
}
|
|
.disabled(cloudKitSync?.isSyncing == true)
|
|
|
|
} footer: {
|
|
if let lastSync = lastManualSyncRelative {
|
|
Text(String(localized: "settings.icloud.lastManualSync \(lastSync)"))
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.3), value: cloudKitSync?.syncStatus)
|
|
.animation(.easeInOut(duration: 0.3), value: cloudKitSync?.uploadProgress)
|
|
}
|
|
|
|
// MARK: - Status Icons
|
|
|
|
private var accountStatusIcon: some View {
|
|
Group {
|
|
switch cloudKitSync?.accountStatus {
|
|
case .available:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
case .noAccount, .restricted:
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
case .temporarilyUnavailable:
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(.yellow)
|
|
default:
|
|
Image(systemName: "questionmark.circle.fill")
|
|
.foregroundStyle(.gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var syncStatusIcon: some View {
|
|
Group {
|
|
// Show rotating icon for both syncing and receiving changes
|
|
if cloudKitSync?.isSyncing == true || cloudKitSync?.isReceivingChanges == true {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(.blue)
|
|
.rotationEffect(.degrees(syncRotation))
|
|
.onAppear {
|
|
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
|
syncRotation = 360
|
|
}
|
|
}
|
|
.onDisappear {
|
|
syncRotation = 0
|
|
}
|
|
} else {
|
|
switch cloudKitSync?.syncStatus {
|
|
case .syncing:
|
|
// Handled above
|
|
EmptyView()
|
|
case .upToDate:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
case .pending:
|
|
Image(systemName: "clock.fill")
|
|
.foregroundStyle(.orange)
|
|
case .error:
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
case .none:
|
|
Image(systemName: "questionmark.circle.fill")
|
|
.foregroundStyle(.gray)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Display
|
|
|
|
@ViewBuilder
|
|
private func errorRow(_ error: Error) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Collapsed view
|
|
Button {
|
|
withAnimation {
|
|
expandedError.toggle()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(.red)
|
|
Text(String(localized: "settings.icloud.syncError"))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
Image(systemName: expandedError ? "chevron.up" : "chevron.down")
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
// Expanded view with details
|
|
if expandedError {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(error.localizedDescription)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
// Retry button
|
|
Button {
|
|
Task {
|
|
await cloudKitSync?.sync()
|
|
}
|
|
} label: {
|
|
Label(String(localized: "settings.icloud.retrySync"), systemImage: "arrow.clockwise")
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(.leading, 28) // Align with text
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var updateAvailableRow: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button {
|
|
withAnimation {
|
|
expandedUpdateWarning.toggle()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.foregroundStyle(.orange)
|
|
Text(String(localized: "settings.icloud.update.title"))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
Image(systemName: expandedUpdateWarning ? "chevron.up" : "chevron.down")
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
if expandedUpdateWarning {
|
|
Text(String(localized: "settings.icloud.update.message"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.leading, 28)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func showCategoryConfirmation(for category: SyncCategory) {
|
|
if category.usesReplaceStrategy {
|
|
showingCategoryEnableConfirmation = category
|
|
} else {
|
|
showingCategorySyncConfirmation = category
|
|
}
|
|
}
|
|
|
|
private func enableiCloudSync() {
|
|
// Enable sync setting and all categories by default
|
|
settingsManager?.iCloudSyncEnabled = true
|
|
settingsManager?.enableAllSyncCategories()
|
|
|
|
// Enable CloudKit sync engine then upload all existing local data
|
|
Task {
|
|
await cloudKitSync?.enable()
|
|
await cloudKitSync?.performInitialUpload()
|
|
}
|
|
|
|
// Keep old sync for non-migrated data
|
|
settingsManager?.replaceWithiCloudData()
|
|
instancesManager?.replaceWithiCloudData()
|
|
mediaSourcesManager?.replaceWithiCloudData()
|
|
}
|
|
|
|
private func disableiCloudSync() {
|
|
settingsManager?.iCloudSyncEnabled = false
|
|
cloudKitSync?.disable()
|
|
}
|
|
|
|
private func enableCategory(_ category: SyncCategory) {
|
|
switch category {
|
|
case .instances:
|
|
settingsManager?.syncInstances = true
|
|
instancesManager?.replaceWithiCloudData()
|
|
case .subscriptions:
|
|
settingsManager?.syncSubscriptions = true
|
|
// Upload existing subscriptions to CloudKit
|
|
Task {
|
|
await cloudKitSync?.uploadAllLocalSubscriptions()
|
|
}
|
|
case .playbackHistory:
|
|
settingsManager?.syncPlaybackHistory = true
|
|
// Upload existing watch history to CloudKit
|
|
Task {
|
|
await cloudKitSync?.uploadAllLocalWatchHistory()
|
|
}
|
|
case .bookmarks:
|
|
settingsManager?.syncBookmarks = true
|
|
// Upload existing bookmarks to CloudKit
|
|
Task {
|
|
await cloudKitSync?.uploadAllLocalBookmarks()
|
|
}
|
|
case .playlists:
|
|
settingsManager?.syncPlaylists = true
|
|
// Upload existing playlists to CloudKit
|
|
Task {
|
|
await cloudKitSync?.uploadAllLocalPlaylists()
|
|
}
|
|
case .searchHistory:
|
|
settingsManager?.syncSearchHistory = true
|
|
// Upload existing search history to CloudKit
|
|
Task {
|
|
await cloudKitSync?.uploadAllLocalSearchHistory()
|
|
await cloudKitSync?.uploadAllRecentChannels()
|
|
await cloudKitSync?.uploadAllRecentPlaylists()
|
|
}
|
|
case .settings:
|
|
settingsManager?.syncSettings = true
|
|
settingsManager?.replaceWithiCloudData()
|
|
// Upload existing controls presets to CloudKit
|
|
Task {
|
|
await cloudKitSync?.uploadAllLocalControlsPresets()
|
|
}
|
|
case .mediaSources:
|
|
settingsManager?.syncMediaSources = true
|
|
mediaSourcesManager?.replaceWithiCloudData()
|
|
}
|
|
}
|
|
|
|
private func syncNow() {
|
|
// Trigger CloudKit refresh sync (clears stale tokens, fetches all changes)
|
|
Task {
|
|
await cloudKitSync?.refreshSync()
|
|
}
|
|
|
|
// Push other data to iCloud (non-CloudKit)
|
|
settingsManager?.syncToiCloud()
|
|
instancesManager?.syncToiCloud()
|
|
mediaSourcesManager?.syncToiCloud()
|
|
lastManualSyncTime = Date()
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Category
|
|
|
|
private enum SyncCategory {
|
|
case instances
|
|
case subscriptions
|
|
case bookmarks
|
|
case playbackHistory
|
|
case playlists
|
|
case searchHistory
|
|
case mediaSources
|
|
case settings
|
|
|
|
|
|
/// Categories that replace local data with iCloud data
|
|
var usesReplaceStrategy: Bool {
|
|
switch self {
|
|
case .instances, .settings, .mediaSources:
|
|
return true
|
|
case .subscriptions, .bookmarks, .playlists, .playbackHistory, .searchHistory:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
iCloudSettingsView()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|