From 41b3174c3b4342218700483bc2931c082d3f7e51 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 4 Jan 2026 18:11:26 -0600 Subject: [PATCH] Add CloudSyncable protocol and iCloudSyncSettingsView for reusable iCloud sync section --- .../Bedrock/Views/Settings/SETTINGS_GUIDE.md | 52 +++++ .../Settings/iCloudSyncSettingsView.swift | 192 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift diff --git a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md index a9ab462..83106f3 100644 --- a/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md +++ b/Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md @@ -612,6 +612,57 @@ BadgePill( --- +## iCloud Sync Section + +Bedrock provides a reusable iCloud sync section with the `CloudSyncable` protocol and `iCloudSyncSettingsView`. + +### Step 1: Conform ViewModel to CloudSyncable + +```swift +import Bedrock + +@Observable @MainActor +final class SettingsViewModel: CloudSyncable { + private let cloudSync = CloudSyncManager() + + // CloudSyncable protocol requirements + var iCloudAvailable: Bool { cloudSync.iCloudAvailable } + var iCloudEnabled: Bool { + get { cloudSync.iCloudEnabled } + set { cloudSync.iCloudEnabled = newValue } + } + var lastSyncDate: Date? { cloudSync.lastSyncDate } + var syncStatus: String { cloudSync.syncStatus } + var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync } + + func forceSync() { cloudSync.sync() } +} +``` + +### Step 2: Use iCloudSyncSettingsView + +```swift +SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud", accentColor: AppAccent.primary) + +SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { + iCloudSyncSettingsView( + viewModel: viewModel, + accentColor: AppAccent.primary, + successColor: AppStatus.success, + warningColor: AppStatus.warning + ) +} +``` + +The component automatically handles: +- Toggle for enabling/disabling sync +- Dynamic subtitle based on iCloud availability +- Sync status display with appropriate icons +- "Sync Now" button +- Relative time formatting for last sync date + +--- + ## Debug Section Pattern Add a debug section to settings for developer tools. This section is only visible in DEBUG builds. @@ -773,6 +824,7 @@ SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { | `SettingsRow` | Action rows | `systemImage`, `title`, `action` | | `SelectableRow` | Card selection | `title`, `isSelected`, `badge` | | `BadgePill` | Price/tag badges | `text`, `isSelected` | +| `iCloudSyncSettingsView` | iCloud sync controls | `viewModel: CloudSyncable`, colors | --- diff --git a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift new file mode 100644 index 0000000..8f1af40 --- /dev/null +++ b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift @@ -0,0 +1,192 @@ +// +// iCloudSyncSettingsView.swift +// Bedrock +// +// A reusable iCloud sync settings section. +// + +import SwiftUI + +// MARK: - CloudSyncable Protocol + +/// Protocol for view models that support iCloud sync. +/// +/// Conform your settings view model to this protocol to use `iCloudSyncSettingsView`. +/// +/// ```swift +/// @Observable @MainActor +/// class SettingsViewModel: CloudSyncable { +/// private let cloudSync = CloudSyncManager() +/// +/// var iCloudAvailable: Bool { cloudSync.iCloudAvailable } +/// var iCloudEnabled: Bool { +/// get { cloudSync.iCloudEnabled } +/// set { cloudSync.iCloudEnabled = newValue } +/// } +/// var lastSyncDate: Date? { cloudSync.lastSyncDate } +/// var syncStatus: String { cloudSync.syncStatus } +/// var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync } +/// +/// func forceSync() { cloudSync.sync() } +/// } +/// ``` +@MainActor +public protocol CloudSyncable: AnyObject, Observable { + /// Whether iCloud sync is available on this device. + var iCloudAvailable: Bool { get } + + /// Whether iCloud sync is enabled by the user. + var iCloudEnabled: Bool { get set } + + /// The last sync date, if available. + var lastSyncDate: Date? { get } + + /// Current sync status message. + var syncStatus: String { get } + + /// Whether the initial sync has completed. + var hasCompletedInitialSync: Bool { get } + + /// Forces an immediate sync with iCloud. + func forceSync() +} + +// MARK: - iCloud Sync Settings View + +/// A reusable iCloud sync settings section. +/// +/// Use this in settings screens to provide iCloud sync controls. +/// +/// ```swift +/// SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { +/// iCloudSyncSettingsView( +/// viewModel: viewModel, +/// accentColor: AppAccent.primary, +/// successColor: AppStatus.success, +/// warningColor: AppStatus.warning +/// ) +/// } +/// ``` +public struct iCloudSyncSettingsView: View { + /// The view model conforming to CloudSyncable. + @Bindable public var viewModel: ViewModel + + /// The accent color for interactive elements. + public let accentColor: Color + + /// The color for success states. + public let successColor: Color + + /// The color for warning/syncing states. + public let warningColor: Color + + /// Creates an iCloud sync settings view. + /// - Parameters: + /// - viewModel: The view model conforming to CloudSyncable. + /// - accentColor: Accent color for toggle and buttons (default: primary accent). + /// - successColor: Color for synced state (default: success status). + /// - warningColor: Color for syncing state (default: warning status). + public init( + viewModel: ViewModel, + accentColor: Color = .Accent.primary, + successColor: Color = .Status.success, + warningColor: Color = .Status.warning + ) { + self.viewModel = viewModel + self.accentColor = accentColor + self.successColor = successColor + self.warningColor = warningColor + } + + public var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + // Sync toggle + SettingsToggle( + title: String(localized: "Sync Settings"), + subtitle: viewModel.iCloudAvailable + ? String(localized: "Sync settings across all your devices") + : String(localized: "Sign in to iCloud to enable sync"), + isOn: $viewModel.iCloudEnabled, + accentColor: accentColor + ) + .disabled(!viewModel.iCloudAvailable) + .accessibilityHint(String(localized: "Syncs settings across all your devices via iCloud")) + + // Sync status (show when enabled and available) + if viewModel.iCloudEnabled && viewModel.iCloudAvailable { + HStack(spacing: Design.Spacing.small) { + Image(systemName: syncStatusIcon) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(syncStatusColor) + + Text(syncStatusText) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Spacer() + + Button { + viewModel.forceSync() + } label: { + Text(String(localized: "Sync Now")) + .font(.system(size: Design.BaseFontSize.caption, weight: .medium)) + .foregroundStyle(accentColor) + } + } + .padding(.top, Design.Spacing.xSmall) + } + } + } + + // MARK: - Sync Status Helpers + + private var syncStatusIcon: String { + if !viewModel.hasCompletedInitialSync { + return "arrow.triangle.2.circlepath" + } + return viewModel.syncStatus.isEmpty ? "checkmark.icloud" : "icloud" + } + + private var syncStatusColor: Color { + if !viewModel.hasCompletedInitialSync { + return warningColor + } + return successColor + } + + private var syncStatusText: String { + if !viewModel.hasCompletedInitialSync { + return String(localized: "Syncing...") + } + + if let lastSync = viewModel.lastSyncDate { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: Date()))") + } + + return viewModel.syncStatus.isEmpty + ? String(localized: "Synced") + : viewModel.syncStatus + } +} + +// MARK: - Preview + +#Preview { + // Mock view model for preview + @Previewable @State var mockEnabled = true + + VStack(spacing: Design.Spacing.medium) { + Text("iCloud Sync Settings") + .font(.headline) + .foregroundStyle(.white) + + // Note: Preview requires a concrete CloudSyncable implementation + Text("Use with a CloudSyncable-conforming ViewModel") + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + .padding() + .background(Color.Surface.overlay) +}