| | |
| | | @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID |
| | | @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 hotkeyStatusToastToken: UUID? = nil |
| | | @State private var selectedTab: SettingsTab = .general |
| | | @State private var selectedInitialLayoutMode: InitialLayoutMode = .smart |
| | | @State private var settingsProPromptFeature: ProFeature? = nil |
| | | |
| | | init(initialTabRawValue: String? = nil) { |
| | | let initialTab = initialTabRawValue.flatMap(SettingsTab.init(rawValue:)) ?? .general |
| | |
| | | } |
| | | |
| | | 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) |
| | |
| | | AppGridTheme(storedID: appGridThemeID) |
| | | } |
| | | |
| | | private var effectiveAppGridTheme: AppGridTheme { |
| | | ProEntitlementPolicy.effectiveTheme(for: selectedAppGridTheme) |
| | | } |
| | | |
| | | private var proStatusPillStyle: ProStatusPillStyle { |
| | | if proEntitlement.isPreviewingTheme { |
| | | return .preview |
| | | } |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return .locked |
| | | case .purchased: |
| | | return .unlocked |
| | | case .legacy: |
| | | return .legacy |
| | | } |
| | | } |
| | | |
| | | private var proCardTitle: String { |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return tr("pro.card.title.free") |
| | | case .purchased: |
| | | return tr("pro.card.title.unlocked") |
| | | case .legacy: |
| | | return tr("pro.card.title.legacy") |
| | | } |
| | | } |
| | | |
| | | private var proCardSummary: String { |
| | | switch proEntitlement.accessState { |
| | | case .free: |
| | | return tr("pro.card.summary.free") |
| | | case .purchased: |
| | | return tr("pro.card.summary.unlocked") |
| | | case .legacy: |
| | | return tr("pro.card.summary.legacy") |
| | | } |
| | | } |
| | | |
| | | private var themeStatusSummary: String { |
| | | if proEntitlement.isPreviewingTheme { |
| | | 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) |
| | | } |
| | | |
| | | private func themeOptionAccessory(for theme: AppGridTheme) -> ThemeOptionAccessory { |
| | | if let preview = proEntitlement.themePreviewState?.theme, preview == theme { |
| | | return .pill(text: tr("pro.card.previewBadge"), style: .preview) |
| | | } |
| | | if !proEntitlement.isUnlocked && theme.requiresPro { |
| | | return .pill(text: tr("pro.card.badge"), style: .locked) |
| | | } |
| | | return effectiveAppGridTheme == theme ? .checkmark : .circle |
| | | } |
| | | |
| | | private func presentSettingsProPrompt(for feature: ProFeature) { |
| | | proEntitlement.clearTransientOperationState() |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) { |
| | | settingsProPromptFeature = feature |
| | | } |
| | | } |
| | | |
| | | private func dismissSettingsProPrompt() { |
| | | proEntitlement.clearTransientOperationState() |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | settingsProPromptFeature = nil |
| | | } |
| | | } |
| | | |
| | | private func handleProAccessStateChange(_ newState: ProAccessState) { |
| | | guard newState.isUnlocked else { return } |
| | | if let preview = proEntitlement.themePreviewState { |
| | | appGridThemeID = preview.theme.rawValue |
| | | proEntitlement.stopThemePreview() |
| | | } |
| | | if settingsProPromptFeature != nil { |
| | | dismissSettingsProPrompt() |
| | | } |
| | | } |
| | | |
| | | private func generalSettingRow<Control: View>( |
| | | _ label: String, |
| | | description: String, |
| | |
| | | statusTone: hotkeyStatusTone(for: .quickSearch) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private var themeProStatusRow: some View { |
| | | HStack(spacing: 12) { |
| | | ProStatusPill( |
| | | text: tr(proEntitlement.compactStatusTextKey), |
| | | style: proStatusPillStyle |
| | | ) |
| | | |
| | | Text(themeStatusSummary) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | |
| | | Spacer(minLength: 0) |
| | | |
| | | if !proEntitlement.isUnlocked { |
| | | 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 dataProStatusCard: some View { |
| | | HStack(alignment: .top, spacing: 18) { |
| | | VStack(alignment: .leading, spacing: 10) { |
| | | HStack(spacing: 10) { |
| | | Text(proCardTitle) |
| | | .font(.system(size: 20, weight: .bold)) |
| | | ProStatusPill( |
| | | text: tr(proEntitlement.compactStatusTextKey), |
| | | style: proStatusPillStyle |
| | | ) |
| | | } |
| | | |
| | | Text(proCardSummary) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(.secondary) |
| | | .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) |
| | | |
| | | VStack(alignment: .trailing, spacing: 10) { |
| | | if !proEntitlement.isUnlocked { |
| | | Button(unlockButtonTitle()) { |
| | | proEntitlement.purchasePro() |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | } |
| | | |
| | | Button(tr("pro.card.restore")) { |
| | | proEntitlement.restorePurchases() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | |
| | | if let statusKey = proEntitlement.operationState.statusMessageKey { |
| | | Text(tr(statusKey)) |
| | | .font(.caption) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.trailing) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 20) |
| | | .padding(.vertical, 18) |
| | | .frame(width: dataPanelWidth, 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 var settingsTabBar: some View { |
| | |
| | | .foregroundStyle(.secondary) |
| | | } |
| | | |
| | | themeProStatusRow |
| | | |
| | | LazyVGrid(columns: themeColumns, alignment: .leading, spacing: 12) { |
| | | ForEach(AppGridTheme.allCases) { theme in |
| | | ThemeOptionButton( |
| | | theme: theme, |
| | | title: tr(theme.titleKey), |
| | | isSelected: selectedAppGridTheme == theme |
| | | isSelected: effectiveAppGridTheme == theme, |
| | | accessory: themeOptionAccessory(for: theme) |
| | | ) { |
| | | appGridThemeID = theme.rawValue |
| | | handleThemeSelection(theme) |
| | | } |
| | | } |
| | | } |
| | |
| | | Spacer(minLength: 36) |
| | | |
| | | VStack(alignment: .center, spacing: 24) { |
| | | dataProStatusCard |
| | | |
| | | dataSection(minHeight: 124) { |
| | | HStack(alignment: .center, spacing: 12) { |
| | | dataSectionTitle(tr("settings.initialLayout")) |
| | |
| | | |
| | | VStack(alignment: .center, spacing: 8) { |
| | | HStack(spacing: 12) { |
| | | Button(tr("settings.export")) { exportTags() } |
| | | Button { |
| | | exportTags() |
| | | } label: { |
| | | HStack(spacing: 8) { |
| | | Text(tr("settings.export")) |
| | | if !proEntitlement.isUnlocked { |
| | | ProStatusPill(text: tr("pro.card.badge"), style: .locked, compact: true) |
| | | } |
| | | } |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | Button(tr("settings.import")) { importTags() } |
| | | Button { |
| | | importTags() |
| | | } label: { |
| | | HStack(spacing: 8) { |
| | | Text(tr("settings.import")) |
| | | if !proEntitlement.isUnlocked { |
| | | ProStatusPill(text: tr("pro.card.badge"), style: .locked, compact: true) |
| | | } |
| | | } |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .disabled(isDataFilePanelPresented) |
| | | } |
| | |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in |
| | | refreshDataState() |
| | | } |
| | | .onChange(of: proEntitlement.accessState) { _, newState in |
| | | handleProAccessStateChange(newState) |
| | | } |
| | | |
| | | if isRefreshingLanguage { |
| | | ZStack { |
| | |
| | | .transition(.opacity) |
| | | } |
| | | |
| | | if let feature = settingsProPromptFeature { |
| | | ZStack { |
| | | Color.black.opacity(0.16) |
| | | .ignoresSafeArea() |
| | | |
| | | ProUpgradePromptView( |
| | | title: tr(feature.titleKey), |
| | | message: tr(feature.promptMessageKey), |
| | | benefitText: tr(feature.benefitKey), |
| | | operationText: proEntitlement.operationState.statusMessageKey.map(tr), |
| | | unlockTitle: unlockButtonTitle(), |
| | | restoreTitle: tr("pro.card.restore"), |
| | | isBusy: proEntitlement.operationState.isBusy, |
| | | onClose: dismissSettingsProPrompt, |
| | | onUnlock: { proEntitlement.purchasePro() }, |
| | | onRestore: { proEntitlement.restorePurchases() } |
| | | ) |
| | | } |
| | | .zIndex(4) |
| | | .transition(.opacity) |
| | | } |
| | | |
| | | if let hotkeyStatusToast { |
| | | VStack { |
| | | Spacer() |
| | |
| | | .frame(maxWidth: 620) |
| | | .padding(.bottom, 22) |
| | | } |
| | | .zIndex(4) |
| | | .zIndex(5) |
| | | .transition(.opacity.combined(with: .move(edge: .bottom))) |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | private enum ThemeOptionAccessory { |
| | | case checkmark |
| | | case circle |
| | | case pill(text: String, style: ProStatusPillStyle) |
| | | } |
| | | |
| | | private struct ThemeOptionButton: View { |
| | | let theme: AppGridTheme |
| | | let title: String |
| | | let isSelected: Bool |
| | | let accessory: ThemeOptionAccessory |
| | | let action: () -> Void |
| | | |
| | | var body: some View { |
| | |
| | | |
| | | Spacer(minLength: 0) |
| | | |
| | | Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(isSelected ? Color.accentColor : Color.secondary.opacity(0.42)) |
| | | accessoryView |
| | | } |
| | | .padding(10) |
| | | .frame(maxWidth: .infinity, minHeight: 64) |
| | |
| | | ? 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 .pill(let text, let style): |
| | | ProStatusPill(text: text, style: style, compact: true) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private struct DisplayModeOptionButton: View { |