diff --git a/apple/TablePro/Views/Components/SyncStatusIndicator.swift b/apple/TablePro/Views/Components/SyncStatusIndicator.swift deleted file mode 100644 index 3e470e1aa9..0000000000 --- a/apple/TablePro/Views/Components/SyncStatusIndicator.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// SyncStatusIndicator.swift -// TablePro -// - -import SwiftUI -import TableProSyncTransport - -struct SyncStatusIndicator: View { - let onActivateLicense: () -> Void - - @ObservedObject private var syncCoordinator = SyncCoordinator.shared - - var body: some View { - let presentation = SyncStatusPresentation( - status: syncCoordinator.syncStatus, - lastSyncDate: syncCoordinator.lastSyncDate - ) - if presentation.showsIndicator { - Button { - handleTap() - } label: { - HStack(spacing: 4) { - Image(systemName: presentation.symbolName) - .symbolReplaceTransition() - .pulsingSymbol(isActive: syncCoordinator.syncStatus.isSyncing) - Text(presentation.indicatorLabel) - .contentTransition(.numericText()) - } - .font(.subheadline) - .foregroundStyle(foregroundStyle(for: presentation)) - .motionAnimation(.default, value: syncCoordinator.syncStatus) - } - .buttonStyle(.plain) - .help(presentation.helpText) - } - } - - private func foregroundStyle(for presentation: SyncStatusPresentation) -> AnyShapeStyle { - if presentation.isWarning { - return AnyShapeStyle(.orange) - } - return syncCoordinator.syncStatus.isSyncing ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary) - } - - /// An unverified license is the one degraded state activation cannot mend, so it retries the - /// check instead of opening a sheet that asks for a key the person already gave. - private func handleTap() { - switch syncCoordinator.syncStatus { - case .disabled(.licenseRequired), .disabled(.licenseExpired): - onActivateLicense() - case .disabled(.licenseUnverified): - Task { await LicenseManager.shared.revalidate() } - case .idle, .syncing, .error, .disabled(.userDisabled): - WindowOpener.shared.openSettings(tab: .sync) - } - } -} - -#Preview { - HStack(spacing: 16) { - SyncStatusIndicator(onActivateLicense: {}) - } - .padding() -} diff --git a/apple/TablePro/Views/Components/SyncStatusPresentation.swift b/apple/TablePro/Views/Components/SyncStatusPresentation.swift index b8c7a143f7..bf0bab897c 100644 --- a/apple/TablePro/Views/Components/SyncStatusPresentation.swift +++ b/apple/TablePro/Views/Components/SyncStatusPresentation.swift @@ -26,12 +26,7 @@ internal struct SyncNotice: Equatable { let actions: [SyncNoticeAction] } -/// How macOS words a sync status, for the welcome indicator and the Sync pane alike. -/// -/// The one place that does, so the two cannot describe the same state in different words. It reads -/// only `SyncStatus`, which carries no text, so CloudKit's own descriptions and record ids have no -/// way onto the screen. Every switch is exhaustive: a new status has to be worded here before the -/// app builds. +// Reads only `SyncStatus`, which carries no text, so CloudKit's own descriptions never reach the Sync pane. internal struct SyncStatusPresentation { let status: SyncStatus let lastSyncDate: Date? @@ -43,29 +38,6 @@ internal struct SyncStatusPresentation { } internal extension SyncStatusPresentation { - /// Sync the person turned off has nothing to report, and an indicator would only restate the - /// switch. - var showsIndicator: Bool { - status != .disabled(.userDisabled) - } - - var indicatorLabel: String { - switch status { - case .idle: - return String(localized: "Synced") - case .syncing: - return String(localized: "Syncing…") - case .error(let error): - return Self.indicatorLabel(for: error) - case .disabled(.licenseRequired), .disabled(.licenseExpired): - return String(localized: "Sync Off") - case .disabled(.licenseUnverified): - return String(localized: "Sync Paused") - case .disabled(.userDisabled): - return "" - } - } - /// Every symbol here ships with macOS 13. The circlepath and dashed iCloud variants do not. var symbolName: String { switch status { @@ -99,33 +71,6 @@ internal extension SyncStatusPresentation { } } - var helpText: String { - helpText(relativeTo: Date()) - } - - func helpText(relativeTo now: Date) -> String { - switch status { - case .idle: - guard let lastSyncDate else { return String(localized: "iCloud Sync is active") } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - let relative = formatter.localizedString(for: lastSyncDate, relativeTo: now) - return String(format: String(localized: "Last synced %@"), relative) - case .syncing: - return String(localized: "Syncing with iCloud…") - case .error(let error): - return Self.message(for: error) - case .disabled(.licenseRequired): - return ProFeature.iCloudSync.planRequirement - case .disabled(.licenseExpired): - return String(localized: "License expired, sync paused") - case .disabled(.licenseUnverified): - return String(localized: "License not verified in 30 days, sync paused. Click to check again.") - case .disabled(.userDisabled): - return "" - } - } - /// The value of the Status row in the Sync pane. var statusTitle: String { switch status { @@ -246,27 +191,6 @@ private extension SyncStatusPresentation { String(localized: "TablePro has not confirmed this license with the server in 30 days.") } - static func indicatorLabel(for error: SyncError) -> String { - switch error { - case .blocked(.storageFull): - return String(localized: "iCloud Full") - case .blocked(.signedOut): - return String(localized: "No iCloud") - case .blocked(.accountNotReady), .blocked(.dataDeletedFromICloud): - return String(localized: "Sync Paused") - case .blocked(.accountRestricted), .blocked(.unavailable): - return String(localized: "Sync Off") - case .blocked(.appUpdateRequired): - return String(localized: "Update Needed") - case .offline: - return String(localized: "Offline") - case .busy: - return String(localized: "Sync Delayed") - case .recordsRejected, .pullNotSaved, .unexpected: - return String(localized: "Sync Error") - } - } - static func symbolName(for blocker: SyncBlocker) -> String { switch blocker { case .signedOut: diff --git a/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift b/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift index 98b8ce8f84..b85364c21b 100644 --- a/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift +++ b/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift @@ -20,98 +20,97 @@ struct WelcomeActionsPanel: View { .map(\.showWelcomeVersionInfo) .removeDuplicates() - /// Captured once: the same appearance that reads the stored value overwrites it. + // Captured once: the same appearance that reads the stored value overwrites it. @State private var showsWhatsNew: Bool? var body: some View { VStack(spacing: 0) { - VStack(spacing: 12) { - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: 96, height: 96) - .shadow(color: Color(nsColor: .shadowColor).opacity(0.18), radius: 8, x: 0, y: 4) - .accessibilityHidden(true) - - VStack(spacing: 4) { - Text(verbatim: "TablePro") - .font(.title2.weight(.semibold)) - - if showsVersionInfo { - Text(String(format: String(localized: "Version %@"), Bundle.main.displayVersion)) - .font(.callout) - .foregroundStyle(.secondary) - .accessibilityIdentifier("welcome-version-line") - } + identity + .padding(.top, 28) - // A deferred update has its gentle reminder here, so hiding the line never hides that. - if showsVersionInfo || updater.hasPendingUpdate { - Button(updater.checkForUpdatesTitle) { - updater.checkForUpdates() - } - .buttonStyle(.link) - .font(.callout) - .disabled(!updater.canCheckForUpdates) - .accessibilityIdentifier("welcome-check-for-updates-link") - } + Spacer(minLength: 24) - if showsWhatsNew == true { - Button(String(format: String(localized: "What's New in %@"), Bundle.main.appVersion)) { - NSApp.sendAction(#selector(AppDelegate.openChangelog(_:)), to: nil, from: nil) - } - .buttonStyle(.link) + actions + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear(perform: recordAppVersion) + .onReceive(Self.versionInfoSetting) { showsVersionInfo = $0 } + } + + private var identity: some View { + VStack(spacing: 12) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 96, height: 96) + .shadow(color: Color(nsColor: .shadowColor).opacity(0.18), radius: 8, x: 0, y: 4) + .accessibilityHidden(true) + + VStack(spacing: 4) { + Text(verbatim: "TablePro") + .font(.title2.weight(.semibold)) + + if showsVersionInfo { + Text(String(format: String(localized: "Version %@"), Bundle.main.displayVersion)) .font(.callout) - .accessibilityIdentifier("welcome-updated-line") - } + .foregroundStyle(.secondary) + .accessibilityIdentifier("welcome-version-line") } - VStack(spacing: 4) { - licenseBadge - if licenseManager.supportAudience == .prospect { - SupportPromptLink() + // A deferred update has its gentle reminder here, so hiding the line never hides that. + if showsVersionInfo || updater.hasPendingUpdate { + Button(updater.checkForUpdatesTitle) { + updater.checkForUpdates() } + .buttonStyle(.link) + .font(.callout) + .disabled(!updater.canCheckForUpdates) + .accessibilityIdentifier("welcome-check-for-updates-link") } - .font(.subheadline) - } - .padding(.top, 28) - - Spacer() - .frame(height: 24) - VStack(spacing: 8) { - Button(action: onNewConnection) { - Text("New Connection…") - .frame(maxWidth: .infinity) + if showsWhatsNew == true { + Button(String(format: String(localized: "What's New in %@"), Bundle.main.appVersion)) { + NSApp.sendAction(#selector(AppDelegate.openChangelog(_:)), to: nil, from: nil) + } + .buttonStyle(.link) + .font(.callout) + .accessibilityIdentifier("welcome-updated-line") } - .buttonStyle(.borderedProminent) + } - Button(action: onOpenFile) { - Text("Open File…") - .frame(maxWidth: .infinity) + VStack(spacing: 4) { + licenseNotice + if licenseManager.supportAudience == .prospect { + SupportPromptLink() } + } + .font(.subheadline) + } + } - WelcomeImportMenuButton(actions: WelcomeImportActions( - importConnectionsFile: onImportConnectionsFile, - importFromURL: onImportFromURL, - importFromApp: onImportFromApp, - importFromAWS: onImportFromAWS, - openProjectFolder: onOpenProjectFolder - )) - .frame(maxWidth: .infinity) + private var actions: some View { + VStack(spacing: 8) { + Button(action: onNewConnection) { + Text("New Connection…") + .frame(maxWidth: .infinity) } - .controlSize(.large) - .padding(.horizontal, 24) - Spacer(minLength: 24) + Button(action: onOpenFile) { + Text("Open File…") + .frame(maxWidth: .infinity) + } - SyncStatusIndicator(onActivateLicense: onActivateLicense) - .font(.caption) - .foregroundStyle(.tertiary) - .padding(.horizontal, 12) - .padding(.bottom, 20) + WelcomeImportMenuButton(actions: WelcomeImportActions( + importConnectionsFile: onImportConnectionsFile, + importFromURL: onImportFromURL, + importFromApp: onImportFromApp, + importFromAWS: onImportFromAWS, + openProjectFolder: onOpenProjectFolder + )) + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear(perform: recordAppVersion) - .onReceive(Self.versionInfoSetting) { showsVersionInfo = $0 } + .controlSize(.large) } private func recordAppVersion() { @@ -119,16 +118,13 @@ struct WelcomeActionsPanel: View { showsWhatsNew = WhatsNewNotice(bundle: .main, defaults: AppStorageEnvironment.shared.defaults).recordLaunch() } - /// The badge follows entitlement and the support link follows whether anything has been paid, - /// which are different questions: a license the server has not confirmed in 30 days still - /// pauses paid features, and its owner is still not someone to ask for a purchase. + // Only states that need the person: an active license says nothing. A license the server has not + // confirmed in 30 days still pauses paid features, so it keeps its warning. @ViewBuilder - private var licenseBadge: some View { + private var licenseNotice: some View { switch licenseManager.status { case .active: - Label(licenseManager.currentTier.displayName, systemImage: "checkmark.seal.fill") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.green) + EmptyView() case .validationFailed: Label(licenseManager.currentTier.displayName, systemImage: "exclamationmark.seal.fill") .font(.subheadline.weight(.medium)) diff --git a/apple/TableProTests/Core/Sync/SyncStatusPresentationTests.swift b/apple/TableProTests/Core/Sync/SyncStatusPresentationTests.swift index a2b89d4a96..e84c73ff69 100644 --- a/apple/TableProTests/Core/Sync/SyncStatusPresentationTests.swift +++ b/apple/TableProTests/Core/Sync/SyncStatusPresentationTests.swift @@ -17,7 +17,7 @@ struct SyncStatusPresentationTests { @Test("Every error is worded in plain text, never with CloudKit's", arguments: everyError) func everyErrorIsWorded(_ error: SyncError) { let presentation = SyncStatusPresentation(status: .error(error)) - let texts = [presentation.indicatorLabel, presentation.statusTitle, presentation.helpText, presentation.message ?? ""] + let texts = [presentation.statusTitle, presentation.message ?? ""] for text in texts { #expect(!text.isEmpty) @@ -72,10 +72,8 @@ struct SyncStatusPresentationTests { #expect(one.replacingOccurrences(of: "1", with: "#") != several.replacingOccurrences(of: "3", with: "#")) } - @Test("Sync the person turned off shows no indicator; a synced state is not a warning") + @Test("A synced state is not a warning and needs no explanation") func quietStates() { - #expect(!SyncStatusPresentation(status: .disabled(.userDisabled)).showsIndicator) - #expect(SyncStatusPresentation(status: .idle).showsIndicator) #expect(!SyncStatusPresentation(status: .idle).isWarning) #expect(SyncStatusPresentation(status: .idle).message == nil) } diff --git a/apple/changelog.d/3775.changed.md b/apple/changelog.d/3775.changed.md new file mode 100644 index 0000000000..e83c8380bd --- /dev/null +++ b/apple/changelog.d/3775.changed.md @@ -0,0 +1 @@ +The welcome window's actions sit at the bottom of its sidebar, without the sync status or the active license badge.