| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import Carbon |
| | | |
| | | // 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 |
| | | case theme |
| | | case hotkeys |
| | | case tags |
| | | case data |
| | | case pro |
| | | case about |
| | | |
| | | var id: String { rawValue } |
| | |
| | | switch self { |
| | | case .language: return "settings.language" |
| | | case .general: return "settings.general" |
| | | case .theme: return "settings.theme" |
| | | case .hotkeys: return "quickSearch.hotkeys" |
| | | case .tags: return "settings.tags" |
| | | case .data: return "settings.data" |
| | | case .pro: return "pro.card.badge" |
| | | case .about: return "settings.about" |
| | | } |
| | | } |
| | |
| | | switch self { |
| | | case .language: return "globe" |
| | | case .general: return "gearshape" |
| | | case .theme: return "paintpalette" |
| | | case .hotkeys: return "keyboard" |
| | | case .tags: return "tag.fill" |
| | | case .data: return "externaldrive.fill" |
| | | case .tags: return "tag" |
| | | case .data: return "externaldrive" |
| | | case .pro: return "crown" |
| | | case .about: return "info.circle" |
| | | } |
| | | } |
| | | } |
| | | |
| | | private enum InitialLayoutMode: String { |
| | | case uncategorized |
| | | case smart |
| | | } |
| | | |
| | | struct PreferencesView: View { |
| | | private let settingsWindowWidth: CGFloat = 1000 |
| | | private let settingsWindowHeight: CGFloat = 640 |
| | | private let settingsContentWidth: CGFloat = 940 |
| | | private let languageContentWidth: CGFloat = 820 |
| | | private let generalContentWidth: CGFloat = 720 |
| | | private let generalLabelWidth: CGFloat = 190 |
| | | private let generalControlWidth: CGFloat = 500 |
| | | private let compactPickerWidth: CGFloat = 320 |
| | | private let dataPanelWidth: CGFloat = 760 |
| | | private let dataLabelWidth: CGFloat = 220 |
| | | private let dataActionWidth: CGFloat = 112 |
| | | private let dataPanelWidth: CGFloat = 820 |
| | | private let dataLabelWidth: CGFloat = 190 |
| | | private let dataActionWidth: CGFloat = 128 |
| | | |
| | | @AppStorage("tagFontSize") private var tagFontSize: Double = AppDefaults.tagFontSize |
| | | @AppStorage("iconSize") private var iconSize: Double = AppDefaults.iconSize |
| | |
| | | @AppStorage("showDockIcon") private var showDockIcon = AppDefaults.showDockIcon |
| | | @AppStorage("launchAtLogin") private var launchAtLogin = AppDefaults.launchAtLogin |
| | | @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles |
| | | @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips |
| | | @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID |
| | | @AppStorage(AppGridTheme.deepBlueTuningStorageKey) private var deepBlueThemeTuning = AppGridTheme.deepBlue.defaultColorTuning |
| | | @AppStorage(AppGridTheme.colorfulTuningStorageKey) private var colorfulThemeTuning = AppGridTheme.colorful.defaultColorTuning |
| | | @AppStorage("mainHotkeyRegistrationState") private var mainHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue |
| | | @AppStorage("quickSearchHotkeyRegistrationState") private var quickSearchHotkeyRegistrationState = LauncherHotkeyRegistrationState.active.rawValue |
| | | @ObservedObject private var proEntitlement = ProEntitlementCenter.shared |
| | | @State private var selectedLanguage = L10n.selectedLanguageCode |
| | | @State private var isRefreshingLanguage = false |
| | | @State private var allApps: [AppInfo] = [] |
| | | @State private var tagColors: [String: Int] = [:] |
| | | @State private var tagCustomColors: [String: TagCustomColor] = [:] |
| | | @State private var categoryScheme = TagDatabase.CategorySchemeState() |
| | | @State private var isApplyingSystemScheme = false |
| | | @State private var isResettingToUncategorized = false |
| | | @State private var showApplySystemSchemeConfirmation = false |
| | | @State private var showResetToUncategorizedConfirmation = false |
| | | @State private var isDataFilePanelPresented = false |
| | | @State private var hotkeyStatusToast: String? = nil |
| | | @State private var hotkeyStatusToastToken: UUID? = nil |
| | | @State private var recordingHotkeyKind: LauncherHotkeyKind? = nil |
| | | @State private var hotkeyRecorderMonitor: Any? = nil |
| | | @State private var settingsEscapeMonitor: Any? = nil |
| | | @State private var hotkeySettingsRefreshToken = UUID() |
| | | @State private var selectedTab: SettingsTab = .general |
| | | @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart |
| | | @State private var settingsProPromptFeature: ProFeature? = nil |
| | | @State private var settingsCustomContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil |
| | | @State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus() |
| | | @State private var themePreviewNow = Date() |
| | | |
| | | init(initialTabRawValue: String? = nil) { |
| | | let initialTab = initialTabRawValue.flatMap(SettingsTab.init(rawValue:)) ?? .general |
| | | _selectedTab = State(initialValue: initialTab) |
| | | } |
| | | |
| | | private func selectTab(rawValue: String?) { |
| | | guard let rawValue, let tab = SettingsTab(rawValue: rawValue) else { return } |
| | | selectedTab = tab |
| | | } |
| | | |
| | | private func scanApps() { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | |
| | | let store = TagDatabase.loadWithEnsuredCategoryScheme() |
| | | apps = TagEditor.annotate(apps: apps) |
| | | let colors = store.tags.mapValues { $0.color } |
| | | let customColors = store.tags.compactMapValues { $0.customColor } |
| | | let quotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store) |
| | | DispatchQueue.main.async { |
| | | allApps = apps |
| | | tagColors = colors |
| | | tagCustomColors = customColors |
| | | categoryScheme = store.categoryScheme |
| | | settingsCustomContainerQuotaOverviewStatus = quotaStatus |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | private func exportTags() { |
| | | guard proEntitlement.isUnlocked else { |
| | | presentSettingsProPrompt(for: .layoutExport) |
| | | return |
| | | } |
| | | guard prepareDataFilePanelPresentation() else { return } |
| | | DispatchQueue.main.async { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | |
| | | guard response == .OK, let url = panel.url else { return } |
| | | do { |
| | | try TagDatabase.exportTo(url) |
| | | } catch let error as ProEntitlementError { |
| | | if let feature = error.feature { |
| | | presentSettingsProPrompt(for: feature) |
| | | } |
| | | } catch { |
| | | fputs("[TagLauncher] Export failed: \(error)\n", stderr) |
| | | showDataAlert(title: tr("settings.exportFailed"), message: error.localizedDescription) |
| | |
| | | } |
| | | |
| | | private func importTags() { |
| | | guard proEntitlement.isUnlocked else { |
| | | presentSettingsProPrompt(for: .layoutImport) |
| | | return |
| | | } |
| | | guard prepareDataFilePanelPresentation() else { return } |
| | | DispatchQueue.main.async { |
| | | let panel = NSOpenPanel() |
| | |
| | | scanApps() |
| | | refreshDataState() |
| | | notifyDataChanged() |
| | | } catch let error as ProEntitlementError { |
| | | if let feature = error.feature { |
| | | presentSettingsProPrompt(for: feature) |
| | | } |
| | | } catch { |
| | | fputs("[TagLauncher] Import failed: \(error)\n", stderr) |
| | | showDataAlert(title: tr("settings.importFailed"), message: error.localizedDescription) |
| | |
| | | } |
| | | } |
| | | |
| | | private func installSettingsEscapeMonitor() { |
| | | guard settingsEscapeMonitor == nil else { return } |
| | | settingsEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in |
| | | guard Int(event.keyCode) == kVK_Escape, |
| | | isSettingsOverlayEscapeEvent(event) |
| | | else { return event } |
| | | return handleSettingsEscapeKey() ? nil : event |
| | | } |
| | | } |
| | | |
| | | private func removeSettingsEscapeMonitor() { |
| | | if let settingsEscapeMonitor { |
| | | NSEvent.removeMonitor(settingsEscapeMonitor) |
| | | } |
| | | settingsEscapeMonitor = nil |
| | | } |
| | | |
| | | private func isSettingsOverlayEscapeEvent(_ event: NSEvent) -> Bool { |
| | | guard let window = preferencesWindow, |
| | | window.isVisible, |
| | | let parent = window.parent as? OverlayPanel, |
| | | parent.isVisible |
| | | else { return false } |
| | | |
| | | if let eventWindow = event.window { |
| | | return eventWindow == window || eventWindow == parent || eventWindow.parent == window |
| | | } |
| | | return NSApp.keyWindow == window || NSApp.keyWindow == parent || NSApp.mainWindow == window |
| | | } |
| | | |
| | | private func handleSettingsEscapeKey() -> Bool { |
| | | guard let window = preferencesWindow else { return false } |
| | | guard !isDataFilePanelPresented, window.attachedSheet == nil else { return false } |
| | | |
| | | if settingsProPromptFeature != nil { |
| | | dismissSettingsProPrompt() |
| | | return true |
| | | } |
| | | |
| | | if showApplySystemSchemeConfirmation { |
| | | withAnimation(.easeOut(duration: 0.14)) { |
| | | showApplySystemSchemeConfirmation = false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | if showResetToUncategorizedConfirmation { |
| | | withAnimation(.easeOut(duration: 0.14)) { |
| | | showResetToUncategorizedConfirmation = false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | if recordingHotkeyKind != nil { |
| | | stopHotkeyRecording(showCancelledToast: true) |
| | | return true |
| | | } |
| | | |
| | | window.performClose(nil) |
| | | return true |
| | | } |
| | | |
| | | private var preferencesWindow: NSWindow? { |
| | | if let taggedWindow = NSApp.windows.first(where: { |
| | | $0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible |
| | |
| | | .first(where: isPreferencesWindowFallback(_:)) |
| | | } |
| | | |
| | | private var localizedPreferencesWindowTitle: String { |
| | | tr("menu.preferences").replacingOccurrences(of: "…", with: "") |
| | | } |
| | | |
| | | private func refreshPreferencesWindowTitle() { |
| | | preferencesWindow?.title = localizedPreferencesWindowTitle |
| | | } |
| | | |
| | | private func isPreferencesWindowFallback(_ window: NSWindow) -> Bool { |
| | | guard window.isVisible, |
| | | !(window is NSPanel), |
| | | !(window is OverlayPanel) |
| | | else { return false } |
| | | |
| | | let preferencesTitle = tr("menu.preferences").replacingOccurrences(of: "…", with: "") |
| | | return window.title == preferencesTitle |
| | | return window.title == localizedPreferencesWindowTitle |
| | | } |
| | | |
| | | private func showDataAlert(title: String, message: String) { |
| | |
| | | let status = LauncherHotkeyRegistrationStore.failureCode(for: kind) |
| | | .map(String.init) ?? "-" |
| | | return tr(key) |
| | | .replacingOccurrences(of: "%shortcut%", with: kind.hotkey.displayString) |
| | | .replacingOccurrences(of: "%shortcut%", with: LauncherHotkeySettings.effectiveHotkey(for: kind).displayString) |
| | | .replacingOccurrences(of: "%status%", with: status) |
| | | } |
| | | |
| | | private func startHotkeyRecording(for kind: LauncherHotkeyKind) { |
| | | guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else { |
| | | presentSettingsProPrompt(for: .customHotkeys) |
| | | return |
| | | } |
| | | guard let appDelegate = AppDelegate.shared else { |
| | | handleHotkeyCustomizationResult(.registrationFailed(-1)) |
| | | return |
| | | } |
| | | stopHotkeyRecording(showCancelledToast: false) |
| | | appDelegate.suspendConfiguredHotkeysForRecording() |
| | | recordingHotkeyKind = kind |
| | | showHotkeyStatusToast(tr("hotkeys.recording")) |
| | | hotkeyRecorderMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in |
| | | DispatchQueue.main.async { |
| | | self.captureHotkeyEvent(event) |
| | | } |
| | | return nil |
| | | } |
| | | } |
| | | |
| | | private func captureHotkeyEvent(_ event: NSEvent) { |
| | | guard let kind = recordingHotkeyKind else { return } |
| | | if Int(event.keyCode) == kVK_Escape { |
| | | stopHotkeyRecording(showCancelledToast: true) |
| | | return |
| | | } |
| | | let candidate = LauncherHotkeySettings.candidate(from: event) |
| | | let result = AppDelegate.shared? |
| | | .applyCustomHotkey(candidate, for: kind) |
| | | ?? .registrationFailed(-1) |
| | | stopHotkeyRecording(showCancelledToast: false) |
| | | handleHotkeyCustomizationResult(result) |
| | | } |
| | | |
| | | private func restoreDefaultHotkey(for kind: LauncherHotkeyKind) { |
| | | guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else { |
| | | presentSettingsProPrompt(for: .customHotkeys) |
| | | return |
| | | } |
| | | let result = AppDelegate.shared? |
| | | .restoreDefaultHotkey(for: kind) |
| | | ?? .registrationFailed(-1) |
| | | handleHotkeyCustomizationResult(result) |
| | | } |
| | | |
| | | private func stopHotkeyRecording(showCancelledToast: Bool = false) { |
| | | let wasRecording = recordingHotkeyKind != nil || hotkeyRecorderMonitor != nil |
| | | if let hotkeyRecorderMonitor { |
| | | NSEvent.removeMonitor(hotkeyRecorderMonitor) |
| | | } |
| | | hotkeyRecorderMonitor = nil |
| | | recordingHotkeyKind = nil |
| | | if wasRecording { |
| | | AppDelegate.shared?.resumeConfiguredHotkeysAfterRecording() |
| | | } |
| | | if showCancelledToast { |
| | | showHotkeyStatusToast(tr("hotkeys.recordingCancelled")) |
| | | } |
| | | } |
| | | |
| | | private func handleHotkeyCustomizationResult(_ result: LauncherHotkeyCustomizationResult) { |
| | | if case .locked = result { |
| | | presentSettingsProPrompt(for: .customHotkeys) |
| | | return |
| | | } |
| | | hotkeySettingsRefreshToken = UUID() |
| | | showHotkeyStatusToast(hotkeyCustomizationMessage(for: result)) |
| | | } |
| | | |
| | | private func hotkeyCustomizationMessage(for result: LauncherHotkeyCustomizationResult) -> String { |
| | | switch result { |
| | | case .registrationFailed(let status): |
| | | return tr(result.messageKey).replacingOccurrences(of: "%status%", with: String(status)) |
| | | default: |
| | | return tr(result.messageKey) |
| | | } |
| | | } |
| | | |
| | | private func showHotkeyStatusToast(_ message: String) { |
| | |
| | | } |
| | | } |
| | | |
| | | private func applySelectedInitialLayout() { |
| | | switch selectedInitialLayoutMode { |
| | | case .uncategorized: |
| | | guard !isResettingToUncategorized else { return } |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | showResetToUncategorizedConfirmation = true |
| | | } |
| | | case .smart: |
| | | applySystemInitialScheme() |
| | | } |
| | | } |
| | | |
| | | private func performApplySystemInitialScheme() { |
| | | guard !isApplyingSystemScheme else { return } |
| | | showApplySystemSchemeConfirmation = false |
| | |
| | | message: tr("settings.systemSchemeApplyFailed") |
| | | ) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func performResetToUncategorized() { |
| | | guard !isResettingToUncategorized else { return } |
| | | showResetToUncategorizedConfirmation = false |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | isResettingToUncategorized = true |
| | | |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let result = AppLibraryController.resetToUncategorized() |
| | | |
| | | DispatchQueue.main.async { |
| | | isResettingToUncategorized = false |
| | | allApps = result.snapshot.apps |
| | | tagColors = result.snapshot.tagColors |
| | | refreshDataState() |
| | | notifyDataChanged() |
| | | showDataAlert( |
| | | title: tr("settings.resetToUncategorizedApplied"), |
| | | message: tr("settings.resetToUncategorizedAppliedMessage") |
| | | ) |
| | | } |
| | | } |
| | | } |
| | |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func initialLayoutOption( |
| | | _ title: String, |
| | | mode: InitialLayoutMode |
| | | ) -> some View { |
| | | let isSelected = selectedInitialLayoutMode == mode |
| | | return Button { |
| | | selectedInitialLayoutMode = mode |
| | | } label: { |
| | | HStack(spacing: 6) { |
| | | Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .frame(width: 16, height: 16) |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.85) |
| | | } |
| | | .foregroundStyle(isSelected ? Color.accentColor : Color.primary) |
| | | .contentShape(Rectangle()) |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func dataSectionTitle(_ title: String) -> some View { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | } |
| | | |
| | | private func dataSection<Content: View>( |
| | | minHeight: CGFloat, |
| | | @ViewBuilder content: () -> Content |
| | | ) -> some View { |
| | | content() |
| | | .padding(.horizontal, 20) |
| | | .padding(.vertical, 18) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | .frame(minHeight: minHeight, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.18), lineWidth: 1) |
| | | ) |
| | | } |
| | | |
| | | private func syncSelectedLanguage() { |
| | | let code = L10n.selectedLanguageCode |
| | | guard selectedLanguage != code else { return } |
| | |
| | | GridItem(.flexible(minimum: 220), spacing: 10, alignment: .top), |
| | | ] |
| | | } |
| | | private var themeColumns: [GridItem] { |
| | | [ |
| | | GridItem(.flexible(minimum: 220), spacing: 12, alignment: .top), |
| | | GridItem(.flexible(minimum: 220), spacing: 12, alignment: .top), |
| | | ] |
| | | } |
| | | private var displayModeOptions: [(id: String, title: String)] { |
| | | [ |
| | | ("flat", tr("settings.flat")), |
| | |
| | | } |
| | | private var containerDisplayModeOptions: [(id: String, title: String)] { |
| | | Array(displayModeOptions.dropFirst()) |
| | | } |
| | | private var selectedAppGridTheme: AppGridTheme { |
| | | AppGridTheme(storedID: appGridThemeID) |
| | | } |
| | | |
| | | private var effectiveAppGridTheme: AppGridTheme { |
| | | ProEntitlementPolicy.effectiveTheme(for: selectedAppGridTheme) |
| | | } |
| | | |
| | | private var effectiveDisplayMode: String { |
| | | ProEntitlementPolicy.effectiveDisplayMode(for: displayMode) |
| | | } |
| | | |
| | | private var proStatusPillStyle: ProStatusPillStyle { |
| | | if proEntitlement.isPreviewingProAppearance { |
| | | return .preview |
| | | } |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return .neutral |
| | | case .purchased: |
| | | return .unlocked |
| | | case .legacy: |
| | | return .legacy |
| | | } |
| | | } |
| | | |
| | | private var proAccentColor: Color { |
| | | Color(red: 0.56, green: 0.35, blue: 0.03) |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private func proHeaderStatusLabel(compact: Bool = false) -> some View { |
| | | if proEntitlement.accessState == .free && !proEntitlement.isPreviewingProAppearance { |
| | | Text(tr("settings.proStatus.freeUser")) |
| | | .font(.system(size: compact ? 12 : 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | } else { |
| | | ProStatusPill( |
| | | text: tr(proEntitlement.compactStatusTextKey), |
| | | style: proStatusPillStyle, |
| | | compact: compact |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private func centeredSettingsScrollContent<Content: View>( |
| | | width: CGFloat, |
| | | @ViewBuilder content: @escaping () -> Content |
| | | ) -> some View { |
| | | GeometryReader { proxy in |
| | | ScrollView(.vertical, showsIndicators: true) { |
| | | VStack(alignment: .center, spacing: 18) { |
| | | content() |
| | | } |
| | | .frame(width: width, alignment: .center) |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .frame(minHeight: proxy.size.height, alignment: .center) |
| | | } |
| | | } |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private func proUnlockedBenefitLine(_ textKey: String, bottomPadding: CGFloat = 14) -> some View { |
| | | if proEntitlement.isUnlocked { |
| | | HStack(spacing: 12) { |
| | | ProStatusPill( |
| | | text: tr(proEntitlement.compactStatusTextKey), |
| | | style: proStatusPillStyle |
| | | ) |
| | | |
| | | Text(tr(textKey)) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(2) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | |
| | | Spacer(minLength: 0) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .padding(.bottom, bottomPadding) |
| | | } |
| | | } |
| | | |
| | | private func proPurchaseGuidanceLine( |
| | | _ textKey: String, |
| | | feature: ProFeature, |
| | | bottomPadding: CGFloat = 14, |
| | | compact: Bool = false |
| | | ) -> some View { |
| | | HStack(spacing: compact ? 8 : 12) { |
| | | proHeaderStatusLabel(compact: compact) |
| | | .layoutPriority(1) |
| | | |
| | | Text(tr(textKey)) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(2) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .layoutPriority(2) |
| | | |
| | | Spacer(minLength: 8) |
| | | |
| | | if !proEntitlement.isUnlocked { |
| | | Button(unlockButtonTitle()) { |
| | | presentSettingsProPrompt(for: feature) |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(compact ? .small : .regular) |
| | | .layoutPriority(3) |
| | | |
| | | Button(tr("pro.card.restore")) { |
| | | proEntitlement.restorePurchases() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(compact ? .small : .regular) |
| | | .layoutPriority(3) |
| | | } |
| | | } |
| | | .padding(.horizontal, compact ? 10 : 16) |
| | | .padding(.vertical, compact ? 8 : 14) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | .padding(.bottom, bottomPadding) |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private func proAccessGuidanceLine( |
| | | freeTextKey: String, |
| | | unlockedTextKey: String, |
| | | feature: ProFeature, |
| | | bottomPadding: CGFloat = 14, |
| | | compact: Bool = false |
| | | ) -> some View { |
| | | if proEntitlement.isUnlocked { |
| | | proUnlockedBenefitLine(unlockedTextKey, bottomPadding: bottomPadding) |
| | | } else { |
| | | proPurchaseGuidanceLine(freeTextKey, feature: feature, bottomPadding: bottomPadding, compact: compact) |
| | | } |
| | | } |
| | | |
| | | private var settingsProHeaderFeature: ProFeature { |
| | | switch selectedTab { |
| | | case .hotkeys: |
| | | return .customHotkeys |
| | | case .tags: |
| | | return .customTagColors |
| | | case .data: |
| | | return .layoutExport |
| | | case .theme, .general, .language, .pro, .about: |
| | | return .premiumThemes |
| | | } |
| | | } |
| | | |
| | | private var settingsProHeaderText: String { |
| | | if selectedTab == .pro { |
| | | return tr(proEntitlement.isUnlocked ? "settings.proStatus.proUser" : "settings.proStatus.freeUser") |
| | | } |
| | | |
| | | switch selectedTab { |
| | | case .language, .about: |
| | | return "" |
| | | case .general: |
| | | return tr(proEntitlement.isUnlocked ? "settings.proBenefit.general" : "settings.proGuide.general") |
| | | case .theme: |
| | | return themeStatusSummary |
| | | case .hotkeys: |
| | | return tr(proEntitlement.isUnlocked ? "settings.proBenefit.hotkeys" : "settings.proGuide.hotkeys") |
| | | case .tags: |
| | | return tr(proEntitlement.isUnlocked ? "settings.proBenefit.tags" : "settings.proGuide.tags") |
| | | case .data: |
| | | return tr(proEntitlement.isUnlocked ? "settings.proBenefit.data" : "settings.proGuide.data") |
| | | case .pro: |
| | | return tr(proEntitlement.isUnlocked ? "settings.proStatus.proUser" : "settings.proStatus.freeUser") |
| | | } |
| | | } |
| | | |
| | | private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus { |
| | | if proEntitlement.isUnlocked { |
| | | return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max) |
| | | } |
| | | return settingsCustomContainerQuotaOverviewStatus |
| | | } |
| | | |
| | | private var customContainerQuotaAccessory: some View { |
| | | let status = effectiveCustomContainerQuotaStatus |
| | | let text = status.isUnlimited |
| | | ? tr("pro.customContainers.quotaUnlimitedShort") |
| | | : tr( |
| | | "pro.customContainers.quotaShort", |
| | | replacements: [ |
| | | "%used%": "\(status.used)", |
| | | "%limit%": "\(status.limit)" |
| | | ] |
| | | ) |
| | | return Text(text) |
| | | .font(.system(size: 12, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | } |
| | | |
| | | private func refreshSettingsCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) { |
| | | settingsCustomContainerQuotaOverviewStatus = status |
| | | } |
| | | |
| | | private func refreshSettingsCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) { |
| | | settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store) |
| | | } |
| | | |
| | | private var shouldShowSettingsFixedProHeader: Bool { |
| | | true |
| | | } |
| | | |
| | | private var shouldCenterUnlockedIdentityHeader: Bool { |
| | | guard proEntitlement.isUnlocked else { return false } |
| | | switch selectedTab { |
| | | case .language, .pro, .about: |
| | | return true |
| | | case .general, .theme, .hotkeys, .tags, .data: |
| | | return false |
| | | } |
| | | } |
| | | |
| | | private var settingsFixedProHeader: some View { |
| | | HStack { |
| | | HStack(spacing: 12) { |
| | | if shouldCenterUnlockedIdentityHeader { |
| | | Spacer(minLength: 0) |
| | | |
| | | ProStatusPill( |
| | | text: tr("pro.status.unlocked"), |
| | | style: .unlocked, |
| | | compact: true |
| | | ) |
| | | .layoutPriority(1) |
| | | |
| | | Text(tr("settings.proStatus.proUser")) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .layoutPriority(2) |
| | | |
| | | Spacer(minLength: 0) |
| | | } else { |
| | | if selectedTab != .pro { |
| | | proHeaderStatusLabel(compact: true) |
| | | .layoutPriority(1) |
| | | } |
| | | |
| | | if !settingsProHeaderText.isEmpty { |
| | | Text(settingsProHeaderText) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(2) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .layoutPriority(2) |
| | | } |
| | | |
| | | Spacer(minLength: 8) |
| | | |
| | | if !proEntitlement.isUnlocked { |
| | | Button(unlockButtonTitle()) { |
| | | presentSettingsProPrompt(for: settingsProHeaderFeature) |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.small) |
| | | .layoutPriority(3) |
| | | |
| | | Button(tr("pro.card.restore")) { |
| | | proEntitlement.restorePurchases() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.small) |
| | | .layoutPriority(3) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 16) |
| | | .padding(.vertical, 10) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | .frame(minHeight: 44) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .padding(.top, 12) |
| | | .padding(.bottom, 10) |
| | | .background(Color(nsColor: .windowBackgroundColor)) |
| | | } |
| | | |
| | | private var proTabComparisonFeatures: [ProTabComparisonFeature] { |
| | | [ |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.maintainableTagCount"), |
| | | freeStatusKey: "settings.proStatus.maxSixTags", |
| | | proLockedStatusKey: "settings.proStatus.unlimitedTags", |
| | | proUnlockedStatusKey: "settings.proStatus.unlimitedTags", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.tagOrderPersistence"), |
| | | freeStatusKey: "settings.proStatus.previewOnly", |
| | | proLockedStatusKey: "settings.proStatus.supported", |
| | | proUnlockedStatusKey: "settings.proStatus.supported", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.containerAppOrderPersistence"), |
| | | freeStatusKey: "settings.proStatus.previewOnly", |
| | | proLockedStatusKey: "settings.proStatus.supported", |
| | | proUnlockedStatusKey: "settings.proStatus.supported", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.customTagColors"), |
| | | freeStatusKey: "settings.proStatus.unavailableFeature", |
| | | proLockedStatusKey: "settings.proStatus.lockedFeature", |
| | | proUnlockedStatusKey: "settings.proStatus.unlockedFeature", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: "\(tr("settings.coloredContainer")) / \(tr("settings.coloredGridContainer"))", |
| | | freeStatusKey: "settings.proStatus.previewFiveMinutes", |
| | | proLockedStatusKey: "settings.proStatus.lockedFeature", |
| | | proUnlockedStatusKey: "settings.proStatus.unlockedFeature", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.themes"), |
| | | freeStatusKey: "settings.proStatus.previewFiveMinutes", |
| | | proLockedStatusKey: "settings.proStatus.lockedFeature", |
| | | proUnlockedStatusKey: "settings.proStatus.unlockedFeature", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("pro.feature.customHotkeys"), |
| | | freeStatusKey: "settings.proStatus.unavailableFeature", |
| | | proLockedStatusKey: "settings.proStatus.lockedFeature", |
| | | proUnlockedStatusKey: "settings.proStatus.unlockedFeature", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("settings.proFeature.appNotes"), |
| | | freeStatusKey: "settings.proStatus.freeNotes", |
| | | proLockedStatusKey: "settings.proStatus.unlimitedNotes", |
| | | proUnlockedStatusKey: "settings.proStatus.unlimitedNotes", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ), |
| | | ProTabComparisonFeature( |
| | | title: tr("settings.proFeature.dataBackup"), |
| | | freeStatusKey: "settings.proStatus.unavailableFeature", |
| | | proLockedStatusKey: "settings.proStatus.lockedFeature", |
| | | proUnlockedStatusKey: "settings.proStatus.unlockedFeature", |
| | | freeStyle: .neutral, |
| | | proLockedStyle: .neutral, |
| | | proUnlockedStyle: .neutral |
| | | ) |
| | | ] |
| | | } |
| | | |
| | | private func proTabComparisonTable(width: CGFloat) -> some View { |
| | | VStack(alignment: .leading, spacing: 8) { |
| | | VStack(spacing: 8) { |
| | | HStack(spacing: 10) { |
| | | Text("") |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | Text(tr("settings.proCompare.free")) |
| | | .font(.system(size: 12, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .frame(width: 118, alignment: .leading) |
| | | HStack(spacing: 4) { |
| | | Image(systemName: "crown") |
| | | .font(.system(size: 12, weight: .semibold)) |
| | | Text(tr("settings.proCompare.pro")) |
| | | .font(.system(size: 12, weight: .semibold)) |
| | | } |
| | | .foregroundStyle(proAccentColor) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .frame(width: 118, alignment: .leading) |
| | | } |
| | | |
| | | ForEach(proTabComparisonFeatures) { feature in |
| | | let proStatusKey = proEntitlement.isUnlocked ? feature.proUnlockedStatusKey : feature.proLockedStatusKey |
| | | let proStyle = proEntitlement.isUnlocked ? feature.proUnlockedStyle : feature.proLockedStyle |
| | | |
| | | HStack(alignment: .top, spacing: 10) { |
| | | Text(feature.title) |
| | | .font(.system(size: 12, weight: .medium)) |
| | | .foregroundStyle(proAccentColor) |
| | | .lineLimit(2) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | |
| | | ProStatusPill( |
| | | text: tr(feature.freeStatusKey), |
| | | style: feature.freeStyle, |
| | | compact: true |
| | | ) |
| | | .frame(width: 118, alignment: .leading) |
| | | |
| | | ProStatusPill( |
| | | text: tr(proStatusKey), |
| | | style: proStyle, |
| | | compact: true |
| | | ) |
| | | .frame(width: 118, alignment: .leading) |
| | | } |
| | | .padding(.horizontal, 10) |
| | | .padding(.vertical, 8) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .windowBackgroundColor).opacity(0.72)) |
| | | ) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 16) |
| | | .padding(.vertical, 14) |
| | | .frame(width: width, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | |
| | | private var themeStatusSummary: String { |
| | | if proEntitlement.isPreviewingProAppearance { |
| | | if let countdown = themePreviewCountdownText { |
| | | return tr("pro.theme.status.previewCountdown") |
| | | .replacingOccurrences(of: "%time%", with: countdown) |
| | | } |
| | | return tr("pro.theme.status.preview") |
| | | } |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return tr("pro.theme.status.free") |
| | | case .purchased, .legacy: |
| | | return tr("pro.theme.status.unlocked") |
| | | } |
| | | } |
| | | |
| | | private func unlockButtonTitle() -> String { |
| | | tr("pro.card.unlock") |
| | | } |
| | | |
| | | private func handleThemeSelection(_ theme: AppGridTheme) { |
| | | if theme.isFreeTheme || proEntitlement.isUnlocked { |
| | | proEntitlement.stopThemePreview() |
| | | appGridThemeID = theme.rawValue |
| | | return |
| | | } |
| | | proEntitlement.startThemePreview(for: theme, tuning: currentThemeTuning(for: theme)) |
| | | } |
| | | |
| | | private func handleDisplayModeSelection(_ mode: String) { |
| | | if ProEntitlementPolicy.canUseDisplayMode(mode) { |
| | | proEntitlement.stopDisplayModePreview() |
| | | displayMode = mode |
| | | return |
| | | } |
| | | proEntitlement.startDisplayModePreview(for: mode) |
| | | } |
| | | |
| | | private var activeColorTuningTheme: AppGridTheme? { |
| | | let theme = effectiveAppGridTheme |
| | | return theme.supportsColorTuning ? theme : nil |
| | | } |
| | | |
| | | private func currentThemeTuning(for theme: AppGridTheme) -> Double { |
| | | if let preview = proEntitlement.themePreviewState, |
| | | preview.theme == theme { |
| | | return theme.normalizedTuning(preview.themeTuning) |
| | | } |
| | | switch theme { |
| | | case .deepBlue: |
| | | return theme.normalizedTuning(deepBlueThemeTuning) |
| | | case .colorful: |
| | | return theme.normalizedTuning(colorfulThemeTuning) |
| | | case .defaultLight, .black, .pink, .purple, .green, .blue: |
| | | return theme.defaultColorTuning |
| | | } |
| | | } |
| | | |
| | | private func persistThemeTuning(_ tuning: Double?, for theme: AppGridTheme) { |
| | | let normalized = theme.normalizedTuning(tuning) |
| | | switch theme { |
| | | case .deepBlue: |
| | | deepBlueThemeTuning = normalized |
| | | case .colorful: |
| | | colorfulThemeTuning = normalized |
| | | case .defaultLight, .black, .pink, .purple, .green, .blue: |
| | | break |
| | | } |
| | | } |
| | | |
| | | private func updateThemeTuning(_ value: Double, for theme: AppGridTheme) { |
| | | let normalized = theme.normalizedTuning(value) |
| | | if proEntitlement.isUnlocked || theme.isFreeTheme { |
| | | persistThemeTuning(normalized, for: theme) |
| | | return |
| | | } |
| | | if proEntitlement.themePreviewState?.theme == theme { |
| | | proEntitlement.updateThemePreviewTuning(normalized, for: theme) |
| | | } else { |
| | | proEntitlement.startThemePreview(for: theme, tuning: normalized) |
| | | } |
| | | } |
| | | |
| | | private func themeTuningBinding(for theme: AppGridTheme) -> Binding<Double> { |
| | | Binding( |
| | | get: { currentThemeTuning(for: theme) }, |
| | | set: { updateThemeTuning($0, for: theme) } |
| | | ) |
| | | } |
| | | |
| | | private func themeOptionAccessory(for theme: AppGridTheme) -> ThemeOptionAccessory { |
| | | if let preview = proEntitlement.themePreviewState?.theme, preview == theme { |
| | | return .countdown(text: themePreviewCountdownText ?? "00:00") |
| | | } |
| | | if theme.requiresPro { |
| | | return .pill(text: tr("pro.card.badge"), style: .locked) |
| | | } |
| | | return effectiveAppGridTheme == theme ? .checkmark : .circle |
| | | } |
| | | |
| | | private func displayModeOptionAccessory(for mode: String) -> ThemeOptionAccessory { |
| | | if proEntitlement.displayModePreviewState?.displayMode == mode { |
| | | return .countdown(text: themePreviewCountdownText ?? "00:00") |
| | | } |
| | | if ProEntitlementPolicy.isPremiumDisplayMode(mode) { |
| | | return .pill(text: tr("pro.card.badge"), style: .locked) |
| | | } |
| | | return effectiveDisplayMode == mode ? .checkmark : .circle |
| | | } |
| | | |
| | | private func presentSettingsProPrompt( |
| | | for feature: ProFeature, |
| | | customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil |
| | | ) { |
| | | proEntitlement.clearTransientOperationState() |
| | | settingsCustomContainerQuotaStatus = customContainerQuotaStatus |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) { |
| | | settingsProPromptFeature = feature |
| | | } |
| | | } |
| | | |
| | | private func presentSettingsCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) { |
| | | presentSettingsProPrompt( |
| | | for: .customContainerQuota, |
| | | customContainerQuotaStatus: status |
| | | ) |
| | | } |
| | | |
| | | private var themePreviewCountdownText: String? { |
| | | if let preview = proEntitlement.themePreviewState { |
| | | return Self.formatThemePreviewCountdown(seconds: preview.remainingSeconds(at: themePreviewNow)) |
| | | } |
| | | if let preview = proEntitlement.displayModePreviewState { |
| | | return Self.formatThemePreviewCountdown(seconds: preview.remainingSeconds(at: themePreviewNow)) |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | 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 |
| | | if let preview = proEntitlement.themePreviewState, |
| | | preview.remainingSeconds(at: now) <= 0 { |
| | | proEntitlement.stopThemePreview() |
| | | } |
| | | if let preview = proEntitlement.displayModePreviewState, |
| | | preview.remainingSeconds(at: now) <= 0 { |
| | | proEntitlement.stopDisplayModePreview() |
| | | } |
| | | } |
| | | |
| | | private func dismissSettingsProPrompt() { |
| | | proEntitlement.clearTransientOperationState() |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | settingsProPromptFeature = nil |
| | | } |
| | | settingsCustomContainerQuotaStatus = nil |
| | | } |
| | | |
| | | private func settingsProPromptMessage(for feature: ProFeature) -> String { |
| | | if feature == .customContainerQuota, |
| | | let status = settingsCustomContainerQuotaStatus, |
| | | !status.isUnlimited { |
| | | return tr( |
| | | "pro.customContainers.quotaPrompt", |
| | | replacements: [ |
| | | "%used%": "\(status.used)", |
| | | "%limit%": "\(status.limit)", |
| | | "%remaining%": "\(status.remaining)" |
| | | ] |
| | | ) |
| | | } |
| | | return tr(feature.promptMessageKey) |
| | | } |
| | | |
| | | private func settingsProPromptSecondaryTitle(for feature: ProFeature) -> String { |
| | | feature == .customContainerQuota |
| | | ? tr("pro.customContainers.manageExisting") |
| | | : tr("pro.card.restore") |
| | | } |
| | | |
| | | private func handleSettingsProPromptSecondaryAction(for feature: ProFeature) { |
| | | if feature == .customContainerQuota { |
| | | dismissSettingsProPrompt() |
| | | selectedTab = .tags |
| | | return |
| | | } |
| | | proEntitlement.restorePurchases() |
| | | } |
| | | |
| | | private func handleProAccessStateChange(_ newState: ProAccessState) { |
| | | guard newState.isUnlocked else { |
| | | stopHotkeyRecording(showCancelledToast: false) |
| | | return |
| | | } |
| | | if let preview = proEntitlement.themePreviewState { |
| | | appGridThemeID = preview.theme.rawValue |
| | | persistThemeTuning(preview.themeTuning, for: preview.theme) |
| | | proEntitlement.stopThemePreview() |
| | | } |
| | | if let preview = proEntitlement.displayModePreviewState { |
| | | displayMode = preview.displayMode |
| | | proEntitlement.stopDisplayModePreview() |
| | | } |
| | | if settingsProPromptFeature != nil { |
| | | dismissSettingsProPrompt() |
| | | } |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private var themeColorTuningSection: some View { |
| | | if let theme = activeColorTuningTheme { |
| | | VStack(alignment: .leading, spacing: 8) { |
| | | HStack(alignment: .firstTextBaseline, spacing: 10) { |
| | | Label( |
| | | tr(theme == .deepBlue ? "theme.tuning.dark" : "theme.tuning.light"), |
| | | systemImage: "slider.horizontal.3" |
| | | ) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | |
| | | Spacer(minLength: 12) |
| | | |
| | | Text("\(Int(round(currentThemeTuning(for: theme) * 100)))") |
| | | .font(.system(size: 12, weight: .semibold, design: .monospaced)) |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | |
| | | Slider(value: themeTuningBinding(for: theme), in: 0...1) |
| | | .controlSize(.regular) |
| | | .accessibilityLabel(tr(theme == .deepBlue ? "theme.tuning.dark" : "theme.tuning.light")) |
| | | |
| | | Text(tr("theme.tuning.desc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | .padding(14) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor).opacity(0.82)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.18), lineWidth: 1) |
| | | ) |
| | | .transition(.opacity.combined(with: .move(edge: .top))) |
| | | } |
| | | } |
| | | |
| | | private func generalSettingRow<Control: View>( |
| | |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | CustomizableHotkeyInfoRow( |
| | | title: tr("quickSearch.mainHotkey"), |
| | | description: tr("quickSearch.mainHotkeyDesc"), |
| | | displayText: LauncherHotkey.main.displayString, |
| | | displayText: LauncherHotkeySettings.effectiveHotkey(for: .main).displayString, |
| | | statusText: hotkeyStatusText(for: .main), |
| | | statusTone: hotkeyStatusTone(for: .main) |
| | | statusTone: hotkeyStatusTone(for: .main), |
| | | customizeTitle: tr("hotkeys.customize"), |
| | | proBadgeTitle: tr("pro.card.badge"), |
| | | restoreTitle: tr("hotkeys.restoreDefault"), |
| | | isRecording: recordingHotkeyKind == .main, |
| | | isProLocked: !proEntitlement.isUnlocked, |
| | | canRestore: proEntitlement.isUnlocked && LauncherHotkeySettings.hasCustomHotkey(for: .main), |
| | | onCustomize: { startHotkeyRecording(for: .main) }, |
| | | onRestore: { restoreDefaultHotkey(for: .main) } |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | |
| | | statusTone: .neutral |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | CustomizableHotkeyInfoRow( |
| | | title: tr("quickSearch.globalHotkey"), |
| | | description: tr("quickSearch.globalHotkeyDesc"), |
| | | displayText: LauncherHotkey.quickSearch.displayString, |
| | | displayText: LauncherHotkeySettings.effectiveHotkey(for: .quickSearch).displayString, |
| | | statusText: hotkeyStatusText(for: .quickSearch), |
| | | statusTone: hotkeyStatusTone(for: .quickSearch) |
| | | statusTone: hotkeyStatusTone(for: .quickSearch), |
| | | customizeTitle: tr("hotkeys.customize"), |
| | | proBadgeTitle: tr("pro.card.badge"), |
| | | restoreTitle: tr("hotkeys.restoreDefault"), |
| | | isRecording: recordingHotkeyKind == .quickSearch, |
| | | isProLocked: !proEntitlement.isUnlocked, |
| | | canRestore: proEntitlement.isUnlocked && LauncherHotkeySettings.hasCustomHotkey(for: .quickSearch), |
| | | onCustomize: { startHotkeyRecording(for: .quickSearch) }, |
| | | onRestore: { restoreDefaultHotkey(for: .quickSearch) } |
| | | ) |
| | | |
| | | StaticHotkeyInfoRow( |
| | | title: tr("trackpadGesture.title"), |
| | | description: tr("trackpadGesture.description"), |
| | | displayText: "taglauncher://show", |
| | | statusText: tr("trackpadGesture.status"), |
| | | statusTone: .neutral |
| | | ) |
| | | } |
| | | .id(hotkeySettingsRefreshToken) |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private var themeProStatusRow: some View { |
| | | if proEntitlement.isUnlocked { |
| | | proUnlockedBenefitLine("pro.theme.status.unlocked", bottomPadding: 0) |
| | | } else { |
| | | HStack(spacing: 12) { |
| | | proHeaderStatusLabel() |
| | | |
| | | Text(themeStatusSummary) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | |
| | | Spacer(minLength: 0) |
| | | |
| | | Button(unlockButtonTitle()) { |
| | | presentSettingsProPrompt(for: .premiumThemes) |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | |
| | | Button(tr("pro.card.restore")) { |
| | | proEntitlement.restorePurchases() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | } |
| | | .padding(.horizontal, 16) |
| | | .padding(.vertical, 14) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private var settingsTabBar: some View { |
| | | HStack(spacing: 18) { |
| | | HStack(spacing: 6) { |
| | | ForEach(SettingsTab.allCases) { tab in |
| | | settingsTabButton(tab) |
| | | } |
| | | } |
| | | .frame(height: 104, alignment: .center) |
| | | .frame(height: 96, alignment: .center) |
| | | .frame(maxWidth: .infinity) |
| | | .background(Color(nsColor: .windowBackgroundColor)) |
| | | } |
| | |
| | | } label: { |
| | | VStack(spacing: 5) { |
| | | Image(systemName: tab.systemImage) |
| | | .font(.system(size: 25, weight: .regular)) |
| | | .font(.system(size: 24, weight: .regular)) |
| | | .symbolRenderingMode(.hierarchical) |
| | | .frame(width: 32, height: 32) |
| | | .frame(width: 30, height: 30) |
| | | Text(tr(tab.titleKey)) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .font(.system(size: 12, weight: .semibold)) |
| | | .lineLimit(2) |
| | | .multilineTextAlignment(.center) |
| | | .minimumScaleFactor(0.8) |
| | | } |
| | | .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) |
| | | .frame(width: 72, height: 70) |
| | | .frame(width: 94, height: 72) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(isSelected ? Color.accentColor.opacity(0.10) : Color.clear) |
| | |
| | | VStack(spacing: 0) { |
| | | settingsTabBar |
| | | Divider() |
| | | if shouldShowSettingsFixedProHeader { |
| | | settingsFixedProHeader |
| | | } |
| | | Group { |
| | | switch selectedTab { |
| | | case .language: |
| | | // Tab 1: Language |
| | | VStack(alignment: .leading, spacing: 10) { |
| | | Text(tr("settings.language")) |
| | | .font(.headline) |
| | | Text(tr("settings.languageDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | |
| | | ScrollView { |
| | | centeredSettingsScrollContent(width: languageContentWidth) { |
| | | LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) { |
| | | Button { |
| | | selectedLanguage = L10n.automaticCode |
| | |
| | | } |
| | | } |
| | | .padding(.vertical, 4) |
| | | } |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| | | } |
| | | .frame(maxWidth: settingsContentWidth, alignment: .leading) |
| | | .padding() |
| | | |
| | | case .general: |
| | | // Tab 2: General |
| | |
| | | AppDelegate.refreshChromeSettings() |
| | | } |
| | | Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames) |
| | | Toggle(tr("settings.hideUsageTips"), isOn: $hideUsageTips) |
| | | .help(tr("settings.hideUsageTipsDesc")) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | .padding(.bottom, 16) |
| | | .padding(.top, 12) |
| | | .padding(.bottom, 18) |
| | | |
| | | Divider() |
| | | .padding(.bottom, 16) |
| | |
| | | DisplayModeOptionButton( |
| | | mode: "flat", |
| | | title: tr("settings.flat"), |
| | | isSelected: displayMode == "flat" |
| | | isSelected: effectiveDisplayMode == "flat", |
| | | accessory: displayModeOptionAccessory(for: "flat") |
| | | ) { |
| | | displayMode = "flat" |
| | | handleDisplayModeSelection("flat") |
| | | } |
| | | |
| | | LazyVGrid(columns: displayModeColumns, alignment: .leading, spacing: 8) { |
| | |
| | | DisplayModeOptionButton( |
| | | mode: option.id, |
| | | title: option.title, |
| | | isSelected: displayMode == option.id |
| | | isSelected: effectiveDisplayMode == option.id, |
| | | accessory: displayModeOptionAccessory(for: option.id) |
| | | ) { |
| | | displayMode = option.id |
| | | handleDisplayModeSelection(option.id) |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .center) |
| | | .padding() |
| | | .padding(.horizontal) |
| | | .padding(.top, 28) |
| | | .padding(.bottom) |
| | | } |
| | | |
| | | case .theme: |
| | | ScrollView(.vertical, showsIndicators: true) { |
| | | VStack(alignment: .leading, spacing: 18) { |
| | | LazyVGrid(columns: themeColumns, alignment: .leading, spacing: 12) { |
| | | ForEach(AppGridTheme.settingsDisplayOrder) { theme in |
| | | ThemeOptionButton( |
| | | theme: theme, |
| | | title: tr(theme.titleKey), |
| | | isSelected: effectiveAppGridTheme == theme, |
| | | previewTuning: currentThemeTuning(for: theme), |
| | | accessory: themeOptionAccessory(for: theme) |
| | | ) { |
| | | handleThemeSelection(theme) |
| | | } |
| | | } |
| | | } |
| | | |
| | | themeColorTuningSection |
| | | } |
| | | .frame(maxWidth: generalContentWidth, alignment: .leading) |
| | | .padding(.horizontal) |
| | | .padding(.top, 28) |
| | | .padding(.bottom) |
| | | } |
| | | |
| | | case .hotkeys: |
| | | // Tab 3: Hotkeys |
| | |
| | | VStack(spacing: 0) { |
| | | TagEditorView( |
| | | tagColors: $tagColors, |
| | | tagCustomColors: $tagCustomColors, |
| | | excludedTagNames: ["Mac自带", defaultGroupName], |
| | | onRefresh: { scanApps() } |
| | | isCustomColorUnlocked: proEntitlement.isUnlocked, |
| | | onLockedCustomColor: { presentSettingsProPrompt(for: .customTagColors) }, |
| | | onCustomContainerQuotaExceeded: presentSettingsCustomContainerQuotaPrompt, |
| | | onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus, |
| | | topLeadingAccessory: AnyView(customContainerQuotaAccessory), |
| | | onRefresh: { |
| | | refreshSettingsCustomContainerQuotaStatus() |
| | | scanApps() |
| | | } |
| | | ) |
| | | } |
| | | .onAppear { scanApps() } |
| | | .onAppear { |
| | | refreshSettingsCustomContainerQuotaStatus() |
| | | scanApps() |
| | | } |
| | | |
| | | case .data: |
| | | // Tab 5: Data |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 32) |
| | | ScrollView(.vertical, showsIndicators: true) { |
| | | VStack(spacing: 18) { |
| | | VStack(alignment: .center, spacing: 24) { |
| | | dataSection(minHeight: 124) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | dataSectionTitle(tr("settings.initialLayout")) |
| | | |
| | | VStack(alignment: .center, spacing: 0) { |
| | | VStack(alignment: .leading, spacing: 12) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(tr("settings.systemScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | HStack(spacing: 22) { |
| | | initialLayoutOption( |
| | | tr("settings.initialLayoutUncategorized"), |
| | | mode: .uncategorized |
| | | ) |
| | | initialLayoutOption( |
| | | tr("settings.initialLayoutSmart"), |
| | | mode: .smart |
| | | ) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(1) |
| | | |
| | | Text(SmartStartService.systemInitialSchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(0) |
| | | |
| | | dataActionButton( |
| | | tr("settings.applyScheme"), |
| | | prominent: true, |
| | | enabled: !isApplyingSystemScheme, |
| | | action: applySystemInitialScheme |
| | | ) |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(tr("settings.previousScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | Text(previousCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(canRestorePreviousScheme ? .primary : .tertiary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(0) |
| | | |
| | | dataActionButton( |
| | | tr("settings.restorePreviousScheme"), |
| | | prominent: false, |
| | | enabled: canRestorePreviousScheme, |
| | | action: restorePreviousCategoryScheme |
| | | ) |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | |
| | | HStack(alignment: .firstTextBaseline, spacing: 12) { |
| | | Text(tr("settings.currentScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | .multilineTextAlignment(.trailing) |
| | | .frame(width: dataLabelWidth, alignment: .trailing) |
| | | Text(currentCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | Color.clear |
| | | .frame(width: dataActionWidth, height: 1) |
| | | } |
| | | |
| | | Divider().opacity(0.35) |
| | | |
| | | VStack(alignment: .center, spacing: 8) { |
| | | HStack(spacing: 12) { |
| | | Button(tr("settings.export")) { exportTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button(tr("settings.import")) { importTags() } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | dataActionButton( |
| | | tr("settings.applyScheme"), |
| | | prominent: true, |
| | | enabled: !isApplyingSystemScheme && !isResettingToUncategorized, |
| | | action: applySelectedInitialLayout |
| | | ) |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | Text(tr("settings.backupDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(maxWidth: dataPanelWidth - 72, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | } |
| | | .padding(.horizontal, 20) |
| | | .padding(.vertical, 16) |
| | | .frame(width: dataPanelWidth, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | .frame(width: dataPanelWidth, alignment: .center) |
| | | |
| | | Spacer(minLength: 22) |
| | | dataSection(minHeight: 190) { |
| | | HStack(alignment: .top, spacing: 12) { |
| | | dataSectionTitle(tr("settings.customLayout")) |
| | | .padding(.top, 4) |
| | | |
| | | HStack(alignment: .center, spacing: 14) { |
| | | Text(tr("settings.bubbleDisplayScope")) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .frame(width: 190, alignment: .trailing) |
| | | VStack(alignment: .leading, spacing: 10) { |
| | | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| | | Text(tr("settings.previousScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | Text(previousCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(canRestorePreviousScheme ? .primary : .tertiary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | } |
| | | |
| | | HStack(spacing: 20) { |
| | | bubbleScopeOption( |
| | | tr("settings.bubbleAllApps"), |
| | | isSelected: !showUncommonAppBubbles |
| | | ) { |
| | | showUncommonAppBubbles = false |
| | | } |
| | | bubbleScopeOption( |
| | | tr("settings.bubbleUncommonOnly"), |
| | | isSelected: showUncommonAppBubbles |
| | | ) { |
| | | showUncommonAppBubbles = true |
| | | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| | | Text(tr("settings.currentScheme")) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | Text(currentCategorySchemeName) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | } |
| | | |
| | | Divider().opacity(0.35) |
| | | .padding(.top, 2) |
| | | |
| | | VStack(alignment: .center, spacing: 8) { |
| | | HStack(spacing: 12) { |
| | | Button { |
| | | exportTags() |
| | | } label: { |
| | | HStack(spacing: 8) { |
| | | Text(tr("settings.export")) |
| | | ProStatusPill(text: tr("pro.card.badge"), style: .locked, compact: true) |
| | | } |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button { |
| | | importTags() |
| | | } label: { |
| | | HStack(spacing: 8) { |
| | | Text(tr("settings.import")) |
| | | ProStatusPill(text: tr("pro.card.badge"), style: .locked, compact: true) |
| | | } |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | } |
| | | Text(tr("settings.backupDesc")) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | .frame(maxWidth: dataPanelWidth - dataLabelWidth - dataActionWidth - 92, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .center) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(1) |
| | | |
| | | dataActionButton( |
| | | tr("settings.restorePreviousScheme"), |
| | | prominent: false, |
| | | enabled: canRestorePreviousScheme, |
| | | action: restorePreviousCategoryScheme |
| | | ) |
| | | .frame(width: dataActionWidth, alignment: .trailing) |
| | | .layoutPriority(2) |
| | | } |
| | | } |
| | | } |
| | | .frame(width: 300, alignment: .leading) |
| | | } |
| | | .frame(width: 520, alignment: .center) |
| | | .frame(width: dataPanelWidth, alignment: .center) |
| | | |
| | | Spacer(minLength: 18) |
| | | HStack(alignment: .center, spacing: 14) { |
| | | Text(tr("settings.bubbleDisplayScope")) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .frame(width: 190, alignment: .trailing) |
| | | |
| | | HStack(spacing: 20) { |
| | | bubbleScopeOption( |
| | | tr("settings.bubbleAllApps"), |
| | | isSelected: !showUncommonAppBubbles |
| | | ) { |
| | | showUncommonAppBubbles = false |
| | | } |
| | | bubbleScopeOption( |
| | | tr("settings.bubbleUncommonOnly"), |
| | | isSelected: showUncommonAppBubbles |
| | | ) { |
| | | showUncommonAppBubbles = true |
| | | } |
| | | } |
| | | .frame(width: 300, alignment: .leading) |
| | | } |
| | | .frame(width: 520, alignment: .center) |
| | | } |
| | | .frame(maxWidth: dataPanelWidth, alignment: .center) |
| | | .padding() |
| | | } |
| | | .padding() |
| | | .onAppear { refreshDataState() } |
| | | |
| | | case .about: |
| | | // Tab 6: About |
| | | VStack(spacing: 0) { |
| | | Spacer(minLength: 72) |
| | | case .pro: |
| | | // Tab 6: Pro |
| | | centeredSettingsScrollContent(width: dataPanelWidth) { |
| | | proTabComparisonTable(width: dataPanelWidth) |
| | | } |
| | | |
| | | HStack { |
| | | case .about: |
| | | // Tab 7: About |
| | | centeredSettingsScrollContent(width: dataPanelWidth) { |
| | | HStack(alignment: .center, spacing: 24) { |
| | | if let icon = NSImage(named: NSImage.applicationIconName) { |
| | | Image(nsImage: icon) |
| | | .resizable() |
| | | .frame(width: 128, height: 128) |
| | | .frame(width: 112, height: 112) |
| | | } |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text("TagLauncher") |
| | |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | .frame(maxWidth: 520, alignment: .leading) |
| | | } |
| | | .frame(width: 560, alignment: .leading) |
| | | |
| | | Spacer(minLength: 30) |
| | | Spacer(minLength: 18) |
| | | } |
| | | .padding() |
| | | } |
| | | } |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| | |
| | | } else { |
| | | syncSelectedLanguage() |
| | | } |
| | | refreshPreferencesWindowTitle() |
| | | scanApps() |
| | | refreshDataState() |
| | | showLanguageRefresh() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in |
| | | refreshDataState() |
| | | } |
| | | .onChange(of: proEntitlement.accessState) { _, newState in |
| | | handleProAccessStateChange(newState) |
| | | } |
| | | .onChange(of: selectedTab) { _, newTab in |
| | | if newTab != .hotkeys { |
| | | stopHotkeyRecording(showCancelledToast: false) |
| | | } |
| | | } |
| | | |
| | | if isRefreshingLanguage { |
| | |
| | | } |
| | | |
| | | if showApplySystemSchemeConfirmation { |
| | | ApplySystemSchemeConfirmationView( |
| | | SettingsConfirmationView( |
| | | title: tr("settings.applySystemSchemeWarningTitle"), |
| | | message: tr("settings.applySystemSchemeWarningMessage"), |
| | | confirmTitle: tr("settings.confirmApplyScheme"), |
| | |
| | | } |
| | | ) |
| | | .zIndex(3) |
| | | .transition(.opacity) |
| | | } |
| | | |
| | | if showResetToUncategorizedConfirmation { |
| | | SettingsConfirmationView( |
| | | title: tr("settings.resetToUncategorizedWarningTitle"), |
| | | message: tr("settings.resetToUncategorizedWarningMessage"), |
| | | confirmTitle: tr("settings.confirmResetToUncategorized"), |
| | | cancelTitle: tr("settings.cancel"), |
| | | onConfirm: performResetToUncategorized, |
| | | onCancel: { |
| | | withAnimation(.easeOut(duration: 0.14)) { |
| | | showResetToUncategorizedConfirmation = false |
| | | } |
| | | } |
| | | ) |
| | | .zIndex(3) |
| | | .transition(.opacity) |
| | | } |
| | | |
| | | if let feature = settingsProPromptFeature { |
| | | ZStack { |
| | | Color.black.opacity(0.16) |
| | | .ignoresSafeArea() |
| | | |
| | | ProUpgradePromptView( |
| | | title: tr(feature.titleKey), |
| | | message: settingsProPromptMessage(for: feature), |
| | | benefitText: tr(feature.benefitKey), |
| | | operationText: proEntitlement.operationState.statusMessageKey.map(tr), |
| | | unlockTitle: unlockButtonTitle(), |
| | | restoreTitle: settingsProPromptSecondaryTitle(for: feature), |
| | | isBusy: proEntitlement.operationState.isBusy, |
| | | onClose: dismissSettingsProPrompt, |
| | | onUnlock: { proEntitlement.purchasePro() }, |
| | | onRestore: { handleSettingsProPromptSecondaryAction(for: feature) } |
| | | ) |
| | | } |
| | | .zIndex(4) |
| | | .transition(.opacity) |
| | | } |
| | | |
| | |
| | | .frame(maxWidth: 620) |
| | | .padding(.bottom, 22) |
| | | } |
| | | .zIndex(4) |
| | | .zIndex(5) |
| | | .transition(.opacity.combined(with: .move(edge: .bottom))) |
| | | } |
| | | } |
| | | .frame(width: settingsWindowWidth, height: 480) |
| | | .frame(width: settingsWindowWidth, height: settingsWindowHeight) |
| | | .onAppear { |
| | | syncSelectedLanguage() |
| | | refreshPreferencesWindowTitle() |
| | | installSettingsEscapeMonitor() |
| | | showPendingHotkeyWarningIfNeeded() |
| | | refreshThemePreviewCountdown() |
| | | } |
| | | .onDisappear { |
| | | removeSettingsEscapeMonitor() |
| | | stopHotkeyRecording() |
| | | } |
| | | .onReceive(themePreviewCountdownTimer) { now in |
| | | refreshThemePreviewCountdown(now: now) |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in |
| | | hotkeySettingsRefreshToken = UUID() |
| | | showPendingHotkeyWarningIfNeeded() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherPreferencesTabRequested)) { notification in |
| | | selectTab(rawValue: notification.userInfo?[SettingsTabTarget.userInfoKey] as? String) |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private struct ProTabComparisonFeature: Identifiable { |
| | | var id: String { title } |
| | | let title: String |
| | | let freeStatusKey: String |
| | | let proLockedStatusKey: String |
| | | let proUnlockedStatusKey: String |
| | | let freeStyle: ProStatusPillStyle |
| | | let proLockedStyle: ProStatusPillStyle |
| | | let proUnlockedStyle: ProStatusPillStyle |
| | | } |
| | | |
| | | private enum HotkeyStatusTone { |
| | |
| | | let statusTone: HotkeyStatusTone |
| | | |
| | | var body: some View { |
| | | VStack(alignment: .leading, spacing: 12) { |
| | | HStack(alignment: .top, spacing: 12) { |
| | | VStack(alignment: .leading, spacing: 3) { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | Text(description) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text(displayText) |
| | | .font(.system(size: 15, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.85) |
| | | Text(statusText) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(statusTone.foreground) |
| | | .padding(.horizontal, 8) |
| | | .padding(.vertical, 3) |
| | | .background(Capsule().fill(statusTone.background)) |
| | | } |
| | | .frame(width: 180, alignment: .leading) |
| | | HStack(alignment: .center, spacing: 18) { |
| | | VStack(alignment: .leading, spacing: 3) { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | Text(description) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | |
| | | HStack(alignment: .center, spacing: 12) { |
| | | Text(displayText) |
| | | .font(.system(size: 15, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.85) |
| | | .frame(width: 156, alignment: .leading) |
| | | |
| | | HotkeyStatusPill(text: statusText, tone: statusTone) |
| | | } |
| | | .frame(width: 310, alignment: .leading) |
| | | } |
| | | .padding(12) |
| | | .background( |
| | |
| | | } |
| | | } |
| | | |
| | | private struct ApplySystemSchemeConfirmationView: View { |
| | | private struct CustomizableHotkeyInfoRow: View { |
| | | let title: String |
| | | let description: String |
| | | let displayText: String |
| | | let statusText: String |
| | | let statusTone: HotkeyStatusTone |
| | | let customizeTitle: String |
| | | let proBadgeTitle: String |
| | | let restoreTitle: String |
| | | let isRecording: Bool |
| | | let isProLocked: Bool |
| | | let canRestore: Bool |
| | | let onCustomize: () -> Void |
| | | let onRestore: () -> Void |
| | | |
| | | var body: some View { |
| | | HStack(alignment: .center, spacing: 18) { |
| | | VStack(alignment: .leading, spacing: 3) { |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | Text(description) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | |
| | | HStack(alignment: .center, spacing: 12) { |
| | | VStack(alignment: .leading, spacing: 6) { |
| | | Text(isRecording ? "…" : displayText) |
| | | .font(.system(size: 15, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.85) |
| | | |
| | | HStack(spacing: 6) { |
| | | HotkeyStatusPill(text: statusText, tone: statusTone) |
| | | |
| | | if isRecording { |
| | | Text(tr("hotkeys.recordingInline")) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(.blue) |
| | | } |
| | | |
| | | if !isProLocked { |
| | | ProStatusPill(text: proBadgeTitle, style: .locked, compact: true) |
| | | } |
| | | } |
| | | } |
| | | .frame(width: 156, alignment: .leading) |
| | | |
| | | HotkeyPrimaryActionButton( |
| | | title: customizeTitle, |
| | | proBadgeTitle: proBadgeTitle, |
| | | isProLocked: isProLocked, |
| | | action: onCustomize |
| | | ) |
| | | |
| | | if canRestore { |
| | | HotkeySecondaryActionButton( |
| | | title: restoreTitle, |
| | | action: onRestore |
| | | ) |
| | | } |
| | | } |
| | | .frame(width: 310, alignment: .leading) |
| | | } |
| | | .padding(12) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(Color.secondary.opacity(0.14), lineWidth: 1) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private struct HotkeyStatusPill: View { |
| | | let text: String |
| | | let tone: HotkeyStatusTone |
| | | |
| | | var body: some View { |
| | | Text(text) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(tone.foreground) |
| | | .lineLimit(1) |
| | | .padding(.horizontal, 8) |
| | | .padding(.vertical, 3) |
| | | .background(Capsule().fill(tone.background)) |
| | | } |
| | | } |
| | | |
| | | private struct HotkeyPrimaryActionButton: View { |
| | | let title: String |
| | | let proBadgeTitle: String |
| | | let isProLocked: Bool |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | | if isProLocked { |
| | | Button(action: action) { |
| | | HStack(spacing: 8) { |
| | | Text(title) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | |
| | | ProStatusPill(text: proBadgeTitle, style: .locked, compact: true) |
| | | } |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) |
| | | } else { |
| | | Button(action: action) { |
| | | Text(title) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(Color.white) |
| | | .lineLimit(1) |
| | | .padding(.horizontal, 12) |
| | | .padding(.vertical, 6) |
| | | .background( |
| | | Capsule(style: .continuous) |
| | | .fill(Color.accentColor) |
| | | ) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .contentShape(Capsule(style: .continuous)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private struct HotkeySecondaryActionButton: View { |
| | | let title: String |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | Text(title) |
| | | .font(.system(size: 11, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .padding(.horizontal, 12) |
| | | .padding(.vertical, 6) |
| | | .background( |
| | | Capsule(style: .continuous) |
| | | .fill(Color.secondary.opacity(0.12)) |
| | | ) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .contentShape(Capsule(style: .continuous)) |
| | | } |
| | | } |
| | | |
| | | private struct SettingsConfirmationView: View { |
| | | let title: String |
| | | let message: String |
| | | let confirmTitle: String |
| | |
| | | } |
| | | } |
| | | |
| | | private struct DisplayModeOptionButton: View { |
| | | let mode: String |
| | | private enum ThemeOptionAccessory { |
| | | case checkmark |
| | | case circle |
| | | case countdown(text: String) |
| | | case pill(text: String, style: ProStatusPillStyle) |
| | | } |
| | | |
| | | private struct ThemeOptionButton: View { |
| | | let theme: AppGridTheme |
| | | let title: String |
| | | let isSelected: Bool |
| | | let previewTuning: Double |
| | | let accessory: ThemeOptionAccessory |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | HStack(spacing: 7) { |
| | | HStack(spacing: 12) { |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill( |
| | | LinearGradient( |
| | | colors: theme.previewColors(tuning: previewTuning), |
| | | startPoint: .topLeading, |
| | | endPoint: .bottomTrailing |
| | | ) |
| | | ) |
| | | .frame(width: 70, height: 42) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(previewStrokeColor, lineWidth: 1) |
| | | ) |
| | | .shadow( |
| | | color: isSelected ? Color.accentColor.opacity(0.22) : Color.clear, |
| | | radius: 10, |
| | | x: 0, |
| | | y: 4 |
| | | ) |
| | | |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .minimumScaleFactor(0.82) |
| | | |
| | | Spacer(minLength: 0) |
| | | |
| | | accessoryView |
| | | } |
| | | .padding(10) |
| | | .frame(maxWidth: .infinity, minHeight: 64) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(isSelected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor)) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(isSelected ? Color.accentColor.opacity(0.32) : Color.secondary.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .accessibilityLabel(title) |
| | | .accessibilityIdentifier("theme-option-\(theme.rawValue)") |
| | | } |
| | | |
| | | private var previewStrokeColor: Color { |
| | | theme.isDefaultLight |
| | | ? Color.secondary.opacity(0.26) |
| | | : Color.white.opacity(0.24) |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private var accessoryView: some View { |
| | | switch accessory { |
| | | case .checkmark: |
| | | Image(systemName: "checkmark.circle.fill") |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(Color.accentColor) |
| | | case .circle: |
| | | 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) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private struct DisplayModeOptionButton: View { |
| | | let mode: String |
| | | let title: String |
| | | let isSelected: Bool |
| | | let accessory: ThemeOptionAccessory |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | HStack(spacing: 8) { |
| | | DisplayModeGlyph(mode: mode, isSelected: isSelected) |
| | | .frame(width: 24, height: 18) |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .multilineTextAlignment(.center) |
| | | .multilineTextAlignment(.leading) |
| | | .lineLimit(2) |
| | | .minimumScaleFactor(0.82) |
| | | .foregroundStyle(isSelected ? Color.white : Color.primary) |
| | | .frame(maxWidth: .infinity) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .layoutPriority(1) |
| | | |
| | | accessoryView |
| | | .frame(width: 64, alignment: .trailing) |
| | | } |
| | | .frame(maxWidth: .infinity, minHeight: 34) |
| | | .frame(maxWidth: .infinity, minHeight: 38) |
| | | .padding(.horizontal, 9) |
| | | .padding(.vertical, 2) |
| | | .background( |
| | |
| | | .buttonStyle(.plain) |
| | | .accessibilityLabel(title) |
| | | } |
| | | |
| | | @ViewBuilder |
| | | private var accessoryView: some View { |
| | | switch accessory { |
| | | case .checkmark: |
| | | Image(systemName: "checkmark.circle.fill") |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(isSelected ? Color.white : Color.accentColor) |
| | | case .circle: |
| | | Image(systemName: "circle") |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(isSelected ? Color.white.opacity(0.74) : Color.secondary.opacity(0.42)) |
| | | .opacity(0.001) |
| | | 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) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private struct DisplayModeGlyph: View { |