diff --git a/Packages/TossKit/Sources/TossKit/Persistence/CloudSyncMonitor.swift b/Packages/TossKit/Sources/TossKit/Persistence/CloudSyncMonitor.swift new file mode 100644 index 0000000..d474203 --- /dev/null +++ b/Packages/TossKit/Sources/TossKit/Persistence/CloudSyncMonitor.swift @@ -0,0 +1,55 @@ +// +// CloudSyncMonitor.swift +// TossKit +// +// Observes NSPersistentCloudKitContainer.eventChangedNotification and exposes +// a coarse idle/syncing/failed state for SwiftUI. SwiftData's ModelContainer +// is built on NSPersistentCloudKitContainer, which posts this notification on +// the default NotificationCenter regardless of how the store was configured. +// + +import CoreData +import Foundation + +@MainActor +public final class CloudSyncMonitor: ObservableObject { + public enum State: Equatable { + case idle + case syncing + case failed(message: String) + } + + @Published public private(set) var state: State = .idle + + private var task: Task? + + public init() {} + + public func start() { + guard task == nil else { return } + task = Task { @MainActor [weak self] in + let name = NSPersistentCloudKitContainer.eventChangedNotification + for await notification in NotificationCenter.default.notifications(named: name) { + guard let self else { return } + guard + let event = notification.userInfo?[ + NSPersistentCloudKitContainer.eventNotificationUserInfoKey + ] as? NSPersistentCloudKitContainer.Event, + event.type != .setup + else { continue } + + if event.endDate == nil { + self.state = .syncing + } else if let error = event.error { + self.state = .failed(message: error.localizedDescription) + } else { + self.state = .idle + } + } + } + } + + deinit { + task?.cancel() + } +} diff --git a/toss/Views/Tosses/TossCard.swift b/toss/Views/Tosses/TossCard.swift index 2be8c61..53b1ddc 100644 --- a/toss/Views/Tosses/TossCard.swift +++ b/toss/Views/Tosses/TossCard.swift @@ -27,13 +27,24 @@ struct TossCard: View { #if os(macOS) .onHover { hovering in isHovered = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } .shadow( - color: .black.opacity(isHovered ? 0.08 : 0.03), - radius: isHovered ? 4 : 2, - y: 1 + color: .black.opacity(isHovered ? 0.12 : 0.03), + radius: isHovered ? 6 : 2, + y: isHovered ? 2 : 1 ) - .scaleEffect(isHovered ? 1.005 : 1.0) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + Color.accentColor.opacity(isHovered ? 0.35 : 0), + lineWidth: 1.5 + ) + } #endif } diff --git a/toss/Views/Tosses/TossesView.swift b/toss/Views/Tosses/TossesView.swift index acad881..c6c1f27 100644 --- a/toss/Views/Tosses/TossesView.swift +++ b/toss/Views/Tosses/TossesView.swift @@ -20,6 +20,7 @@ struct TossesView: View { @Environment(\.modelContext) private var modelContext @StateObject private var viewModel = TossesViewModel() + @EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor @State private var showingAddToss = false @State private var editingToss: Toss? @State private var selectedToss: Toss? @@ -85,6 +86,16 @@ struct TossesView: View { } .toolbar { #if os(macOS) + if #available(macOS 26.0, *) { + ToolbarItem(placement: .principal) { + CloudSyncIndicator(state: cloudSyncMonitor.state) + } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .principal) { + CloudSyncIndicator(state: cloudSyncMonitor.state) + } + } ToolbarItem(placement: .primaryAction) { Button { showingAddToss = true @@ -93,6 +104,16 @@ struct TossesView: View { } } #else + if #available(iOS 26.0, *) { + ToolbarItem(placement: .topBarLeading) { + CloudSyncIndicator(state: cloudSyncMonitor.state) + } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .topBarLeading) { + CloudSyncIndicator(state: cloudSyncMonitor.state) + } + } ToolbarItem(placement: .topBarTrailing) { Button { showingAddToss = true @@ -183,3 +204,27 @@ struct TossesView: View { #endif } } + +private struct CloudSyncIndicator: View { + let state: CloudSyncMonitor.State + + var body: some View { + ZStack { + switch state { + case .idle: + Color.clear + case .syncing: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Syncing with iCloud") + case .failed(let message): + Image(systemName: "exclamationmark.icloud") + .foregroundStyle(.orange) + .help(message) + .accessibilityLabel("iCloud sync failed") + .accessibilityHint(message) + } + } + .frame(width: 18, height: 18) + } +} diff --git a/toss/tossApp.swift b/toss/tossApp.swift index 36118c6..bb01d52 100644 --- a/toss/tossApp.swift +++ b/toss/tossApp.swift @@ -14,6 +14,7 @@ struct tossApp: App { var container: ModelContainer @StateObject private var appSettings = AppSettings() @StateObject private var updateGate = UpdateGateService() + @StateObject private var cloudSyncMonitor = CloudSyncMonitor() #if os(macOS) @StateObject private var macGlobalShortcutController = MacGlobalShortcutController() #endif @@ -40,8 +41,10 @@ struct tossApp: App { } .environmentObject(appSettings) .environmentObject(updateGate) + .environmentObject(cloudSyncMonitor) .tint(Color.accentColor) // Apply accent color globally .onAppear { + cloudSyncMonitor.start() uuidMigration.startIfNeeded(modelContainer: container) backfillMigration.startIfNeeded(modelContainer: container) #if os(macOS)