浏览代码

Add Settings screen with global theme toggle and StoreKit config.

Implements a reference-style Settings page, persists dark/light mode across launches, and wires StoreKit test configuration for upgrade/restore flows.

Made-with: Cursor
huzaifahayat12 3 月之前
父节点
当前提交
3216955c17

+ 82 - 0
zoom_app.xcodeproj/xcshareddata/xcschemes/zoom_app.xcscheme

@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "2640"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "1391CBFF2F8E456500B3B198"
+               BuildableName = "zoom_app.app"
+               BlueprintName = "zoom_app"
+               ReferencedContainer = "container:zoom_app.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES"
+      queueDebuggingEnableBacktraceRecording = "Yes">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "1391CBFF2F8E456500B3B198"
+            BuildableName = "zoom_app.app"
+            BlueprintName = "zoom_app"
+            ReferencedContainer = "container:zoom_app.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <StoreKitConfigurationFileReference
+         identifier = "../../zoom_app/StoreKit.storekit">
+      </StoreKitConfigurationFileReference>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "1391CBFF2F8E456500B3B198"
+            BuildableName = "zoom_app.app"
+            BlueprintName = "zoom_app"
+            ReferencedContainer = "container:zoom_app.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 14 - 0
zoom_app/AppDelegate.swift

@@ -9,6 +9,7 @@ import Cocoa
 
 @main
 class AppDelegate: NSObject, NSApplicationDelegate {
+    private let darkModeDefaultsKey = "settings.darkModeEnabled"
 
     private enum DefaultWindowSize {
         static let width: CGFloat = 1020
@@ -19,6 +20,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
 
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {
+        let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
+        let darkEnabled = hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
+        if hasValue == false {
+            UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
+        }
+        NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
+
         // Force a consistent launch size (avoid state restoration overriding it).
         DispatchQueue.main.async { [weak self] in
             self?.applyDefaultWindowSizeIfNeeded()
@@ -48,5 +56,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         }
     }
 
+    private func systemPrefersDarkMode() -> Bool {
+        let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
+        let style = global?["AppleInterfaceStyle"] as? String
+        return style?.lowercased() == "dark"
+    }
+
 }
 

+ 82 - 0
zoom_app/StoreKit.storekit

@@ -0,0 +1,82 @@
+{
+  "identifier" : "D0E98E7D-0C63-4D0F-A999-EC6AAB6E3B7C",
+  "nonRenewingSubscriptions" : [],
+  "products" : [
+    {
+      "displayPrice" : "1100.00",
+      "familyShareable" : false,
+      "internalID" : "64D1B757-30B3-4D8A-AE2A-83B6E2B9B4A7",
+      "localizations" : [
+        {
+          "description" : "Unlock premium features with weekly access.",
+          "displayName" : "Premium Weekly",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.zoomapp.premium.weekly",
+      "referenceName" : "Premium Weekly",
+      "type" : "NonConsumable"
+    },
+    {
+      "displayPrice" : "2500.00",
+      "familyShareable" : false,
+      "internalID" : "5F0659F3-9E3E-4E7A-95A4-87F68F63AE64",
+      "localizations" : [
+        {
+          "description" : "Unlock premium features with monthly access.",
+          "displayName" : "Premium Monthly",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.zoomapp.premium.monthly",
+      "referenceName" : "Premium Monthly",
+      "type" : "NonConsumable"
+    },
+    {
+      "displayPrice" : "9900.00",
+      "familyShareable" : false,
+      "internalID" : "CE6E8B9A-65D4-4E3A-9EB2-5B81A410F1E0",
+      "localizations" : [
+        {
+          "description" : "Unlock premium features with yearly access.",
+          "displayName" : "Premium Yearly",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.zoomapp.premium.yearly",
+      "referenceName" : "Premium Yearly",
+      "type" : "NonConsumable"
+    },
+    {
+      "displayPrice" : "14900.00",
+      "familyShareable" : false,
+      "internalID" : "D01B7280-2C4C-4FB1-AC1F-4E61B70A1F6D",
+      "localizations" : [
+        {
+          "description" : "One-time premium purchase for lifetime access.",
+          "displayName" : "Premium Lifetime",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "com.mqldev.zoomapp.premium.lifetime",
+      "referenceName" : "Premium Lifetime",
+      "type" : "NonConsumable"
+    }
+  ],
+  "settings" : {
+    "_applicationInternalID" : "6A5A2E7C-2D73-4D48-8D32-7D5580A4B2F1",
+    "_developerTeamID" : "",
+    "_disableDialogs" : false,
+    "_failTransactionsEnabled" : false,
+    "_lastSynchronizedDate" : 0,
+    "_locale" : "en_US",
+    "_renewalRate" : 0,
+    "_storefront" : "USA",
+    "_timeRate" : 0
+  },
+  "subscriptionGroups" : [],
+  "version" : {
+    "major" : 3,
+    "minor" : 0
+  }
+}

+ 640 - 17
zoom_app/ViewController.swift

@@ -8,24 +8,140 @@
 import Cocoa
 import CryptoKit
 import Network
+import StoreKit
 import WebKit
 
 class ViewController: NSViewController {
     private let googleOAuth = GoogleOAuthService.shared
     private let zoomOAuth = ZoomOAuthService.shared
     private let loginStateKey = "zoom_app.isLoggedIn"
+    private let darkModeDefaultsKey = "settings.darkModeEnabled"
+
+    private struct Palette {
+        let isDarkMode: Bool
+
+        var appBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.96, alpha: 1)
+        }
+        var sidebarBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.94, alpha: 1)
+        }
+        var sidebarActiveBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.86, alpha: 1)
+        }
+        var cardBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
+                : NSColor.white
+        }
+        var secondaryCardBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.98, alpha: 1)
+        }
+        var appShellBackground: NSColor { appBackground }
+        var contentShellBackground: NSColor { appBackground }
+        var topStripBackground: NSColor { chromeUnifiedBackground }
+        var chromeUnifiedBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.92, alpha: 1)
+        }
+        var searchPillBackground: NSColor {
+            isDarkMode
+                ? NSColor.white.withAlphaComponent(0.06)
+                : NSColor.black.withAlphaComponent(0.06)
+        }
+        var meetingCardBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.98, alpha: 1)
+        }
+
+        var sectionCard: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
+                : NSColor.white
+        }
+        var inputBackground: NSColor {
+            isDarkMode
+                ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
+                : NSColor(calibratedWhite: 0.97, alpha: 1)
+        }
+        var inputBorder: NSColor {
+            isDarkMode
+                ? NSColor.white.withAlphaComponent(0.08)
+                : NSColor.black.withAlphaComponent(0.10)
+        }
+
+        var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
+        var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
+        var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
+    }
+
+    private final class TopAlignedClipView: NSClipView {
+        override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
+            var rect = super.constrainBoundsRect(proposedBounds)
+            rect.origin.x = 0
+            return rect
+        }
+    }
+
+    private final class HoverButton: NSButton {
+        var normalColor: NSColor = .clear { didSet { applyBackground() } }
+        var hoverColor: NSColor = .clear
+        private var tracking: NSTrackingArea?
+        private var hovering = false { didSet { applyBackground() } }
+
+        override func updateTrackingAreas() {
+            super.updateTrackingAreas()
+            if let tracking { removeTrackingArea(tracking) }
+            let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
+            addTrackingArea(area)
+            tracking = area
+        }
+
+        override func mouseEntered(with event: NSEvent) {
+            hovering = true
+        }
+
+        override func mouseExited(with event: NSEvent) {
+            hovering = false
+        }
+
+        private func applyBackground() {
+            wantsLayer = true
+            layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
+        }
+    }
+
+    private var palette = Palette(isDarkMode: true)
+    private var darkModeEnabled: Bool {
+        get {
+            let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
+            return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
+        }
+        set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
+    }
+
     private let sidebarWidth: CGFloat = 78
-    private let appBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
-    private let sidebarBackground = NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
-    private let sidebarActiveBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
-    private let cardBackground = NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
-    private let secondaryCardBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
-    private let appShellBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
-    private let contentShellBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
-    private let topStripBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
-    private let chromeUnifiedBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
-    private let searchPillBackground = NSColor.white.withAlphaComponent(0.06)
-    private let meetingCardBackground = NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
+    private var appBackground: NSColor { palette.appBackground }
+    private var sidebarBackground: NSColor { palette.sidebarBackground }
+    private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
+    private var cardBackground: NSColor { palette.cardBackground }
+    private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
+    private var appShellBackground: NSColor { palette.appShellBackground }
+    private var contentShellBackground: NSColor { palette.contentShellBackground }
+    private var topStripBackground: NSColor { palette.topStripBackground }
+    private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
+    private var searchPillBackground: NSColor { palette.searchPillBackground }
+    private var meetingCardBackground: NSColor { palette.meetingCardBackground }
     private let appShellCornerRadius: CGFloat = 20
     private let homeChromeHeaderHeight: CGFloat = 56
     private let nativeTrafficLightsLeading: CGFloat = 14
@@ -33,9 +149,9 @@ class ViewController: NSViewController {
     private let brandLeadingInset: CGFloat = 84
     private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
     private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
-    private let primaryText = NSColor(calibratedWhite: 0.98, alpha: 1)
-    private let secondaryText = NSColor(calibratedWhite: 0.78, alpha: 1)
-    private let mutedText = NSColor(calibratedWhite: 0.66, alpha: 1)
+    private var primaryText: NSColor { palette.primaryText }
+    private var secondaryText: NSColor { palette.secondaryText }
+    private var mutedText: NSColor { palette.mutedText }
 
     private let rootContainer = NSView()
     private var loginView: NSView?
@@ -62,6 +178,11 @@ class ViewController: NSViewController {
     private weak var homePlaceholderLabel: NSTextField?
     private weak var homeSearchField: NSTextField?
     private weak var homeSearchPill: NSView?
+    private weak var homeSettingsView: NSView?
+    private weak var settingsDarkModeSwitch: NSSwitch?
+    private weak var settingsUpgradeButton: NSButton?
+    private weak var settingsRestoreButton: NSButton?
+    private weak var settingsGoogleActionButton: NSButton?
     private var allScheduledMeetings: [ScheduledMeeting] = []
     private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
     private var selectedHomeSidebarItem: String = "Home"
@@ -89,9 +210,136 @@ class ViewController: NSViewController {
         case home
     }
 
+    private enum PremiumPlan: String, CaseIterable {
+        case weekly = "com.mqldev.zoomapp.premium.weekly"
+        case monthly = "com.mqldev.zoomapp.premium.monthly"
+        case yearly = "com.mqldev.zoomapp.premium.yearly"
+        case lifetime = "com.mqldev.zoomapp.premium.lifetime"
+
+        var displayName: String {
+            switch self {
+            case .weekly: return "Premium Weekly"
+            case .monthly: return "Premium Monthly"
+            case .yearly: return "Premium Yearly"
+            case .lifetime: return "Premium Lifetime"
+            }
+        }
+    }
+
+    private final class StoreKitCoordinator {
+        enum PurchaseOutcome {
+            case success
+            case pending
+            case cancelled
+            case failed(String)
+        }
+
+        private(set) var productsByID: [String: Product] = [:]
+        private(set) var activeProductIDs = Set<String>()
+
+        var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
+
+        private var transactionUpdatesTask: Task<Void, Never>?
+        var onEntitlementsChanged: ((Bool) -> Void)?
+
+        func start() async {
+            await refreshProducts()
+            await refreshEntitlements()
+            observeTransactionUpdatesIfNeeded()
+        }
+
+        func refreshProducts() async {
+            do {
+                let products = try await Product.products(for: PremiumPlan.allCases.map(\.rawValue))
+                productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
+            } catch {
+                productsByID = [:]
+            }
+        }
+
+        func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
+            guard let product = productsByID[plan.rawValue] else {
+                await refreshProducts()
+                guard let refreshed = productsByID[plan.rawValue] else {
+                    return .failed("Product not available. Check StoreKit configuration and product IDs.")
+                }
+                return await purchase(product: refreshed)
+            }
+            return await purchase(product: product)
+        }
+
+        func restorePurchases() async -> String {
+            do {
+                try await AppStore.sync()
+                await refreshEntitlements()
+                return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
+            } catch {
+                return "Restore failed. \(error.localizedDescription)"
+            }
+        }
+
+        private func purchase(product: Product) async -> PurchaseOutcome {
+            do {
+                let result = try await product.purchase()
+                switch result {
+                case .success(let verification):
+                    guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
+                    await transaction.finish()
+                    await refreshEntitlements()
+                    return .success
+                case .pending:
+                    return .pending
+                case .userCancelled:
+                    return .cancelled
+                @unknown default:
+                    return .failed("Unknown purchase state.")
+                }
+            } catch {
+                return .failed(error.localizedDescription)
+            }
+        }
+
+        private func observeTransactionUpdatesIfNeeded() {
+            guard transactionUpdatesTask == nil else { return }
+            transactionUpdatesTask = Task { [weak self] in
+                for await update in Transaction.updates {
+                    guard case .verified(let transaction) = update else { continue }
+                    if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) {
+                        await self?.refreshEntitlements()
+                    }
+                    await transaction.finish()
+                }
+            }
+        }
+
+        @MainActor
+        private func refreshEntitlements() async {
+            var active = Set<String>()
+            for await entitlement in Transaction.currentEntitlements {
+                guard case .verified(let transaction) = entitlement else { continue }
+                if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID),
+                   transaction.revocationDate == nil {
+                    active.insert(transaction.productID)
+                }
+            }
+            let changed = active != activeProductIDs
+            activeProductIDs = active
+            if changed {
+                onEntitlementsChanged?(hasPremiumAccess)
+            }
+        }
+    }
+
+    private let storeKitCoordinator = StoreKitCoordinator()
+    private var storeKitStartupTask: Task<Void, Never>?
+
     override func viewDidLoad() {
         super.viewDidLoad()
+        palette = Palette(isDarkMode: darkModeEnabled)
+        NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
+        view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
         setupUI()
+        startStoreKit()
     }
 
     override func viewDidAppear() {
@@ -474,6 +722,14 @@ class ViewController: NSViewController {
         alert.runModal()
     }
 
+    private func showSimpleAlert(title: String, message: String) {
+        let alert = NSAlert()
+        alert.alertStyle = .informational
+        alert.messageText = title
+        alert.informativeText = message
+        alert.runModal()
+    }
+
     private struct ScheduledMeeting {
         let title: String
         let start: Date
@@ -936,6 +1192,362 @@ class ViewController: NSViewController {
         return root
     }
 
+    private func makeSettingsView() -> NSView {
+        let panel = NSView()
+        panel.translatesAutoresizingMaskIntoConstraints = false
+
+        let scroll = NSScrollView()
+        scroll.translatesAutoresizingMaskIntoConstraints = false
+        scroll.drawsBackground = false
+        scroll.hasHorizontalScroller = false
+        scroll.hasVerticalScroller = true
+        scroll.autohidesScrollers = true
+        scroll.borderType = .noBorder
+        scroll.scrollerStyle = .overlay
+        scroll.automaticallyAdjustsContentInsets = false
+        let clip = TopAlignedClipView()
+        clip.drawsBackground = false
+        scroll.contentView = clip
+        panel.addSubview(scroll)
+
+        let content = NSView()
+        content.translatesAutoresizingMaskIntoConstraints = false
+        scroll.documentView = content
+
+        let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
+        card.translatesAutoresizingMaskIntoConstraints = false
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
+        content.addSubview(card)
+
+        let stack = NSStackView()
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.orientation = .vertical
+        stack.spacing = 18
+        stack.alignment = .leading
+        card.addSubview(stack)
+
+        let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
+        let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
+        stack.addArrangedSubview(pageTitle)
+        stack.addArrangedSubview(pageSubtitle)
+        stack.setCustomSpacing(24, after: pageSubtitle)
+
+        let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
+        stack.addArrangedSubview(appearanceTitle)
+        let darkModeRow = makeSettingsDarkModeRow()
+        stack.addArrangedSubview(darkModeRow)
+        darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        stack.setCustomSpacing(24, after: darkModeRow)
+
+        let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
+        stack.addArrangedSubview(accountTitle)
+        let googleAccountRow = makeSettingsGoogleAccountRow()
+        stack.addArrangedSubview(googleAccountRow)
+        googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        stack.setCustomSpacing(24, after: googleAccountRow)
+
+        let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
+        stack.addArrangedSubview(appTitle)
+        let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
+        stack.addArrangedSubview(shareButton)
+        shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+
+        let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
+        stack.addArrangedSubview(upgradeButton)
+        upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        settingsUpgradeButton = upgradeButton
+
+        let restoreButton = makeSettingsActionButton(icon: "↺", title: "Restore Purchases", action: .restorePurchases)
+        stack.addArrangedSubview(restoreButton)
+        restoreButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        settingsRestoreButton = restoreButton
+        stack.setCustomSpacing(24, after: restoreButton)
+
+        let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
+        stack.addArrangedSubview(legalTitle)
+        let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
+        stack.addArrangedSubview(privacyButton)
+        privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
+        stack.addArrangedSubview(supportButton)
+        supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
+        stack.addArrangedSubview(termsButton)
+        termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+
+        NSLayoutConstraint.activate([
+            scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
+            scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
+            scroll.topAnchor.constraint(equalTo: panel.topAnchor),
+            scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
+
+            content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
+            content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
+            content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
+            content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
+            content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
+
+            card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+            card.topAnchor.constraint(equalTo: content.topAnchor, constant: 36),
+            content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
+            card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
+            card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
+            card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
+            card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
+
+            stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
+            stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
+            stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
+            stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
+        ])
+
+        updatePremiumButtons()
+        return panel
+    }
+
+    private enum SettingsAction: Int {
+        case shareApp = 1
+        case upgrade = 2
+        case restorePurchases = 3
+        case privacyPolicy = 4
+        case support = 5
+        case termsOfServices = 6
+    }
+
+    private func makeSettingsDarkModeRow() -> NSView {
+        let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 52).isActive = true
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
+        let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
+        let toggle = NSSwitch()
+        toggle.translatesAutoresizingMaskIntoConstraints = false
+        toggle.state = darkModeEnabled ? .on : .off
+        toggle.target = self
+        toggle.action = #selector(settingsDarkModeToggled(_:))
+        settingsDarkModeSwitch = toggle
+
+        row.addSubview(icon)
+        row.addSubview(title)
+        row.addSubview(toggle)
+        NSLayoutConstraint.activate([
+            icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
+            icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+            title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
+            title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
+            toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+            toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
+        ])
+        return row
+    }
+
+    private func makeSettingsGoogleAccountRow() -> NSView {
+        let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
+        row.translatesAutoresizingMaskIntoConstraints = false
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let signedIn = isUserLoggedIn()
+        let titleText = signedIn ? "Google account connected" : "Google account not connected"
+        let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
+
+        let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
+        let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
+        subtitle.maximumNumberOfLines = 2
+        subtitle.lineBreakMode = .byTruncatingTail
+
+        let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
+        actionButton.translatesAutoresizingMaskIntoConstraints = false
+        actionButton.bezelStyle = .rounded
+        actionButton.controlSize = .regular
+        settingsGoogleActionButton = actionButton
+
+        row.addSubview(title)
+        row.addSubview(subtitle)
+        row.addSubview(actionButton)
+        NSLayoutConstraint.activate([
+            row.heightAnchor.constraint(equalToConstant: 78),
+            title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
+            title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
+            subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
+            subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
+            subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
+            actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+            actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
+        ])
+
+        return row
+    }
+
+    private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
+        let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 10
+        button.normalColor = palette.inputBackground
+        button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
+        styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+        button.heightAnchor.constraint(equalToConstant: 46).isActive = true
+        button.tag = action.rawValue
+
+        let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
+        button.addSubview(iconLabel)
+        button.addSubview(titleLabel)
+        NSLayoutConstraint.activate([
+            iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
+            iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
+            titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
+            titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
+        ])
+        return button
+    }
+
+    @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
+        guard let action = SettingsAction(rawValue: sender.tag) else { return }
+        switch action {
+        case .shareApp:
+            showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
+        case .upgrade:
+            settingsUpgradePremiumTapped()
+        case .restorePurchases:
+            settingsRestorePurchasesTapped()
+        case .privacyPolicy:
+            showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
+        case .support:
+            showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
+        case .termsOfServices:
+            showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
+        }
+    }
+
+    @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
+        if isUserLoggedIn() {
+            logoutTapped()
+            return
+        }
+        showSimpleAlert(title: "Sign in", message: "Please sign in from the login screen to connect your account.")
+    }
+
+    private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
+        let v = NSView()
+        v.wantsLayer = true
+        v.layer?.backgroundColor = color.cgColor
+        v.layer?.cornerRadius = cornerRadius
+        return v
+    }
+
+    private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
+        view.wantsLayer = true
+        view.layer?.borderColor = borderColor.cgColor
+        view.layer?.borderWidth = borderWidth
+        if shadow {
+            view.layer?.shadowColor = NSColor.black.cgColor
+            view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
+            view.layer?.shadowRadius = 18
+            view.layer?.shadowOffset = NSSize(width: 0, height: -2)
+        } else {
+            view.layer?.shadowOpacity = 0
+        }
+    }
+
+    private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
+        let label = NSTextField(labelWithString: text)
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.font = font
+        label.textColor = color
+        label.backgroundColor = .clear
+        label.isBezeled = false
+        label.isEditable = false
+        label.isSelectable = false
+        return label
+    }
+
+    private func startStoreKit() {
+        storeKitStartupTask?.cancel()
+        storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
+            DispatchQueue.main.async {
+                self?.updatePremiumButtons()
+            }
+        }
+        storeKitStartupTask = Task { [weak self] in
+            await self?.storeKitCoordinator.start()
+            await MainActor.run {
+                self?.updatePremiumButtons()
+            }
+        }
+    }
+
+    @MainActor
+    private func updatePremiumButtons() {
+        let isPremium = storeKitCoordinator.hasPremiumAccess
+        settingsUpgradeButton?.title = isPremium ? "Premium Active" : "Upgrade"
+        settingsUpgradeButton?.isEnabled = isPremium == false
+        settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
+        settingsRestoreButton?.isEnabled = true
+    }
+
+    @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
+        setDarkMode(sender.state == .on)
+    }
+
+    private func setDarkMode(_ enabled: Bool) {
+        darkModeEnabled = enabled
+        palette = Palette(isDarkMode: enabled)
+        NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
+        view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
+
+        if isUserLoggedIn() {
+            let keepSelected = selectedHomeSidebarItem
+            selectedHomeSidebarItem = keepSelected
+            showHomeView(profile: nil)
+        } else {
+            showLoginView()
+        }
+    }
+
+    private func systemPrefersDarkMode() -> Bool {
+        let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
+        let style = global?["AppleInterfaceStyle"] as? String
+        return style?.lowercased() == "dark"
+    }
+
+    @objc private func settingsUpgradePremiumTapped() {
+        guard storeKitCoordinator.hasPremiumAccess == false else { return }
+        settingsUpgradeButton?.isEnabled = false
+        settingsUpgradeButton?.alphaValue = 0.6
+        Task { [weak self] in
+            guard let self else { return }
+            let result = await self.storeKitCoordinator.purchase(plan: .lifetime)
+            await MainActor.run {
+                self.updatePremiumButtons()
+                switch result {
+                case .success:
+                    self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
+                case .pending:
+                    self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
+                case .cancelled:
+                    break
+                case .failed(let message):
+                    self.showSimpleAlert(title: "Purchase Failed", message: message)
+                }
+            }
+        }
+    }
+
+    @objc private func settingsRestorePurchasesTapped() {
+        Task { [weak self] in
+            guard let self else { return }
+            let message = await self.storeKitCoordinator.restorePurchases()
+            await MainActor.run {
+                self.updatePremiumButtons()
+                self.showSimpleAlert(title: "Restore Purchases", message: message)
+            }
+        }
+    }
+
     // MARK: - Home UI
 
     private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
@@ -1174,6 +1786,9 @@ class ViewController: NSViewController {
         let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
         placeholder.isHidden = true
 
+        let settingsView = makeSettingsView()
+        settingsView.isHidden = selectedHomeSidebarItem != "Settings"
+
         let contentColumn = NSView()
         contentColumn.translatesAutoresizingMaskIntoConstraints = false
         content.addSubview(topBar)
@@ -1192,7 +1807,7 @@ class ViewController: NSViewController {
         [searchIcon, searchField, searchHintLabel].forEach {
             searchPill.addSubview($0)
         }
-        [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder].forEach {
+        [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
             contentColumn.addSubview($0)
         }
@@ -1298,7 +1913,12 @@ class ViewController: NSViewController {
             refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
 
             placeholder.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
-            placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor)
+            placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor),
+
+            settingsView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
+            settingsView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
+            settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
+            settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor)
         ])
 
         timeLabel = timeTitle
@@ -1309,6 +1929,7 @@ class ViewController: NSViewController {
         homeActionsRow = actions
         homeMeetingsPanel = panel
         homePlaceholderLabel = placeholder
+        homeSettingsView = settingsView
         meetingsDayHeaderLabel = panelHeader
         meetingsListStack = meetingsStack
         meetingsStatusLabel = meetingsStatus
@@ -1595,6 +2216,7 @@ class ViewController: NSViewController {
     @MainActor
     private func updateSelectedHomeSectionUI() {
         let isHome = selectedHomeSidebarItem == "Home"
+        let isSettings = selectedHomeSidebarItem == "Settings"
         let title = selectedHomeSidebarItem
 
         homeWelcomeLabel?.stringValue = title
@@ -1613,7 +2235,8 @@ class ViewController: NSViewController {
             meetingsScrollView,
             refreshMeetingsButton
         ]
-        dashboardViews.forEach { $0?.isHidden = isHome == false }
+        dashboardViews.forEach { $0?.isHidden = isHome == false || isSettings }
+        homeSettingsView?.isHidden = isSettings == false
 
         if isHome {
             homePlaceholderLabel?.isHidden = true