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
65 changes: 0 additions & 65 deletions apple/TablePro/Views/Components/SyncStatusIndicator.swift

This file was deleted.

78 changes: 1 addition & 77 deletions apple/TablePro/Views/Components/SyncStatusPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
154 changes: 75 additions & 79 deletions apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,115 +20,111 @@ 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() {
guard showsWhatsNew == nil else { return }
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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions apple/changelog.d/3775.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The welcome window's actions sit at the bottom of its sidebar, without the sync status or the active license badge.
Loading