| | |
| | | |
| | | // 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 |
| | |
| | | } |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return .locked |
| | | return .neutral |
| | | case .purchased: |
| | | return .unlocked |
| | | case .legacy: |
| | |
| | | |
| | | 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() |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | | ) |
| | | } |
| | | } |
| | | |
| | |
| | | ) |
| | | } |
| | | |
| | | private var dataProStatusCard: some View { |
| | | private var proStatusCard: some View { |
| | | HStack(alignment: .top, spacing: 18) { |
| | | VStack(alignment: .leading, spacing: 10) { |
| | | HStack(spacing: 10) { |
| | |
| | | Spacer(minLength: 36) |
| | | |
| | | VStack(alignment: .center, spacing: 24) { |
| | | dataProStatusCard |
| | | |
| | | dataSection(minHeight: 124) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | dataSectionTitle(tr("settings.initialLayout")) |
| | |
| | | case .about: |
| | | // Tab 6: About |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 72) |
| | | Spacer(minLength: 44) |
| | | |
| | | HStack { |
| | | if let icon = NSImage(named: NSImage.applicationIconName) { |
| | | Image(nsImage: icon) |
| | | .resizable() |
| | | .frame(width: 128, height: 128) |
| | | } |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text("TagLauncher") |
| | | .font(.title2) |
| | | .fontWeight(.semibold) |
| | | Text(tr("app.description")) |
| | | .font(.body) |
| | | .foregroundStyle(.secondary) |
| | | Text("\(tr("app.version")) \(appVersion) (\(tr("app.build")) \(buildVersion))") |
| | | .font(.callout) |
| | | .foregroundStyle(.tertiary) |
| | | VStack(alignment: .center, spacing: 24) { |
| | | proStatusCard |
| | | |
| | | Divider() |
| | | .padding(.vertical, 4) |
| | | |
| | | HStack(spacing: 8) { |
| | | Button { |
| | | NSWorkspace.shared.open(helpPDFURL) |
| | | } label: { |
| | | Label(tr("help.openPDF"), systemImage: "questionmark.circle") |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.regular) |
| | | |
| | | Button { |
| | | NSPasteboard.general.clearContents() |
| | | NSPasteboard.general.setString(helpPDFURL.absoluteString, forType: .string) |
| | | } label: { |
| | | Image(systemName: "link") |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.regular) |
| | | .help(tr("help.copyLink")) |
| | | HStack(alignment: .center, spacing: 24) { |
| | | if let icon = NSImage(named: NSImage.applicationIconName) { |
| | | Image(nsImage: icon) |
| | | .resizable() |
| | | .frame(width: 112, height: 112) |
| | | } |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text("TagLauncher") |
| | | .font(.title2) |
| | | .fontWeight(.semibold) |
| | | Text(tr("app.description")) |
| | | .font(.body) |
| | | .foregroundStyle(.secondary) |
| | | Text("\(tr("app.version")) \(appVersion) (\(tr("app.build")) \(buildVersion))") |
| | | .font(.callout) |
| | | .foregroundStyle(.tertiary) |
| | | |
| | | Text(tr("help.currentLanguage")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .padding(.bottom, 2) |
| | | Divider() |
| | | .padding(.vertical, 4) |
| | | |
| | | Text("万物之中,希望最美") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | Text("永桔@shanghai3168@gmail.com") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | HStack(spacing: 8) { |
| | | Button { |
| | | NSWorkspace.shared.open(helpPDFURL) |
| | | } label: { |
| | | Label(tr("help.openPDF"), systemImage: "questionmark.circle") |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.regular) |
| | | |
| | | Button { |
| | | NSPasteboard.general.clearContents() |
| | | NSPasteboard.general.setString(helpPDFURL.absoluteString, forType: .string) |
| | | } label: { |
| | | Image(systemName: "link") |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.regular) |
| | | .help(tr("help.copyLink")) |
| | | } |
| | | |
| | | Text(tr("help.currentLanguage")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .padding(.bottom, 2) |
| | | |
| | | Text("万物之中,希望最美") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | Text("永桔@shanghai3168@gmail.com") |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | } |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | } |
| | | .frame(width: 560, alignment: .leading) |
| | | .frame(width: dataPanelWidth, alignment: .center) |
| | | |
| | | Spacer(minLength: 30) |
| | | Spacer(minLength: 18) |
| | |
| | | .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) |
| | | } |