Przeglądaj źródła

Add sidebar navigation and settings popover

Wire sidebar to swap main content pages, add a Settings popover menu with dark mode toggle, and persist appearance preference.

Made-with: Cursor
huzaifahayat12 2 tygodni temu
rodzic
commit
749aba54dd
2 zmienionych plików z 536 dodań i 26 usunięć
  1. 5 5
      meetings_app/AppDelegate.swift
  2. 531 21
      meetings_app/ViewController.swift

+ 5 - 5
meetings_app/AppDelegate.swift

@@ -9,13 +9,13 @@ import Cocoa
9 9
 
10 10
 @main
11 11
 class AppDelegate: NSObject, NSApplicationDelegate {
12
-
13
-    
14
-
12
+    private let darkModeDefaultsKey = "settings.darkModeEnabled"
15 13
 
16 14
     func applicationDidFinishLaunching(_ aNotification: Notification) {
17
-        // Force app-wide dark appearance for consistent dark UI.
18
-        NSApp.appearance = NSAppearance(named: .darkAqua)
15
+        // Match saved preference (defaults to dark if never set).
16
+        let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
17
+        let darkEnabled = hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : true
18
+        NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
19 19
     }
20 20
 
21 21
     func applicationWillTerminate(_ aNotification: Notification) {

+ 531 - 21
meetings_app/ViewController.swift

@@ -7,10 +7,70 @@
7 7
 
8 8
 import Cocoa
9 9
 
10
+private enum SidebarPage: Int {
11
+    case joinMeetings = 0
12
+    case photo = 1
13
+    case video = 2
14
+    case tutorials = 3
15
+    case settings = 4
16
+}
17
+
18
+private enum MeetingProvider: Int {
19
+    case meet = 0
20
+    case zoom = 1
21
+    case teams = 2
22
+    case zoho = 3
23
+}
24
+
25
+private enum SettingsAction: Int {
26
+    case restore = 0
27
+    case rateUs = 1
28
+    case support = 2
29
+    case moreApps = 3
30
+    case shareApp = 4
31
+}
32
+
10 33
 final class ViewController: NSViewController {
11 34
     private let palette = Palette()
12 35
     private let typography = Typography()
13 36
 
37
+    private var mainContentHost: NSView?
38
+    private var sidebarRowViews: [SidebarPage: NSView] = [:]
39
+    private var tabViews: [MeetingProvider: NSView] = [:]
40
+    private var selectedSidebarPage: SidebarPage = .joinMeetings
41
+    private var selectedMeetingProvider: MeetingProvider = .meet
42
+    private var pageCache: [SidebarPage: NSView] = [:]
43
+    private var sidebarPageByView = [ObjectIdentifier: SidebarPage]()
44
+    private var meetingProviderByView = [ObjectIdentifier: MeetingProvider]()
45
+    private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
46
+
47
+    private let darkModeDefaultsKey = "settings.darkModeEnabled"
48
+    private var darkModeEnabled: Bool {
49
+        get {
50
+            let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
51
+            return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : true
52
+        }
53
+        set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
54
+    }
55
+
56
+    private lazy var settingsPopover: NSPopover = {
57
+        let popover = NSPopover()
58
+        popover.behavior = .transient
59
+        popover.animates = true
60
+        popover.contentViewController = SettingsMenuViewController(
61
+            palette: palette,
62
+            typography: typography,
63
+            darkModeEnabled: darkModeEnabled,
64
+            onToggleDarkMode: { [weak self] enabled in
65
+                self?.setDarkMode(enabled)
66
+            },
67
+            onAction: { [weak self] action in
68
+                self?.handleSettingsAction(action)
69
+            }
70
+        )
71
+        return popover
72
+    }()
73
+
14 74
     override func viewDidLoad() {
15 75
         super.viewDidLoad()
16 76
         setupRootView()
@@ -21,7 +81,7 @@ final class ViewController: NSViewController {
21 81
         super.viewDidAppear()
22 82
         view.window?.setContentSize(NSSize(width: 1120, height: 690))
23 83
         view.window?.minSize = NSSize(width: 940, height: 600)
24
-        view.window?.title = "App for Google Meet"
84
+        applyWindowTitle(for: selectedSidebarPage)
25 85
     }
26 86
 
27 87
     override var representedObject: Any? {
@@ -31,7 +91,7 @@ final class ViewController: NSViewController {
31 91
 
32 92
 private extension ViewController {
33 93
     func setupRootView() {
34
-        view.appearance = NSAppearance(named: .darkAqua)
94
+        view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
35 95
         view.wantsLayer = true
36 96
         view.layer?.backgroundColor = palette.pageBackground.cgColor
37 97
     }
@@ -57,6 +117,170 @@ private extension ViewController {
57 117
         splitContainer.addArrangedSubview(mainPanel)
58 118
     }
59 119
 
120
+    @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) {
121
+        guard let view = sender.view,
122
+              let page = sidebarPageByView[ObjectIdentifier(view)],
123
+              page != selectedSidebarPage || page == .settings else { return }
124
+
125
+        if page == .settings {
126
+            showSettingsPopover()
127
+            return
128
+        }
129
+
130
+        showSidebarPage(page)
131
+    }
132
+
133
+    @objc private func meetingTabClicked(_ sender: NSClickGestureRecognizer) {
134
+        guard let view = sender.view,
135
+              let provider = meetingProviderByView[ObjectIdentifier(view)],
136
+              provider != selectedMeetingProvider else { return }
137
+        selectedMeetingProvider = provider
138
+        updateTabAppearance()
139
+    }
140
+
141
+    private func showSidebarPage(_ page: SidebarPage) {
142
+        selectedSidebarPage = page
143
+        updateSidebarAppearance()
144
+        applyWindowTitle(for: page)
145
+
146
+        guard let host = mainContentHost else { return }
147
+        host.subviews.forEach { $0.removeFromSuperview() }
148
+        let child = viewForPage(page)
149
+        child.translatesAutoresizingMaskIntoConstraints = false
150
+        host.addSubview(child)
151
+        NSLayoutConstraint.activate([
152
+            child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
153
+            child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
154
+            child.topAnchor.constraint(equalTo: host.topAnchor),
155
+            child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
156
+        ])
157
+    }
158
+
159
+    private func showSettingsPopover() {
160
+        guard let anchor = sidebarRowViews[.settings] else { return }
161
+        if settingsPopover.isShown {
162
+            settingsPopover.performClose(nil)
163
+            return
164
+        }
165
+
166
+        if let menu = settingsPopover.contentViewController as? SettingsMenuViewController {
167
+            menu.setDarkModeEnabled(darkModeEnabled)
168
+        }
169
+
170
+        settingsPopover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
171
+    }
172
+
173
+    private func setDarkMode(_ enabled: Bool) {
174
+        darkModeEnabled = enabled
175
+        NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
176
+        view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
177
+    }
178
+
179
+    private func handleSettingsAction(_ action: SettingsAction) {
180
+        switch action {
181
+        case .restore:
182
+            showSimpleAlert(title: "Restore", message: "Restore action tapped.")
183
+        case .rateUs:
184
+            // Replace with your App Store URL when ready.
185
+            showSimpleAlert(title: "Rate Us", message: "Rate Us tapped (add App Store URL).")
186
+        case .support:
187
+            showSimpleAlert(title: "Support", message: "Support tapped (add support email / page).")
188
+        case .moreApps:
189
+            showSimpleAlert(title: "More Apps", message: "More Apps tapped (add developer page URL).")
190
+        case .shareApp:
191
+            let urlString = "https://example.com"
192
+            NSPasteboard.general.clearContents()
193
+            NSPasteboard.general.setString(urlString, forType: .string)
194
+            showSimpleAlert(title: "Share App", message: "Link copied to clipboard:\n\(urlString)")
195
+        }
196
+    }
197
+
198
+    private func showSimpleAlert(title: String, message: String) {
199
+        let alert = NSAlert()
200
+        alert.messageText = title
201
+        alert.informativeText = message
202
+        alert.addButton(withTitle: "OK")
203
+        alert.runModal()
204
+    }
205
+
206
+    private func viewForPage(_ page: SidebarPage) -> NSView {
207
+        if let cached = pageCache[page] { return cached }
208
+        let built: NSView
209
+        switch page {
210
+        case .joinMeetings:
211
+            built = makeJoinMeetingsContent()
212
+        case .photo:
213
+            built = makePlaceholderPage(title: "Photo", subtitle: "Backgrounds — choose a photo background for your meetings.")
214
+        case .video:
215
+            built = makePlaceholderPage(title: "Video", subtitle: "Backgrounds — video background options.")
216
+        case .tutorials:
217
+            built = makePlaceholderPage(title: "Tutorials", subtitle: "Learn how to use the app.")
218
+        case .settings:
219
+            built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
220
+        }
221
+        pageCache[page] = built
222
+        return built
223
+    }
224
+
225
+    private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
226
+        let panel = NSView()
227
+        panel.translatesAutoresizingMaskIntoConstraints = false
228
+        let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary)
229
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
230
+        let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary)
231
+        sub.translatesAutoresizingMaskIntoConstraints = false
232
+        panel.addSubview(titleLabel)
233
+        panel.addSubview(sub)
234
+        NSLayoutConstraint.activate([
235
+            titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
236
+            titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
237
+            sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
238
+            sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
239
+        ])
240
+        return panel
241
+    }
242
+
243
+    private func applyWindowTitle(for page: SidebarPage) {
244
+        switch page {
245
+        case .joinMeetings:
246
+            view.window?.title = "App for Google Meet"
247
+        case .photo:
248
+            view.window?.title = "Backgrounds — Photo"
249
+        case .video:
250
+            view.window?.title = "Backgrounds — Video"
251
+        case .tutorials:
252
+            view.window?.title = "Tutorials"
253
+        case .settings:
254
+            view.window?.title = "Settings"
255
+        }
256
+    }
257
+
258
+    private func updateSidebarAppearance() {
259
+        for (page, row) in sidebarRowViews {
260
+            applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page))
261
+        }
262
+    }
263
+
264
+    private func updateTabAppearance() {
265
+        for (provider, tab) in tabViews {
266
+            applyTabStyle(tab, provider: provider, logoTemplate: logoTemplateForMeetingProvider(provider))
267
+        }
268
+    }
269
+
270
+    private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
271
+        switch page {
272
+        case .photo, .tutorials: return false
273
+        case .joinMeetings, .video, .settings: return true
274
+        }
275
+    }
276
+
277
+    private func logoTemplateForMeetingProvider(_ provider: MeetingProvider) -> Bool {
278
+        switch provider {
279
+        case .teams: return false
280
+        case .meet, .zoom, .zoho: return true
281
+        }
282
+    }
283
+
60 284
     func makeSidebar() -> NSView {
61 285
         let sidebar = NSView()
62 286
         sidebar.translatesAutoresizingMaskIntoConstraints = false
@@ -86,13 +310,23 @@ private extension ViewController {
86 310
         menuStack.spacing = 10
87 311
 
88 312
         menuStack.addArrangedSubview(sidebarSectionTitle("Meetings"))
89
-        menuStack.addArrangedSubview(sidebarItem("Join Meetings", icon: "􀉣", selected: true, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0))
313
+        let joinRow = sidebarItem("Join Meetings", icon: "􀉣", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0)
314
+        menuStack.addArrangedSubview(joinRow)
315
+        sidebarRowViews[.joinMeetings] = joinRow
90 316
         menuStack.addArrangedSubview(sidebarSectionTitle("Backgrounds"))
91
-        menuStack.addArrangedSubview(sidebarItem("Photo", icon: "􀏂", selected: false, logoImageName: "SidebarPhotoLogo", logoIconWidth: 24, logoHeightMultiplier: 82.0 / 62.0, logoTemplate: false))
92
-        menuStack.addArrangedSubview(sidebarItem("Video", icon: "􀎚", selected: false, logoImageName: "SidebarVideoLogo", logoIconWidth: 28, logoHeightMultiplier: 52.0 / 60.0))
317
+        let photoRow = sidebarItem("Photo", icon: "􀏂", page: .photo, logoImageName: "SidebarPhotoLogo", logoIconWidth: 24, logoHeightMultiplier: 82.0 / 62.0, logoTemplate: false)
318
+        menuStack.addArrangedSubview(photoRow)
319
+        sidebarRowViews[.photo] = photoRow
320
+        let videoRow = sidebarItem("Video", icon: "􀎚", page: .video, logoImageName: "SidebarVideoLogo", logoIconWidth: 28, logoHeightMultiplier: 52.0 / 60.0)
321
+        menuStack.addArrangedSubview(videoRow)
322
+        sidebarRowViews[.video] = videoRow
93 323
         menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
94
-        menuStack.addArrangedSubview(sidebarItem("Tutorials", icon: "􀛩", selected: false, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0, logoTemplate: false))
95
-        menuStack.addArrangedSubview(sidebarItem("Settings", icon: "􀍟", selected: false, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0))
324
+        let tutorialsRow = sidebarItem("Tutorials", icon: "􀛩", page: .tutorials, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0, logoTemplate: false)
325
+        menuStack.addArrangedSubview(tutorialsRow)
326
+        sidebarRowViews[.tutorials] = tutorialsRow
327
+        let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
328
+        menuStack.addArrangedSubview(settingsRow)
329
+        sidebarRowViews[.settings] = settingsRow
96 330
 
97 331
         sidebar.addSubview(titleRow)
98 332
         sidebar.addSubview(menuStack)
@@ -120,6 +354,25 @@ private extension ViewController {
120 354
         panel.wantsLayer = true
121 355
         panel.layer?.backgroundColor = palette.pageBackground.cgColor
122 356
 
357
+        let host = NSView()
358
+        host.translatesAutoresizingMaskIntoConstraints = false
359
+        panel.addSubview(host)
360
+        NSLayoutConstraint.activate([
361
+            host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
362
+            host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
363
+            host.topAnchor.constraint(equalTo: panel.topAnchor),
364
+            host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
365
+        ])
366
+        mainContentHost = host
367
+        showSidebarPage(.joinMeetings)
368
+
369
+        return panel
370
+    }
371
+
372
+    func makeJoinMeetingsContent() -> NSView {
373
+        let panel = NSView()
374
+        panel.translatesAutoresizingMaskIntoConstraints = false
375
+
123 376
         let contentStack = NSStackView()
124 377
         contentStack.translatesAutoresizingMaskIntoConstraints = false
125 378
         contentStack.orientation = .vertical
@@ -192,10 +445,18 @@ private extension ViewController {
192 445
         stack.distribution = .fillEqually
193 446
         stack.spacing = 4
194 447
 
195
-        stack.addArrangedSubview(topTab("Meet", icon: "􀤆", selected: true, logoImageName: "MeetLogo"))
196
-        stack.addArrangedSubview(topTab("Zoom", icon: "􀤉", selected: false, logoImageName: "ZoomLogo", logoPointSize: 34))
197
-        stack.addArrangedSubview(topTab("Teams", icon: "􀉨", selected: false, logoImageName: "TeamsLogo", logoPointSize: 26, logoHeightMultiplier: 62.0 / 50.0, logoTemplate: false))
198
-        stack.addArrangedSubview(topTab("Zoho", icon: "􀯶", selected: false, logoImageName: "ZohoLogo", logoPointSize: 28))
448
+        let meetTab = topTab("Meet", icon: "􀤆", provider: .meet, logoImageName: "MeetLogo")
449
+        stack.addArrangedSubview(meetTab)
450
+        tabViews[.meet] = meetTab
451
+        let zoomTab = topTab("Zoom", icon: "􀤉", provider: .zoom, logoImageName: "ZoomLogo", logoPointSize: 34)
452
+        stack.addArrangedSubview(zoomTab)
453
+        tabViews[.zoom] = zoomTab
454
+        let teamsTab = topTab("Teams", icon: "􀉨", provider: .teams, logoImageName: "TeamsLogo", logoPointSize: 26, logoHeightMultiplier: 62.0 / 50.0, logoTemplate: false)
455
+        stack.addArrangedSubview(teamsTab)
456
+        tabViews[.teams] = teamsTab
457
+        let zohoTab = topTab("Zoho", icon: "􀯶", provider: .zoho, logoImageName: "ZohoLogo", logoPointSize: 28)
458
+        stack.addArrangedSubview(zohoTab)
459
+        tabViews[.zoho] = zohoTab
199 460
 
200 461
         shell.addSubview(stack)
201 462
         wrapper.addSubview(shell)
@@ -367,6 +628,188 @@ private extension ViewController {
367 628
     }
368 629
 }
369 630
 
631
+/// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
632
+private final class RowHitTestView: NSView {
633
+    override func hitTest(_ point: NSPoint) -> NSView? {
634
+        guard let superview else { return nil }
635
+        let local = convert(point, from: superview)
636
+        return bounds.contains(local) ? self : nil
637
+    }
638
+}
639
+
640
+private final class SettingsMenuViewController: NSViewController {
641
+    private let palette: Palette
642
+    private let typography: Typography
643
+    private let onToggleDarkMode: (Bool) -> Void
644
+    private let onAction: (SettingsAction) -> Void
645
+
646
+    private var darkToggle: NSSwitch?
647
+
648
+    init(
649
+        palette: Palette,
650
+        typography: Typography,
651
+        darkModeEnabled: Bool,
652
+        onToggleDarkMode: @escaping (Bool) -> Void,
653
+        onAction: @escaping (SettingsAction) -> Void
654
+    ) {
655
+        self.palette = palette
656
+        self.typography = typography
657
+        self.onToggleDarkMode = onToggleDarkMode
658
+        self.onAction = onAction
659
+        super.init(nibName: nil, bundle: nil)
660
+        self.view = makeView(darkModeEnabled: darkModeEnabled)
661
+    }
662
+
663
+    @available(*, unavailable)
664
+    required init?(coder: NSCoder) {
665
+        nil
666
+    }
667
+
668
+    func setDarkModeEnabled(_ enabled: Bool) {
669
+        darkToggle?.state = enabled ? .on : .off
670
+    }
671
+
672
+    private func makeView(darkModeEnabled: Bool) -> NSView {
673
+        let root = NSView()
674
+        root.translatesAutoresizingMaskIntoConstraints = false
675
+
676
+        let card = roundedCard()
677
+        root.addSubview(card)
678
+        NSLayoutConstraint.activate([
679
+            card.leadingAnchor.constraint(equalTo: root.leadingAnchor),
680
+            card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
681
+            card.topAnchor.constraint(equalTo: root.topAnchor),
682
+            card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
683
+            root.widthAnchor.constraint(equalToConstant: 260)
684
+        ])
685
+
686
+        let stack = NSStackView()
687
+        stack.translatesAutoresizingMaskIntoConstraints = false
688
+        stack.orientation = .vertical
689
+        stack.spacing = 6
690
+        stack.alignment = .leading
691
+        card.addSubview(stack)
692
+
693
+        NSLayoutConstraint.activate([
694
+            stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
695
+            stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
696
+            stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
697
+            stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14)
698
+        ])
699
+
700
+        stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
701
+        stack.addArrangedSubview(settingsActionRow(icon: "⟳", title: "Restore", action: .restore))
702
+        stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
703
+        stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
704
+        stack.addArrangedSubview(settingsActionRow(icon: "⋯", title: "More Apps", action: .moreApps))
705
+        stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
706
+
707
+        for v in stack.arrangedSubviews {
708
+            v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
709
+        }
710
+
711
+        return root
712
+    }
713
+
714
+    private func roundedCard() -> NSView {
715
+        let view = NSView()
716
+        view.translatesAutoresizingMaskIntoConstraints = false
717
+        view.wantsLayer = true
718
+        view.layer?.cornerRadius = 12
719
+        view.layer?.backgroundColor = NSColor(calibratedWhite: 0.12, alpha: 1).cgColor
720
+        view.layer?.borderColor = NSColor(calibratedWhite: 0.22, alpha: 1).cgColor
721
+        view.layer?.borderWidth = 1
722
+        view.layer?.shadowColor = NSColor.black.cgColor
723
+        view.layer?.shadowOpacity = 0.28
724
+        view.layer?.shadowOffset = CGSize(width: 0, height: -1)
725
+        view.layer?.shadowRadius = 10
726
+        return view
727
+    }
728
+
729
+    private func settingsDarkModeRow(enabled: Bool) -> NSView {
730
+        let row = RowHitTestView()
731
+        row.translatesAutoresizingMaskIntoConstraints = false
732
+        row.heightAnchor.constraint(equalToConstant: 44).isActive = true
733
+
734
+        let icon = NSTextField(labelWithString: "◐")
735
+        icon.translatesAutoresizingMaskIntoConstraints = false
736
+        icon.font = NSFont.systemFont(ofSize: 18, weight: .medium)
737
+        icon.textColor = .white
738
+
739
+        let title = NSTextField(labelWithString: "Dark Mode")
740
+        title.translatesAutoresizingMaskIntoConstraints = false
741
+        title.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
742
+        title.textColor = .white
743
+
744
+        let toggle = NSSwitch()
745
+        toggle.translatesAutoresizingMaskIntoConstraints = false
746
+        toggle.state = enabled ? .on : .off
747
+        toggle.target = self
748
+        toggle.action = #selector(darkModeToggled(_:))
749
+        darkToggle = toggle
750
+
751
+        row.addSubview(icon)
752
+        row.addSubview(title)
753
+        row.addSubview(toggle)
754
+
755
+        NSLayoutConstraint.activate([
756
+            icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
757
+            icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
758
+
759
+            title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
760
+            title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
761
+
762
+            toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
763
+            toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
764
+        ])
765
+
766
+        return row
767
+    }
768
+
769
+    private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView {
770
+        let row = RowHitTestView()
771
+        row.translatesAutoresizingMaskIntoConstraints = false
772
+        row.heightAnchor.constraint(equalToConstant: 42).isActive = true
773
+
774
+        let iconLabel = NSTextField(labelWithString: icon)
775
+        iconLabel.translatesAutoresizingMaskIntoConstraints = false
776
+        iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
777
+        iconLabel.textColor = .white
778
+
779
+        let titleLabel = NSTextField(labelWithString: title)
780
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
781
+        titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
782
+        titleLabel.textColor = .white
783
+
784
+        row.addSubview(iconLabel)
785
+        row.addSubview(titleLabel)
786
+
787
+        NSLayoutConstraint.activate([
788
+            iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
789
+            iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
790
+            titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
791
+            titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor)
792
+        ])
793
+
794
+        let click = NSClickGestureRecognizer(target: self, action: #selector(settingsActionClicked(_:)))
795
+        row.addGestureRecognizer(click)
796
+        row.identifier = NSUserInterfaceItemIdentifier(rawValue: "\(action.rawValue)")
797
+
798
+        return row
799
+    }
800
+
801
+    @objc private func darkModeToggled(_ sender: NSSwitch) {
802
+        onToggleDarkMode(sender.state == .on)
803
+    }
804
+
805
+    @objc private func settingsActionClicked(_ sender: NSClickGestureRecognizer) {
806
+        guard let view = sender.view,
807
+              let raw = Int(view.identifier?.rawValue ?? ""),
808
+              let action = SettingsAction(rawValue: raw) else { return }
809
+        onAction(action)
810
+    }
811
+}
812
+
370 813
 private extension ViewController {
371 814
     func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
372 815
         let view = NSView()
@@ -408,13 +851,16 @@ private extension ViewController {
408 851
         return field
409 852
     }
410 853
 
411
-    func sidebarItem(_ text: String, icon: String, selected: Bool, logoImageName: String? = nil, logoIconWidth: CGFloat = 18, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true) -> NSView {
412
-        let item = roundedContainer(cornerRadius: 10, color: selected ? palette.primaryBlue : .clear)
854
+    func sidebarItem(_ text: String, icon: String, page: SidebarPage, logoImageName: String? = nil, logoIconWidth: CGFloat = 18, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true, showsDisclosure: Bool = false) -> NSView {
855
+        let item = RowHitTestView()
856
+        item.wantsLayer = true
857
+        item.layer?.cornerRadius = 10
858
+        item.layer?.backgroundColor = NSColor.clear.cgColor
413 859
         item.translatesAutoresizingMaskIntoConstraints = false
414 860
         item.heightAnchor.constraint(equalToConstant: 36).isActive = true
415 861
         item.layer?.borderWidth = 0
862
+        sidebarPageByView[ObjectIdentifier(item)] = page
416 863
 
417
-        let tint = selected ? NSColor.white : palette.textSecondary
418 864
         let leadingView: NSView
419 865
         if let name = logoImageName, let logo = NSImage(named: name) {
420 866
             let imageView = NSImageView(image: logo)
@@ -422,15 +868,12 @@ private extension ViewController {
422 868
             imageView.imageScaling = .scaleProportionallyDown
423 869
             imageView.imageAlignment = .alignCenter
424 870
             imageView.isEditable = false
425
-            if logoTemplate {
426
-                imageView.contentTintColor = tint
427
-            }
428 871
             leadingView = imageView
429 872
         } else {
430
-            leadingView = textLabel(icon, font: typography.sidebarIcon, color: tint)
873
+            leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary)
431 874
         }
432 875
 
433
-        let titleLabel = textLabel(text, font: typography.sidebarItem, color: tint)
876
+        let titleLabel = textLabel(text, font: typography.sidebarItem, color: palette.textSecondary)
434 877
         titleLabel.alignment = .left
435 878
 
436 879
         item.addSubview(leadingView)
@@ -442,6 +885,21 @@ private extension ViewController {
442 885
             titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8),
443 886
             titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor)
444 887
         ]
888
+
889
+        if showsDisclosure {
890
+            let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: palette.textSecondary)
891
+            chevron.translatesAutoresizingMaskIntoConstraints = false
892
+            chevron.alignment = .right
893
+            item.addSubview(chevron)
894
+            constraints.append(contentsOf: [
895
+                chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10),
896
+                chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor),
897
+                titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8)
898
+            ])
899
+        } else {
900
+            constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10))
901
+        }
902
+
445 903
         if logoImageName != nil {
446 904
             let h = logoIconWidth * logoHeightMultiplier
447 905
             constraints.append(contentsOf: [
@@ -451,12 +909,42 @@ private extension ViewController {
451 909
         }
452 910
         NSLayoutConstraint.activate(constraints)
453 911
 
912
+        applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate)
913
+
914
+        let click = NSClickGestureRecognizer(target: self, action: #selector(sidebarItemClicked(_:)))
915
+        item.addGestureRecognizer(click)
916
+
454 917
         return item
455 918
     }
456 919
 
457
-    func topTab(_ title: String, icon: String, selected: Bool, logoImageName: String? = nil, logoPointSize: CGFloat = 26, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true) -> NSView {
458
-        let tab = roundedContainer(cornerRadius: 19, color: selected ? palette.primaryBlue : .clear)
920
+    func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool) {
921
+        let selected = (page == selectedSidebarPage)
922
+        item.layer?.backgroundColor = (selected ? palette.primaryBlue : NSColor.clear).cgColor
923
+        let tint = selected ? NSColor.white : palette.textSecondary
924
+        guard item.subviews.count >= 2 else { return }
925
+        let leading = item.subviews[0]
926
+        let title = item.subviews.first { $0 is NSTextField } as? NSTextField
927
+        title?.textColor = tint
928
+        // Optional disclosure chevron (if present) is the last text field.
929
+        if let chevron = item.subviews.last as? NSTextField, chevron !== title {
930
+            chevron.textColor = tint
931
+        }
932
+        if let imageView = leading as? NSImageView {
933
+            if logoTemplate {
934
+                imageView.contentTintColor = tint
935
+            }
936
+        } else if let iconField = leading as? NSTextField {
937
+            iconField.textColor = tint
938
+        }
939
+    }
940
+
941
+    func topTab(_ title: String, icon: String, provider: MeetingProvider, logoImageName: String? = nil, logoPointSize: CGFloat = 26, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true) -> NSView {
942
+        let tab = RowHitTestView()
943
+        tab.wantsLayer = true
944
+        tab.layer?.cornerRadius = 19
945
+        tab.layer?.backgroundColor = NSColor.clear.cgColor
459 946
         tab.translatesAutoresizingMaskIntoConstraints = false
947
+        meetingProviderByView[ObjectIdentifier(tab)] = provider
460 948
 
461 949
         let leadingView: NSView
462 950
         if let name = logoImageName, let logo = NSImage(named: name) {
@@ -492,9 +980,31 @@ private extension ViewController {
492 980
         }
493 981
         NSLayoutConstraint.activate(constraints)
494 982
 
983
+        applyTabStyle(tab, provider: provider, logoTemplate: logoTemplate)
984
+
985
+        let click = NSClickGestureRecognizer(target: self, action: #selector(meetingTabClicked(_:)))
986
+        tab.addGestureRecognizer(click)
987
+
495 988
         return tab
496 989
     }
497 990
 
991
+    func applyTabStyle(_ tab: NSView, provider: MeetingProvider, logoTemplate: Bool) {
992
+        let selected = (provider == selectedMeetingProvider)
993
+        tab.layer?.backgroundColor = (selected ? palette.primaryBlue : NSColor.clear).cgColor
994
+        guard tab.subviews.count >= 2 else { return }
995
+        let leading = tab.subviews[0]
996
+        let title = tab.subviews[1] as? NSTextField
997
+        let textColor = palette.textPrimary
998
+        title?.textColor = textColor
999
+        if let imageView = leading as? NSImageView {
1000
+            if logoTemplate {
1001
+                imageView.contentTintColor = textColor
1002
+            }
1003
+        } else if let iconField = leading as? NSTextField {
1004
+            iconField.textColor = textColor
1005
+        }
1006
+    }
1007
+
498 1008
     func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView {
499 1009
         let button = roundedContainer(cornerRadius: 9, color: color)
500 1010
         button.translatesAutoresizingMaskIntoConstraints = false