Ariver
2026-06-28 755ca63e4d37c1a54db7fc6475152319a4e46ceb
src/Apptag/PreferencesView.swift
@@ -3,6 +3,8 @@
// 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
@@ -67,6 +69,8 @@
    @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
@@ -85,6 +89,7 @@
    @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
@@ -610,6 +615,10 @@
    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 {
@@ -630,12 +639,64 @@
            appGridThemeID = theme.rawValue
            return
        }
        proEntitlement.startThemePreview(for: theme)
        proEntitlement.startThemePreview(for: theme, tuning: currentThemeTuning(for: theme))
    }
    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 .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)
@@ -650,6 +711,24 @@
        }
    }
    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()
        }
    }
    private func dismissSettingsProPrompt() {
        proEntitlement.clearTransientOperationState()
        withAnimation(.easeOut(duration: 0.18)) {
@@ -661,10 +740,53 @@
        guard newState.isUnlocked else { return }
        if let preview = proEntitlement.themePreviewState {
            appGridThemeID = preview.theme.rawValue
            persistThemeTuning(preview.themeTuning, for: preview.theme)
            proEntitlement.stopThemePreview()
        }
        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)))
        }
    }
@@ -762,6 +884,14 @@
                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
            )
        }
    }
@@ -821,11 +951,6 @@
                    .lineSpacing(2)
                    .fixedSize(horizontal: false, vertical: true)
                if let price = proEntitlement.priceDisplayText, !proEntitlement.isUnlocked {
                    Text(price)
                        .font(.system(size: 12, weight: .semibold))
                        .foregroundStyle(Color.accentColor)
                }
            }
            Spacer(minLength: 16)
@@ -1077,22 +1202,15 @@
                    case .theme:
            ScrollView(.vertical, showsIndicators: true) {
                VStack(alignment: .leading, spacing: 18) {
                    VStack(alignment: .leading, spacing: 6) {
                        Text(tr("settings.theme"))
                            .font(.headline)
                        Text(tr("settings.themeDesc"))
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    themeProStatusRow
                    LazyVGrid(columns: themeColumns, alignment: .leading, spacing: 12) {
                        ForEach(AppGridTheme.allCases) { theme in
                        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)
@@ -1100,10 +1218,7 @@
                        }
                    }
                    Text(tr("settings.themeScopeDesc"))
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                    themeColorTuningSection
                }
                .frame(maxWidth: generalContentWidth, alignment: .leading)
                .padding(.horizontal)
@@ -1473,6 +1588,10 @@
        .onAppear {
            syncSelectedLanguage()
            showPendingHotkeyWarningIfNeeded()
            refreshThemePreviewCountdown()
        }
        .onReceive(themePreviewCountdownTimer) { now in
            refreshThemePreviewCountdown(now: now)
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherHotkeyRegistrationChanged)) { _ in
            showPendingHotkeyWarningIfNeeded()
@@ -1662,6 +1781,7 @@
private enum ThemeOptionAccessory {
    case checkmark
    case circle
    case countdown(text: String)
    case pill(text: String, style: ProStatusPillStyle)
}
@@ -1669,6 +1789,7 @@
    let theme: AppGridTheme
    let title: String
    let isSelected: Bool
    let previewTuning: Double
    let accessory: ThemeOptionAccessory
    let action: () -> Void
@@ -1678,7 +1799,7 @@
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(
                        LinearGradient(
                            colors: theme.previewColors,
                            colors: theme.previewColors(tuning: previewTuning),
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
@@ -1739,6 +1860,22 @@
            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)
        }