Browse Source

Redesign Settings with appearance, subscription, and about sections.

Replace the Phase 1 placeholder settings UI with a centered card layout, remove OpenAI and search-defaults, and wire subscription/about actions for the mock flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 19 giờ trước cách đây
mục cha
commit
e3f4dfd27e

+ 8 - 0
indeed_ai_job_search/Domain/Enums/JobEnums.swift

@@ -161,6 +161,14 @@ enum AppearanceMode: String, CaseIterable, Identifiable {
         case .dark: return "Dark"
         }
     }
+
+    var systemImage: String {
+        switch self {
+        case .system: return "desktopcomputer"
+        case .light: return "sun.max.fill"
+        case .dark: return "moon.fill"
+        }
+    }
 }
 
 enum SearchScreenState: Equatable {

+ 56 - 10
indeed_ai_job_search/Features/Settings/ViewModels/SettingsViewModel.swift

@@ -3,6 +3,7 @@
 //  indeed_ai_job_search
 //
 
+import AppKit
 import Foundation
 import Observation
 import SwiftUI
@@ -10,23 +11,27 @@ import SwiftUI
 @Observable
 @MainActor
 final class SettingsViewModel {
-    var defaultCountry = "Pakistan"
-    var defaultCity = ""
-    var preferRemote = true
-    var defaultFreshnessDays = 14
-    var quietHoursEnabled = false
-    var openAIConfigured = false
+    var quietHoursEnabled: Bool {
+        didSet { UserDefaults.standard.set(quietHoursEnabled, forKey: Keys.quietHoursEnabled) }
+    }
+
     var notificationPermissionLabel = "Not requested"
+    var restoreStatusMessage: String?
+
+    /// Phase 1 placeholder — StoreKit wiring lands later.
+    var isProActive = true
+    var proPlanName = "Yearly Pro"
 
     var appearanceMode: AppearanceMode {
         didSet {
-            UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode")
+            UserDefaults.standard.set(appearanceMode.rawValue, forKey: Keys.appearanceMode)
         }
     }
 
-    init() {
-        let raw = UserDefaults.standard.string(forKey: "appearanceMode") ?? AppearanceMode.system.rawValue
-        appearanceMode = AppearanceMode(rawValue: raw) ?? .system
+    var appVersionLabel: String {
+        let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
+        let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
+        return "Version \(version) (\(build))"
     }
 
     var preferredColorScheme: ColorScheme? {
@@ -36,4 +41,45 @@ final class SettingsViewModel {
         case .dark: return .dark
         }
     }
+
+    init() {
+        let defaults = UserDefaults.standard
+        let raw = defaults.string(forKey: Keys.appearanceMode) ?? AppearanceMode.system.rawValue
+        appearanceMode = AppearanceMode(rawValue: raw) ?? .system
+        quietHoursEnabled = defaults.bool(forKey: Keys.quietHoursEnabled)
+    }
+
+    func restorePurchases() {
+        restoreStatusMessage = isProActive
+            ? "Your Pro purchase is already active on this Mac."
+            : "No previous Pro purchase found. Purchases will restore via StoreKit in a later phase."
+    }
+
+    func manageSubscription() {
+        if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
+            NSWorkspace.shared.open(url)
+        }
+    }
+
+    func openWebsite() {
+        openURL("https://www.indeed.com")
+    }
+
+    func openSupport() {
+        openURL("https://www.indeed.com/support")
+    }
+
+    func openPrivacyPolicy() {
+        openURL("https://hrtechprivacy.com/brands/indeed")
+    }
+
+    private func openURL(_ string: String) {
+        guard let url = URL(string: string) else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    private enum Keys {
+        static let appearanceMode = "appearanceMode"
+        static let quietHoursEnabled = "settings.quietHoursEnabled"
+    }
 }

+ 308 - 86
indeed_ai_job_search/Features/Settings/Views/SettingsView.swift

@@ -10,107 +10,283 @@ struct SettingsView: View {
 
     var body: some View {
         ScrollView {
-            VStack(alignment: .leading, spacing: AppSpacing.lg) {
-                header
+            HStack(spacing: 0) {
+                Spacer(minLength: 0)
 
-                settingsCard("Search defaults") {
-                    labeledField("Default country") {
-                        TextField("Country", text: $viewModel.defaultCountry)
-                            .textFieldStyle(.roundedBorder)
-                    }
-                    labeledField("Default city") {
-                        TextField("City (optional)", text: $viewModel.defaultCity)
-                            .textFieldStyle(.roundedBorder)
-                    }
-                    Toggle("Prefer remote roles", isOn: $viewModel.preferRemote)
-                    labeledField("Default freshness (days)") {
-                        Stepper("\(viewModel.defaultFreshnessDays) days", value: $viewModel.defaultFreshnessDays, in: 1...60)
-                    }
-                }
+                VStack(alignment: .leading, spacing: AppSpacing.lg) {
+                    header
 
-                settingsCard("Notifications") {
-                    HStack {
-                        Text("macOS permission")
-                        Spacer()
-                        Text(viewModel.notificationPermissionLabel)
-                            .foregroundStyle(AppColors.textSecondary)
-                    }
-                    Toggle("Quiet hours (UI only)", isOn: $viewModel.quietHoursEnabled)
-                    Text("Alert delivery is mocked in Phase 1. System notifications arrive in a later phase.")
-                        .font(AppTypography.meta)
-                        .foregroundStyle(AppColors.textTertiary)
+                    appearanceSection
+                    notificationsSection
+                    subscriptionSection
+                    privacySection
+                    aboutSection
                 }
+                .padding(.vertical, AppSpacing.xl)
+                .frame(maxWidth: 640, alignment: .leading)
 
-                settingsCard("Appearance") {
-                    Picker("Theme", selection: Binding(
-                        get: { viewModel.appearanceMode },
-                        set: { viewModel.appearanceMode = $0 }
-                    )) {
-                        ForEach(AppearanceMode.allCases) { mode in
-                            Text(mode.displayName).tag(mode)
-                        }
-                    }
-                    .pickerStyle(.segmented)
-                }
+                Spacer(minLength: 0)
+            }
+            .padding(.horizontal, AppSpacing.xl)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .background(AppColors.backgroundPrimary)
+        .alert("Restore Purchases", isPresented: Binding(
+            get: { viewModel.restoreStatusMessage != nil },
+            set: { if !$0 { viewModel.restoreStatusMessage = nil } }
+        )) {
+            Button("OK", role: .cancel) { viewModel.restoreStatusMessage = nil }
+        } message: {
+            Text(viewModel.restoreStatusMessage ?? "")
+        }
+    }
 
-                settingsCard("OpenAI") {
-                    HStack {
-                        Text("API key status")
-                        Spacer()
-                        Text(viewModel.openAIConfigured ? "Configured" : "Not configured")
-                            .foregroundStyle(viewModel.openAIConfigured ? AppColors.success : AppColors.textSecondary)
-                            .font(AppTypography.filterHeader)
-                    }
-                    Text("Keys are injected at build time in a later phase. Phase 1 uses mock interpretation only.")
-                        .font(AppTypography.meta)
-                        .foregroundStyle(AppColors.textTertiary)
-                }
+    // MARK: - Header
 
-                settingsCard("Privacy & data") {
-                    Text("Search queries may be processed by OpenAI in later phases. Indeed listings open on indeed.com. Local saved jobs and alerts stay on this Mac.")
-                        .font(AppTypography.body)
-                        .foregroundStyle(AppColors.textSecondary)
+    private var header: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            Text("Settings")
+                .font(AppTypography.heroTitle)
+                .foregroundStyle(AppColors.textPrimary)
+            Text("Theme, subscription, and about Indeed AI Jobs.")
+                .font(AppTypography.heroSubtitle)
+                .foregroundStyle(AppColors.textSecondary)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+    }
 
-                    Button("Clear local data") {}
-                        .disabled(true)
-                        .help("Available after persistence ships in Phase 5")
+    // MARK: - Appearance
 
-                    Button("Export saved jobs") {}
-                        .disabled(true)
-                }
+    private var appearanceSection: some View {
+        settingsCard(
+            title: "Appearance",
+            subtitle: "Choose how Indeed AI Jobs looks on this Mac. Default follows your system theme."
+        ) {
+            appearancePicker
+        }
+    }
 
-                settingsCard("Subscription") {
-                    Text("Coming soon")
-                        .font(AppTypography.body)
-                        .foregroundStyle(AppColors.textSecondary)
+    private var appearancePicker: some View {
+        HStack(spacing: 0) {
+            ForEach(AppearanceMode.allCases) { mode in
+                let isSelected = viewModel.appearanceMode == mode
+                Button {
+                    viewModel.appearanceMode = mode
+                } label: {
+                    HStack(spacing: 6) {
+                        Image(systemName: mode.systemImage)
+                            .font(.system(size: 12, weight: .semibold))
+                        Text(mode.displayName)
+                            .font(AppTypography.chip)
+                    }
+                    .foregroundStyle(isSelected ? AppColors.textOnBrand : AppColors.textSecondary)
+                    .frame(maxWidth: .infinity)
+                    .padding(.vertical, 10)
+                    .background(
+                        RoundedRectangle(cornerRadius: 8, style: .continuous)
+                            .fill(isSelected ? AppColors.brand : Color.clear)
+                    )
+                    .contentShape(Rectangle())
                 }
+                .buttonStyle(.plain)
+                .accessibilityAddTraits(isSelected ? .isSelected : [])
+                .accessibilityLabel("\(mode.displayName) appearance")
             }
-            .padding(AppSpacing.xl)
-            .frame(maxWidth: 760, alignment: .leading)
         }
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
-        .background(AppColors.backgroundPrimary)
+        .padding(3)
+        .background(
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(AppColors.backgroundPrimary)
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .stroke(AppColors.border, lineWidth: 1)
+        )
     }
 
-    private var header: some View {
-        VStack(alignment: .leading, spacing: 4) {
-            Text("Settings")
-                .font(AppTypography.sectionTitle)
-                .foregroundStyle(AppColors.textPrimary)
-            Text("Defaults, appearance, and privacy for Indeed AI Jobs.")
+    // MARK: - Notifications
+
+    private var notificationsSection: some View {
+        settingsCard(
+            title: "Notifications",
+            subtitle: "Control how job alerts reach you on this Mac."
+        ) {
+            settingsRow(
+                icon: "bell.badge",
+                title: "macOS permission",
+                subtitle: viewModel.notificationPermissionLabel,
+                showsChevron: false
+            )
+
+            Divider().foregroundStyle(AppColors.border)
+
+            Toggle("Quiet hours", isOn: $viewModel.quietHoursEnabled)
+                .toggleStyle(FilterCheckboxToggleStyle())
+                .padding(.vertical, 4)
+
+            Text("Alert delivery is mocked in Phase 1. System notifications arrive in a later phase.")
+                .font(AppTypography.meta)
+                .foregroundStyle(AppColors.textTertiary)
+        }
+    }
+
+    // MARK: - Subscription
+
+    private var subscriptionSection: some View {
+        settingsCard(
+            title: "Subscription",
+            subtitle: "Manage Free vs Pro access for unlimited AI searches and job alerts."
+        ) {
+            settingsRow(
+                icon: "crown.fill",
+                title: viewModel.proPlanName,
+                subtitle: viewModel.isProActive
+                    ? "Your Yearly plan is active for unlimited AI searches and full tools."
+                    : "Upgrade for unlimited AI searches and full job alert tools.",
+                trailing: viewModel.isProActive ? "Active" : nil,
+                showsChevron: true,
+                action: {}
+            )
+
+            Divider().foregroundStyle(AppColors.border)
+
+            settingsRow(
+                icon: "arrow.clockwise",
+                title: "Restore Purchases",
+                subtitle: "Restore previous Pro purchases on this Mac.",
+                showsChevron: true,
+                action: { viewModel.restorePurchases() }
+            )
+
+            Divider().foregroundStyle(AppColors.border)
+
+            settingsRow(
+                icon: "creditcard",
+                title: "Manage Subscription",
+                subtitle: "Change or cancel your plan in the App Store.",
+                showsChevron: true,
+                action: { viewModel.manageSubscription() }
+            )
+        }
+    }
+
+    // MARK: - Privacy & data
+
+    private var privacySection: some View {
+        settingsCard(
+            title: "Privacy & data",
+            subtitle: "Local saved jobs and alerts stay on this Mac."
+        ) {
+            Text("Search queries may be processed by AI in later phases. Indeed listings open on indeed.com.")
                 .font(AppTypography.body)
                 .foregroundStyle(AppColors.textSecondary)
+
+            settingsRow(
+                icon: "trash",
+                title: "Clear local data",
+                subtitle: "Remove saved jobs and alerts stored on this Mac.",
+                showsChevron: false
+            )
+            .opacity(0.45)
+            .help("Available after persistence ships in Phase 5")
+
+            Divider().foregroundStyle(AppColors.border)
+
+            settingsRow(
+                icon: "square.and.arrow.up",
+                title: "Export saved jobs",
+                subtitle: "Download your saved listings for backup.",
+                showsChevron: false
+            )
+            .opacity(0.45)
+            .help("Available after persistence ships in Phase 5")
         }
     }
 
-    private func settingsCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
-        VStack(alignment: .leading, spacing: AppSpacing.sm) {
-            Text(title)
-                .font(AppTypography.filterHeader)
-                .foregroundStyle(AppColors.textPrimary)
+    // MARK: - About Us
+
+    private var aboutSection: some View {
+        settingsCard(
+            title: "About Us",
+            subtitle: "Product info, support, and legal."
+        ) {
+            HStack(spacing: AppSpacing.sm) {
+                ZStack {
+                    RoundedRectangle(cornerRadius: 10, style: .continuous)
+                        .fill(AppColors.brand)
+                        .frame(width: 40, height: 40)
+                    Image(systemName: "checkmark")
+                        .font(.system(size: 16, weight: .bold))
+                        .foregroundStyle(.white)
+                }
+
+                VStack(alignment: .leading, spacing: 2) {
+                    Text("Indeed AI Jobs")
+                        .font(AppTypography.filterHeader)
+                        .foregroundStyle(AppColors.textPrimary)
+                    Text(viewModel.appVersionLabel)
+                        .font(AppTypography.meta)
+                        .foregroundStyle(AppColors.textSecondary)
+                }
+
+                Spacer(minLength: 0)
+            }
+            .accessibilityElement(children: .combine)
+            .accessibilityLabel("Indeed AI Jobs, \(viewModel.appVersionLabel)")
+
+            Divider().foregroundStyle(AppColors.border)
+
+            settingsRow(
+                icon: "globe",
+                title: "Website",
+                subtitle: "Visit the Indeed AI Jobs homepage.",
+                showsChevron: true,
+                action: { viewModel.openWebsite() }
+            )
+
+            Divider().foregroundStyle(AppColors.border)
+
+            settingsRow(
+                icon: "questionmark.circle",
+                title: "Get Support",
+                subtitle: "Help with search, alerts, saved jobs, and Pro.",
+                showsChevron: true,
+                action: { viewModel.openSupport() }
+            )
+
+            Divider().foregroundStyle(AppColors.border)
+
+            settingsRow(
+                icon: "hand.raised",
+                title: "Privacy Policy",
+                subtitle: "How we handle your data.",
+                showsChevron: true,
+                action: { viewModel.openPrivacyPolicy() }
+            )
+        }
+    }
+
+    // MARK: - Building blocks
+
+    private func settingsCard<Content: View>(
+        title: String,
+        subtitle: String,
+        @ViewBuilder content: () -> Content
+    ) -> some View {
+        VStack(alignment: .leading, spacing: AppSpacing.md) {
+            VStack(alignment: .leading, spacing: 4) {
+                Text(title)
+                    .font(AppTypography.filterHeader)
+                    .foregroundStyle(AppColors.textPrimary)
+                Text(subtitle)
+                    .font(AppTypography.meta)
+                    .foregroundStyle(AppColors.textSecondary)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+
             content()
         }
-        .padding(AppSpacing.md)
+        .padding(AppSpacing.lg)
         .frame(maxWidth: .infinity, alignment: .leading)
         .background(AppColors.surfaceCard)
         .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
@@ -118,14 +294,60 @@ struct SettingsView: View {
             RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
                 .stroke(AppColors.border, lineWidth: 1)
         )
+        .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
     }
 
-    private func labeledField<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
-        VStack(alignment: .leading, spacing: 4) {
-            Text(title)
-                .font(AppTypography.caption)
-                .foregroundStyle(AppColors.textTertiary)
-            content()
+    @ViewBuilder
+    private func settingsRow(
+        icon: String,
+        title: String,
+        subtitle: String,
+        trailing: String? = nil,
+        showsChevron: Bool,
+        action: (() -> Void)? = nil
+    ) -> some View {
+        let rowContent = HStack(alignment: .center, spacing: AppSpacing.sm) {
+            Image(systemName: icon)
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(AppColors.brand)
+                .frame(width: 28, height: 28)
+                .background(AppColors.brandSoft)
+                .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(title)
+                    .font(AppTypography.filterHeader)
+                    .foregroundStyle(AppColors.textPrimary)
+                Text(subtitle)
+                    .font(AppTypography.meta)
+                    .foregroundStyle(AppColors.textSecondary)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+
+            Spacer(minLength: AppSpacing.sm)
+
+            if let trailing {
+                Text(trailing)
+                    .font(AppTypography.meta)
+                    .foregroundStyle(AppColors.textTertiary)
+            }
+
+            if showsChevron {
+                Image(systemName: "chevron.right")
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(AppColors.textTertiary)
+            }
+        }
+        .padding(.vertical, 6)
+        .contentShape(Rectangle())
+
+        if let action {
+            Button(action: action) {
+                rowContent
+            }
+            .buttonStyle(.plain)
+        } else {
+            rowContent
         }
     }
 }