| | |
| | | |
| | | // MARK: - Preferences View |
| | | |
| | | private let themePreviewCountdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() |
| | | |
| | | private enum SettingsTab: String, CaseIterable, Identifiable { |
| | | case language |
| | | case general |
| | |
| | | @State private var selectedTab: SettingsTab = .general |
| | | @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart |
| | | @State private var settingsProPromptFeature: ProFeature? = nil |
| | | @State private var themePreviewNow = Date() |
| | | |
| | | init(initialTabRawValue: String? = nil) { |
| | | let initialTab = initialTabRawValue.flatMap(SettingsTab.init(rawValue:)) ?? .general |
| | |
| | | |
| | | private var themeStatusSummary: String { |
| | | if proEntitlement.isPreviewingTheme { |
| | | if let countdown = themePreviewCountdownText { |
| | | return tr("pro.theme.status.previewCountdown") |
| | | .replacingOccurrences(of: "%time%", with: countdown) |
| | | } |
| | | return tr("pro.theme.status.preview") |
| | | } |
| | | switch proEntitlement.accessState { |
| | |
| | | |
| | | private func themeOptionAccessory(for theme: AppGridTheme) -> ThemeOptionAccessory { |
| | | if let preview = proEntitlement.themePreviewState?.theme, preview == theme { |
| | | return .pill(text: tr("pro.card.previewBadge"), style: .preview) |
| | | return .countdown(text: themePreviewCountdownText ?? "00:00") |
| | | } |
| | | if !proEntitlement.isUnlocked && theme.requiresPro { |
| | | return .pill(text: tr("pro.card.badge"), style: .locked) |
| | |
| | | proEntitlement.clearTransientOperationState() |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) { |
| | | settingsProPromptFeature = feature |
| | | } |
| | | } |
| | | |
| | | private var themePreviewCountdownText: String? { |
| | | guard let preview = proEntitlement.themePreviewState else { return nil } |
| | | return Self.formatThemePreviewCountdown(seconds: preview.remainingSeconds(at: themePreviewNow)) |
| | | } |
| | | |
| | | private static func formatThemePreviewCountdown(seconds: Int) -> String { |
| | | let clamped = max(0, seconds) |
| | | return String(format: "%02d:%02d", clamped / 60, clamped % 60) |
| | | } |
| | | |
| | | private func refreshThemePreviewCountdown(now: Date = Date()) { |
| | | themePreviewNow = now |
| | | guard let preview = proEntitlement.themePreviewState else { return } |
| | | if preview.remainingSeconds(at: now) <= 0 { |
| | | proEntitlement.stopThemePreview() |
| | | } |
| | | } |
| | | |
| | |
| | | displayText: LauncherHotkey.quickSearch.displayString, |
| | | statusText: hotkeyStatusText(for: .quickSearch), |
| | | statusTone: hotkeyStatusTone(for: .quickSearch) |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | title: tr("trackpadGesture.title"), |
| | | description: tr("trackpadGesture.description"), |
| | | displayText: "taglauncher://show", |
| | | statusText: tr("trackpadGesture.status"), |
| | | statusTone: .neutral |
| | | ) |
| | | } |
| | | } |
| | |
| | | .onAppear { |
| | | syncSelectedLanguage() |
| | | showPendingHotkeyWarningIfNeeded() |
| | | refreshThemePreviewCountdown() |
| | | } |
| | | .onReceive(themePreviewCountdownTimer) { now in |
| | | refreshThemePreviewCountdown(now: now) |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in |
| | | showPendingHotkeyWarningIfNeeded() |
| | |
| | | private enum ThemeOptionAccessory { |
| | | case checkmark |
| | | case circle |
| | | case countdown(text: String) |
| | | case pill(text: String, style: ProStatusPillStyle) |
| | | } |
| | | |
| | |
| | | Image(systemName: "circle") |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(Color.secondary.opacity(0.42)) |
| | | case .countdown(let text): |
| | | Text(text) |
| | | .font(.system(size: 10, weight: .semibold, design: .monospaced)) |
| | | .foregroundStyle(Color(red: 0.30, green: 0.22, blue: 0.08)) |
| | | .lineLimit(1) |
| | | .frame(minWidth: 42) |
| | | .padding(.horizontal, 8) |
| | | .padding(.vertical, 4) |
| | | .background( |
| | | Capsule(style: .continuous) |
| | | .fill(Color.orange.opacity(0.14)) |
| | | ) |
| | | .overlay( |
| | | Capsule(style: .continuous) |
| | | .stroke(Color.orange.opacity(0.28), lineWidth: 1) |
| | | ) |
| | | case .pill(let text, let style): |
| | | ProStatusPill(text: text, style: style, compact: true) |
| | | } |