Skip to content

Commit 772cadd

Browse files
pseudobunclaude
andauthored
feat(sync): CloudKit sync status indicator + crisper card hover (#21)
## Summary - Adds a small CloudKit sync status indicator to the `TossesView` toolbar (principal on macOS, top-leading on iOS). Shows a spinner while `.import`/`.export` events are in flight and an `exclamationmark.icloud` icon with a tooltip on error, auto-clearing on the next successful event. `.setup` events are filtered so cold launches don't spin. - Implemented via a new `@MainActor` `CloudSyncMonitor` in `TossKit` that subscribes to `NSPersistentCloudKitContainer.eventChangedNotification` — the Apple-documented path for observing sync in a SwiftData + CloudKit stack (SwiftData's `ModelContainer` is still backed by `NSPersistentCloudKitContainer` and posts this notification globally). - The indicator uses a fixed 18pt frame so the toolbar never reflows between idle and syncing, and opts out of the macOS/iOS 26 shared Liquid Glass background via `.sharedBackgroundVisibility(.hidden)` (gated with `#available` for older OSes). - Replaces the 0.5% `scaleEffect` hover feedback on `TossCard` with a stronger shadow, an inner accent-colored border overlay, and a pointing-hand cursor. The fractional scale was forcing subpixel resampling and making the card look faintly blurry on hover. ## Why CloudKit sync is currently invisible to the user — when it silently broke recently (iOS↔macOS CloudKit environment mismatch) there was no signal at all. Apple's own guidance is that event notifications describe *activity*, not definitive "fully synced" truth, so the UX is deliberately minimal: transient spinner only, auto-clearing error icon, nothing persistent, no "last synced at X" text. ## Test plan - [ ] Launch on macOS; toolbar looks unchanged at idle (no bezel, no empty slot). - [ ] Add a toss on the Mac; spinner appears briefly in the toolbar between "Tosses" and `+`, then disappears. - [ ] Add a toss on a second device on the same iCloud account; first Mac shows a spinner when the `.import` event arrives and the toss appears in the list. - [ ] Sign out of iCloud, add a toss; orange `exclamationmark.icloud` shows with tooltip. Sign back in and add another toss; icon auto-clears. - [ ] Cold-launch several times; no spinner during `.setup` (filtered). - [ ] Hover a toss card on macOS; border + shadow + cursor change, no blur. - [ ] Build on iOS — `.topBarLeading` placement looks correct. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 44c8895 commit 772cadd

4 files changed

Lines changed: 118 additions & 4 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// CloudSyncMonitor.swift
3+
// TossKit
4+
//
5+
// Observes NSPersistentCloudKitContainer.eventChangedNotification and exposes
6+
// a coarse idle/syncing/failed state for SwiftUI. SwiftData's ModelContainer
7+
// is built on NSPersistentCloudKitContainer, which posts this notification on
8+
// the default NotificationCenter regardless of how the store was configured.
9+
//
10+
11+
import CoreData
12+
import Foundation
13+
14+
@MainActor
15+
public final class CloudSyncMonitor: ObservableObject {
16+
public enum State: Equatable {
17+
case idle
18+
case syncing
19+
case failed(message: String)
20+
}
21+
22+
@Published public private(set) var state: State = .idle
23+
24+
private var task: Task<Void, Never>?
25+
26+
public init() {}
27+
28+
public func start() {
29+
guard task == nil else { return }
30+
task = Task { @MainActor [weak self] in
31+
let name = NSPersistentCloudKitContainer.eventChangedNotification
32+
for await notification in NotificationCenter.default.notifications(named: name) {
33+
guard let self else { return }
34+
guard
35+
let event = notification.userInfo?[
36+
NSPersistentCloudKitContainer.eventNotificationUserInfoKey
37+
] as? NSPersistentCloudKitContainer.Event,
38+
event.type != .setup
39+
else { continue }
40+
41+
if event.endDate == nil {
42+
self.state = .syncing
43+
} else if let error = event.error {
44+
self.state = .failed(message: error.localizedDescription)
45+
} else {
46+
self.state = .idle
47+
}
48+
}
49+
}
50+
}
51+
52+
deinit {
53+
task?.cancel()
54+
}
55+
}

toss/Views/Tosses/TossCard.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,24 @@ struct TossCard: View {
2727
#if os(macOS)
2828
.onHover { hovering in
2929
isHovered = hovering
30+
if hovering {
31+
NSCursor.pointingHand.push()
32+
} else {
33+
NSCursor.pop()
34+
}
3035
}
3136
.shadow(
32-
color: .black.opacity(isHovered ? 0.08 : 0.03),
33-
radius: isHovered ? 4 : 2,
34-
y: 1
37+
color: .black.opacity(isHovered ? 0.12 : 0.03),
38+
radius: isHovered ? 6 : 2,
39+
y: isHovered ? 2 : 1
3540
)
36-
.scaleEffect(isHovered ? 1.005 : 1.0)
41+
.overlay {
42+
RoundedRectangle(cornerRadius: 12, style: .continuous)
43+
.strokeBorder(
44+
Color.accentColor.opacity(isHovered ? 0.35 : 0),
45+
lineWidth: 1.5
46+
)
47+
}
3748
#endif
3849
}
3950

toss/Views/Tosses/TossesView.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct TossesView: View {
2020
@Environment(\.modelContext) private var modelContext
2121

2222
@StateObject private var viewModel = TossesViewModel()
23+
@EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor
2324
@State private var showingAddToss = false
2425
@State private var editingToss: Toss?
2526
@State private var selectedToss: Toss?
@@ -85,6 +86,16 @@ struct TossesView: View {
8586
}
8687
.toolbar {
8788
#if os(macOS)
89+
if #available(macOS 26.0, *) {
90+
ToolbarItem(placement: .principal) {
91+
CloudSyncIndicator(state: cloudSyncMonitor.state)
92+
}
93+
.sharedBackgroundVisibility(.hidden)
94+
} else {
95+
ToolbarItem(placement: .principal) {
96+
CloudSyncIndicator(state: cloudSyncMonitor.state)
97+
}
98+
}
8899
ToolbarItem(placement: .primaryAction) {
89100
Button {
90101
showingAddToss = true
@@ -93,6 +104,16 @@ struct TossesView: View {
93104
}
94105
}
95106
#else
107+
if #available(iOS 26.0, *) {
108+
ToolbarItem(placement: .topBarLeading) {
109+
CloudSyncIndicator(state: cloudSyncMonitor.state)
110+
}
111+
.sharedBackgroundVisibility(.hidden)
112+
} else {
113+
ToolbarItem(placement: .topBarLeading) {
114+
CloudSyncIndicator(state: cloudSyncMonitor.state)
115+
}
116+
}
96117
ToolbarItem(placement: .topBarTrailing) {
97118
Button {
98119
showingAddToss = true
@@ -183,3 +204,27 @@ struct TossesView: View {
183204
#endif
184205
}
185206
}
207+
208+
private struct CloudSyncIndicator: View {
209+
let state: CloudSyncMonitor.State
210+
211+
var body: some View {
212+
ZStack {
213+
switch state {
214+
case .idle:
215+
Color.clear
216+
case .syncing:
217+
ProgressView()
218+
.controlSize(.small)
219+
.accessibilityLabel("Syncing with iCloud")
220+
case .failed(let message):
221+
Image(systemName: "exclamationmark.icloud")
222+
.foregroundStyle(.orange)
223+
.help(message)
224+
.accessibilityLabel("iCloud sync failed")
225+
.accessibilityHint(message)
226+
}
227+
}
228+
.frame(width: 18, height: 18)
229+
}
230+
}

toss/tossApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ struct tossApp: App {
1414
var container: ModelContainer
1515
@StateObject private var appSettings = AppSettings()
1616
@StateObject private var updateGate = UpdateGateService()
17+
@StateObject private var cloudSyncMonitor = CloudSyncMonitor()
1718
#if os(macOS)
1819
@StateObject private var macGlobalShortcutController = MacGlobalShortcutController()
1920
#endif
@@ -40,8 +41,10 @@ struct tossApp: App {
4041
}
4142
.environmentObject(appSettings)
4243
.environmentObject(updateGate)
44+
.environmentObject(cloudSyncMonitor)
4345
.tint(Color.accentColor) // Apply accent color globally
4446
.onAppear {
47+
cloudSyncMonitor.start()
4548
uuidMigration.startIfNeeded(modelContainer: container)
4649
backfillMigration.startIfNeeded(modelContainer: container)
4750
#if os(macOS)

0 commit comments

Comments
 (0)