Skip to content
Closed
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
2 changes: 1 addition & 1 deletion sxitch/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ struct ContentView: View {
let windowPickerEnabled = UserDefaults.standard.bool(forKey: "windowPickerEnabled")
let currentMode = appState.mode

if windows.count > 1, userState.shared.isPro, windowPickerEnabled {
if windows.count > 1, userState.shared.hasFullAccess, windowPickerEnabled {
appState.drillDownApp = runningApp
} else if windows.count == 1 {
let theme = ModeTheme.theme(for: currentMode)
Expand Down
12 changes: 7 additions & 5 deletions sxitch/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

private func toggleMode(_ mode: AppMode) {
guard window.isVisible, proState.isPro else {
guard window.isVisible, proState.hasFullAccess else {
closeWindow()
let alert = NSAlert()
alert.messageText = "Hide and Quit modes are disabled"
Expand Down Expand Up @@ -215,7 +215,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// App-launch hotkeys are Pro-only. The handler is registered regardless so the
// shortcut stays claimed and survives the async licence check, but it only acts
// once a licence is present.
guard let self, self.proState.isPro else { return }
guard let self, self.proState.hasFullAccess else { return }
guard let url = URL(string: bundleURL) else { return }
let config = NSWorkspace.OpenConfiguration()
config.activates = true
Expand All @@ -225,7 +225,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func registerModeHotkeys() {
let modes = CustomModeStore.load()
let eligible = proState.isPro
let eligible = proState.hasFullAccess
? modes
: Array(modes.prefix(CustomModeStore.freeModeLimit))
let eligibleIDs = Set(eligible.map { $0.id.uuidString })
Expand Down Expand Up @@ -354,7 +354,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if appState.mode == .normal {
closeWindow()
}
} else if windows.count > 1, proState.isPro, windowPickerEnabled {
} else if windows.count > 1, proState.hasFullAccess, windowPickerEnabled {
appState.drillDownApp = running
} else {
theme.appAction(entry)
Expand Down Expand Up @@ -442,6 +442,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationDidFinishLaunching(_: Notification) {
proState.beginTrialIfNeeded()

window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 0, height: 0),
styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel],
Expand Down Expand Up @@ -834,7 +836,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if let letter = typedCharacter(from: event) {
let raw = String(letter)
let pickerChar: String
if proState.isPro {
if proState.hasFullAccess {
let overrides = UserDefaults.standard.keyOverrides
pickerChar = overrides[raw] ?? raw
} else {
Expand Down
2 changes: 1 addition & 1 deletion sxitch/Core/RunningApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ struct RunningApp: SwitchableApp, Equatable {
return deduped
.filter { app in
app.app.activationPolicy == .regular
&& (!blacklist.contains(app.appName.lowercased()) || !usState.isPro)
&& (!blacklist.contains(app.appName.lowercased()) || !usState.hasFullAccess)
}
.sorted { $0.appName < $1.appName }
}
Expand Down
173 changes: 131 additions & 42 deletions sxitch/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -722,34 +722,103 @@ struct LicensePage: View {
@State private var isActivating = false
@State private var errorMessage: String?
@State private var justActivated = false
@State private var skipVisible = false
@State private var justStartedTrial = false
@State private var showingKeyEntry = false

private var showsConfirmation: Bool {
justActivated || justStartedTrial || appState.hasFullAccess
}

var body: some View {
VStack(spacing: 18) {
if justActivated {
activatedContent
if showsConfirmation {
confirmationContent
} else if showingKeyEntry {
keyEntryContent
} else {
offerContent
skipContent
choiceContent
}
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
// Skip appears after a short beat so the offer gets a fair look first.
try? await Task.sleep(for: .seconds(1.2))
withAnimation(.easeOut(duration: 0.3)) {
skipVisible = true
.animation(.easeOut(duration: 0.3), value: showingKeyEntry)
.animation(.spring(response: 0.4, dampingFraction: 0.75), value: showsConfirmation)
}

// MARK: Choice state

private var choiceContent: some View {
VStack(spacing: 16) {
VStack(spacing: 8) {
Text("Choose Your Sxitch")
.font(.largeTitle)
.fontWeight(.bold)

Text("Try everything Pro has to offer, unlock it with a key, or keep the free version. You can always change this later in Settings.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 440)
}

VStack(spacing: 10) {
Button {
appState.startTrial()
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
justStartedTrial = true
}
} label: {
VStack(spacing: 3) {
Text("Start 14-Day Pro Trial")
.font(.headline)
Text("All Pro features free for 14 days. No card required.")
.font(.caption)
.opacity(0.7)
}
.frame(maxWidth: 380)
}
.buttonStyle(WhitishProminentStyle())

Button {
withAnimation(.easeOut(duration: 0.3)) {
showingKeyEntry = true
}
} label: {
VStack(spacing: 3) {
Text("Enter Activation Key")
.font(.headline)
Text("Already purchased? Unlock Pro with your license key.")
.font(.caption)
.opacity(0.7)
}
.frame(maxWidth: 380)
}
.buttonStyle(WhitishOutlineStyle())

Button {
onSkip()
} label: {
VStack(spacing: 3) {
Text("Use in Free Mode")
.font(.headline)
Text("Core switching features stay free, forever.")
.font(.caption)
.opacity(0.7)
}
.frame(maxWidth: 380)
}
.buttonStyle(WhitishGhostStyle())
}
}
.transition(.opacity)
}

// MARK: Offer state
// MARK: Key entry state

private var offerContent: some View {
private var keyEntryContent: some View {
VStack(spacing: 16) {
VStack(spacing: 8) {
Text("Activate License Key")
Text("Enter Activation Key")
.font(.largeTitle)
.fontWeight(.bold)

Expand All @@ -771,7 +840,7 @@ struct LicensePage: View {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Activating…")
Text("Activating")
}
} else {
Text("Activate")
Expand All @@ -781,7 +850,15 @@ struct LicensePage: View {
.disabled(
licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isActivating
)
}

if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
}

HStack(spacing: 12) {
Button {
if let url = URL(string: "https://sxitch.app/#pricing") {
NSWorkspace.shared.open(url)
Expand All @@ -790,20 +867,30 @@ struct LicensePage: View {
Text("Get Pro")
}
.buttonStyle(WhitishOutlineStyle())
}

if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
Button {
withAnimation(.easeOut(duration: 0.3)) {
showingKeyEntry = false
}
} label: {
Text("Back to Options")
}
.buttonStyle(WhitishGhostStyle())

Button {
onSkip()
} label: {
Text("Skip")
}
.buttonStyle(WhitishGhostStyle())
}
}
.transition(.opacity)
}

// MARK: Activated state
// MARK: Confirmation state

private var activatedContent: some View {
private var confirmationContent: some View {
VStack(spacing: 18) {
ZStack {
ConfettiBurst(tint: accent)
Expand All @@ -812,13 +899,23 @@ struct LicensePage: View {
.frame(height: 100)

VStack(spacing: 8) {
Text("You're Pro! 🎉")
.font(.largeTitle)
.fontWeight(.bold)
if justActivated || appState.isPro {
Text("You're Pro! 🎉")
.font(.largeTitle)
.fontWeight(.bold)

Text("Thank you for supporting Sxitch's development.")
.font(.body)
.foregroundStyle(.secondary)
} else {
Text("Trial Started! 🎉")
.font(.largeTitle)
.fontWeight(.bold)

Text("Thank you for supporting Sxitch's development.")
.font(.body)
.foregroundStyle(.secondary)
Text("All Pro features are unlocked for the next \(userState.trialLengthDays) days. Enjoy!")
.font(.body)
.foregroundStyle(.secondary)
}
}

Button("Continue") {
Expand All @@ -829,19 +926,6 @@ struct LicensePage: View {
.transition(.scale(scale: 0.9).combined(with: .opacity))
}

// MARK: Skip

@ViewBuilder
private var skipContent: some View {
if skipVisible {
Button("Skip") {
onSkip()
}
.buttonStyle(WhitishGhostStyle())
.animation(.easeOut(duration: 0.4), value: skipVisible)
}
}

// MARK: Actions

private func performActivation() async {
Expand All @@ -856,7 +940,6 @@ struct LicensePage: View {
licenseKey = ""
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
justActivated = true
skipVisible = true
}
} else {
errorMessage = "Invalid license key or activation limit reached."
Expand Down Expand Up @@ -900,7 +983,13 @@ struct FinishPage: View {
}

HStack(spacing: 12) {
if !userState.shared.isPro {
if userState.shared.isPro {
// Nothing extra to upsell for licensed users.
} else if userState.shared.isTrialActive {
Text("\(userState.shared.trialDaysRemaining) days of Pro left")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Link(destination: URL(string: "https://sxitch.app/#pricing")!) {
Label("Get Sxitch Pro", systemImage: "star.fill")
}
Expand Down
36 changes: 33 additions & 3 deletions sxitch/Settings/ActivateSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,42 @@ struct ActivateSettingsView: View, SettingsTab {
Button("Deactivate Device", role: .destructive) {
deactivateLicense()
}
} else if appState.isTrialActive {
HStack {
Image(systemName: "hourglass.bottom.filled.tentpath")
.foregroundColor(.blue)
.imageScale(.large)
VStack(alignment: .leading, spacing: 2) {
Text("Pro Trial Active")
.font(.headline)
Text(
"\(appState.trialDaysRemaining) of \(userState.trialLengthDays) days remaining. All Pro features are unlocked."
)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
} else {
HStack {
Image(systemName: "xmark.seal.fill")
Image(systemName: appState.isTrialExpired ? "clock.badge.xmark" : "xmark.seal.fill")
.foregroundColor(.secondary)
Text("Free Version")
.font(.headline)
VStack(alignment: .leading, spacing: 2) {
Text(appState.isTrialExpired ? "Free Version (Trial Ended)" : "Free Version")
.font(.headline)
if appState.isTrialExpired {
Text("Your 14-day Pro trial has ended.")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
if !appState.hasTrialEverStarted {
Button {
appState.startTrial()
} label: {
Label("Start 14-Day Free Trial", systemImage: "clock.badge.checkmark")
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions sxitch/Settings/CustomModesSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ struct CustomModesSettingsView: View {
private var usState = userState.shared

private var atLimit: Bool {
!usState.isPro && modes.count >= CustomModeStore.freeModeLimit
!usState.hasFullAccess && modes.count >= CustomModeStore.freeModeLimit
}

private var canAdd: Bool {
usState.isPro || modes.count < CustomModeStore.freeModeLimit
usState.hasFullAccess || modes.count < CustomModeStore.freeModeLimit
}

var body: some View {
Expand Down
Loading
Loading