Add CloudSyncable protocol and iCloudSyncSettingsView for reusable iCloud sync section

This commit is contained in:
Matt Bruce 2026-01-04 18:11:26 -06:00
parent 3e1d63740f
commit 41b3174c3b
2 changed files with 244 additions and 0 deletions

View File

@ -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<SyncedSettings>()
// 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 |
---

View File

@ -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<SyncedSettings>()
///
/// 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<ViewModel: CloudSyncable>: 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)
}