Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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()
}
}
19 changes: 15 additions & 4 deletions toss/Views/Tosses/TossCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
45 changes: 45 additions & 0 deletions toss/Views/Tosses/TossesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions toss/tossApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading