|
|
@@ -49,6 +49,12 @@ class ViewController: NSViewController {
|
|
49
|
49
|
private weak var meetingsListStack: NSStackView?
|
|
50
|
50
|
private weak var meetingsStatusLabel: NSTextField?
|
|
51
|
51
|
private weak var meetingsScrollView: NSScrollView?
|
|
|
52
|
+ private weak var homeSearchField: NSTextField?
|
|
|
53
|
+ private weak var homeSearchPill: NSView?
|
|
|
54
|
+ private var allScheduledMeetings: [ScheduledMeeting] = []
|
|
|
55
|
+ private var searchTextObserver: NSObjectProtocol?
|
|
|
56
|
+ private var searchShortcutMonitor: Any?
|
|
|
57
|
+ private var searchOutsideClickMonitor: Any?
|
|
52
|
58
|
private var clockTimer: Timer?
|
|
53
|
59
|
private var meetingsRefreshTimer: Timer?
|
|
54
|
60
|
private var isSigningIn = false
|
|
|
@@ -113,6 +119,11 @@ class ViewController: NSViewController {
|
|
113
|
119
|
meetingsRefreshTimer?.invalidate()
|
|
114
|
120
|
meetingsRefreshTimer = nil
|
|
115
|
121
|
clearMeetingsScrollObserver()
|
|
|
122
|
+ removeSearchFieldObserver()
|
|
|
123
|
+ removeSearchShortcutMonitor()
|
|
|
124
|
+ homeSearchField = nil
|
|
|
125
|
+ homeSearchPill = nil
|
|
|
126
|
+ allScheduledMeetings = []
|
|
116
|
127
|
homeView?.removeFromSuperview()
|
|
117
|
128
|
homeView = nil
|
|
118
|
129
|
isSigningIn = false
|
|
|
@@ -130,9 +141,14 @@ class ViewController: NSViewController {
|
|
130
|
141
|
private func showHomeView(profile: GoogleUserProfile?) {
|
|
131
|
142
|
loginView?.removeFromSuperview()
|
|
132
|
143
|
clearMeetingsScrollObserver()
|
|
|
144
|
+ removeSearchFieldObserver()
|
|
|
145
|
+ removeSearchShortcutMonitor()
|
|
|
146
|
+ homeSearchField = nil
|
|
|
147
|
+ homeSearchPill = nil
|
|
133
|
148
|
homeView?.removeFromSuperview()
|
|
134
|
149
|
homeView = makeHomeView(profile: profile)
|
|
135
|
150
|
if let homeView { attachToRoot(homeView) }
|
|
|
151
|
+ installSearchShortcutMonitor()
|
|
136
|
152
|
persistLoggedInState(true)
|
|
137
|
153
|
startClock()
|
|
138
|
154
|
startMeetingsAutoRefresh()
|
|
|
@@ -270,6 +286,73 @@ class ViewController: NSViewController {
|
|
270
|
286
|
meetingsScrollView = nil
|
|
271
|
287
|
}
|
|
272
|
288
|
|
|
|
289
|
+ private func removeSearchFieldObserver() {
|
|
|
290
|
+ if let searchTextObserver {
|
|
|
291
|
+ NotificationCenter.default.removeObserver(searchTextObserver)
|
|
|
292
|
+ }
|
|
|
293
|
+ searchTextObserver = nil
|
|
|
294
|
+ }
|
|
|
295
|
+
|
|
|
296
|
+ private func installSearchShortcutMonitor() {
|
|
|
297
|
+ removeSearchShortcutMonitor()
|
|
|
298
|
+ searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
|
299
|
+ guard let self else { return event }
|
|
|
300
|
+ guard event.modifierFlags.contains(.command),
|
|
|
301
|
+ event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
|
|
|
302
|
+ guard self.homeSearchField != nil else { return event }
|
|
|
303
|
+ DispatchQueue.main.async {
|
|
|
304
|
+ self.focusHomeSearchField()
|
|
|
305
|
+ }
|
|
|
306
|
+ return nil
|
|
|
307
|
+ }
|
|
|
308
|
+ searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
|
|
|
309
|
+ guard let self else { return event }
|
|
|
310
|
+ guard let window = self.view.window, event.window === window else { return event }
|
|
|
311
|
+ guard let field = self.homeSearchField else { return event }
|
|
|
312
|
+ guard self.isSearchFieldActive(field, in: window) else { return event }
|
|
|
313
|
+ let location = event.locationInWindow
|
|
|
314
|
+ let pill = self.homeSearchPill ?? field
|
|
|
315
|
+ let rectInWindow = pill.convert(pill.bounds, to: nil)
|
|
|
316
|
+ if rectInWindow.contains(location) { return event }
|
|
|
317
|
+ DispatchQueue.main.async {
|
|
|
318
|
+ window.makeFirstResponder(nil)
|
|
|
319
|
+ (field as? SearchPillTextField)?.forceClearFocusState()
|
|
|
320
|
+ self.applySearchPillFocusBorder(focused: false)
|
|
|
321
|
+ }
|
|
|
322
|
+ return event
|
|
|
323
|
+ }
|
|
|
324
|
+ }
|
|
|
325
|
+
|
|
|
326
|
+ private func removeSearchShortcutMonitor() {
|
|
|
327
|
+ if let searchShortcutMonitor {
|
|
|
328
|
+ NSEvent.removeMonitor(searchShortcutMonitor)
|
|
|
329
|
+ }
|
|
|
330
|
+ searchShortcutMonitor = nil
|
|
|
331
|
+ if let searchOutsideClickMonitor {
|
|
|
332
|
+ NSEvent.removeMonitor(searchOutsideClickMonitor)
|
|
|
333
|
+ }
|
|
|
334
|
+ searchOutsideClickMonitor = nil
|
|
|
335
|
+ }
|
|
|
336
|
+
|
|
|
337
|
+ private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
|
|
|
338
|
+ guard let fr = window.firstResponder else { return false }
|
|
|
339
|
+ if fr === field { return true }
|
|
|
340
|
+ if let editor = field.currentEditor(), fr === editor { return true }
|
|
|
341
|
+ return false
|
|
|
342
|
+ }
|
|
|
343
|
+
|
|
|
344
|
+ @MainActor
|
|
|
345
|
+ private func applySearchPillFocusBorder(focused: Bool) {
|
|
|
346
|
+ homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
|
|
|
347
|
+ homeSearchPill?.layer?.borderColor = accentBlue.cgColor
|
|
|
348
|
+ }
|
|
|
349
|
+
|
|
|
350
|
+ @MainActor
|
|
|
351
|
+ private func focusHomeSearchField() {
|
|
|
352
|
+ guard let field = homeSearchField else { return }
|
|
|
353
|
+ view.window?.makeFirstResponder(field)
|
|
|
354
|
+ }
|
|
|
355
|
+
|
|
273
|
356
|
private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
|
|
274
|
357
|
clearMeetingsScrollObserver()
|
|
275
|
358
|
meetingsScrollView = scrollView
|
|
|
@@ -345,16 +428,41 @@ class ViewController: NSViewController {
|
|
345
|
428
|
|
|
346
|
429
|
@MainActor
|
|
347
|
430
|
private func applyMeetings(_ meetings: [ScheduledMeeting]) {
|
|
|
431
|
+ allScheduledMeetings = meetings
|
|
|
432
|
+ applyFilteredMeetings()
|
|
|
433
|
+ }
|
|
|
434
|
+
|
|
|
435
|
+ @MainActor
|
|
|
436
|
+ private func applyFilteredMeetings() {
|
|
348
|
437
|
guard let stack = meetingsListStack else { return }
|
|
349
|
438
|
stack.arrangedSubviews.forEach { view in
|
|
350
|
439
|
stack.removeArrangedSubview(view)
|
|
351
|
440
|
view.removeFromSuperview()
|
|
352
|
441
|
}
|
|
353
|
442
|
|
|
354
|
|
- let ordered = meetings.sorted(by: { $0.start < $1.start })
|
|
|
443
|
+ let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
444
|
+ let source = allScheduledMeetings
|
|
|
445
|
+ let filtered: [ScheduledMeeting]
|
|
|
446
|
+ if query.isEmpty {
|
|
|
447
|
+ filtered = source
|
|
|
448
|
+ } else {
|
|
|
449
|
+ filtered = source.filter { meeting in
|
|
|
450
|
+ meeting.title.lowercased().contains(query)
|
|
|
451
|
+ || meeting.host.lowercased().contains(query)
|
|
|
452
|
+ || meeting.source.lowercased().contains(query)
|
|
|
453
|
+ }
|
|
|
454
|
+ }
|
|
|
455
|
+
|
|
|
456
|
+ let ordered = filtered.sorted(by: { $0.start < $1.start })
|
|
355
|
457
|
if ordered.isEmpty {
|
|
356
|
458
|
emptyMeetingLabel?.isHidden = false
|
|
357
|
|
- meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
|
|
|
459
|
+ if source.isEmpty {
|
|
|
460
|
+ meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
|
|
|
461
|
+ emptyMeetingLabel?.stringValue = "No meetings scheduled for today."
|
|
|
462
|
+ } else {
|
|
|
463
|
+ meetingsStatusLabel?.stringValue = "Zoom meetings"
|
|
|
464
|
+ emptyMeetingLabel?.stringValue = "No meetings match your search."
|
|
|
465
|
+ }
|
|
358
|
466
|
return
|
|
359
|
467
|
}
|
|
360
|
468
|
|
|
|
@@ -743,8 +851,39 @@ class ViewController: NSViewController {
|
|
743
|
851
|
searchPill.wantsLayer = true
|
|
744
|
852
|
searchPill.layer?.backgroundColor = searchPillBackground.cgColor
|
|
745
|
853
|
searchPill.layer?.cornerRadius = 10
|
|
746
|
|
- let search = makeLabel("Search (\u{2318}E)", size: 13, color: mutedText, weight: .regular, centered: true)
|
|
747
|
|
-
|
|
|
854
|
+ searchPill.layer?.borderWidth = 0
|
|
|
855
|
+ let searchIcon = NSImageView()
|
|
|
856
|
+ searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
|
|
|
857
|
+ searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
|
|
|
858
|
+ searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
|
|
|
859
|
+ searchIcon.imageScaling = .scaleProportionallyUpOrDown
|
|
|
860
|
+ let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
|
|
|
861
|
+ searchHintLabel.isHidden = false
|
|
|
862
|
+ let searchField = SearchPillTextField()
|
|
|
863
|
+ searchField.isBordered = false
|
|
|
864
|
+ searchField.drawsBackground = false
|
|
|
865
|
+ searchField.backgroundColor = .clear
|
|
|
866
|
+ searchField.focusRingType = .none
|
|
|
867
|
+ searchField.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
868
|
+ searchField.textColor = primaryText
|
|
|
869
|
+ searchField.alignment = .left
|
|
|
870
|
+ searchField.placeholderString = nil
|
|
|
871
|
+ if let cell = searchField.cell as? NSTextFieldCell {
|
|
|
872
|
+ cell.isBezeled = false
|
|
|
873
|
+ cell.isBordered = false
|
|
|
874
|
+ cell.backgroundColor = .clear
|
|
|
875
|
+ }
|
|
|
876
|
+ let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
|
|
|
877
|
+ guard let searchField, let searchHintLabel else { return }
|
|
|
878
|
+ let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
|
|
|
879
|
+ searchHintLabel.isHidden = shouldShow == false
|
|
|
880
|
+ }
|
|
|
881
|
+ searchField.onFocusChange = { [weak self] focused in
|
|
|
882
|
+ self?.applySearchPillFocusBorder(focused: focused)
|
|
|
883
|
+ updateSearchHintVisibility()
|
|
|
884
|
+ }
|
|
|
885
|
+ updateSearchHintVisibility()
|
|
|
886
|
+
|
|
748
|
887
|
let backForwardCluster = NSStackView()
|
|
749
|
888
|
backForwardCluster.orientation = .horizontal
|
|
750
|
889
|
backForwardCluster.spacing = 4
|
|
|
@@ -840,15 +979,18 @@ class ViewController: NSViewController {
|
|
840
|
979
|
content.addSubview(topBarDivider)
|
|
841
|
980
|
content.addSubview(contentColumn)
|
|
842
|
981
|
|
|
843
|
|
- [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, search].forEach {
|
|
|
982
|
+ [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
|
|
844
|
983
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
845
|
984
|
}
|
|
846
|
985
|
[brandStack].forEach {
|
|
847
|
986
|
shell.addSubview($0)
|
|
848
|
987
|
}
|
|
849
|
|
- [searchRow, rightTopBarCluster, search].forEach {
|
|
|
988
|
+ [searchRow, rightTopBarCluster].forEach {
|
|
850
|
989
|
topBar.addSubview($0)
|
|
851
|
990
|
}
|
|
|
991
|
+ [searchIcon, searchField, searchHintLabel].forEach {
|
|
|
992
|
+ searchPill.addSubview($0)
|
|
|
993
|
+ }
|
|
852
|
994
|
[welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
|
|
853
|
995
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
854
|
996
|
contentColumn.addSubview($0)
|
|
|
@@ -893,9 +1035,16 @@ class ViewController: NSViewController {
|
|
893
|
1035
|
|
|
894
|
1036
|
searchPill.heightAnchor.constraint(equalToConstant: 32),
|
|
895
|
1037
|
searchPill.widthAnchor.constraint(equalToConstant: 320),
|
|
896
|
|
- search.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
|
|
897
|
|
- search.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -12),
|
|
898
|
|
- search.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
1038
|
+ searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
|
|
|
1039
|
+ searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
1040
|
+ searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
|
|
1041
|
+ searchIcon.heightAnchor.constraint(equalToConstant: 16),
|
|
|
1042
|
+ searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
|
|
|
1043
|
+ searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
|
1044
|
+ searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
|
|
|
1045
|
+ searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
|
|
|
1046
|
+ searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
|
|
|
1047
|
+ searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
|
|
899
|
1048
|
profileChip.widthAnchor.constraint(equalToConstant: 34),
|
|
900
|
1049
|
profileChip.heightAnchor.constraint(equalToConstant: 34),
|
|
901
|
1050
|
|
|
|
@@ -953,9 +1102,26 @@ class ViewController: NSViewController {
|
|
953
|
1102
|
emptyMeetingLabel = noMeeting
|
|
954
|
1103
|
observeMeetingsScrollEdges(in: meetingsScrollView)
|
|
955
|
1104
|
updateClock()
|
|
|
1105
|
+
|
|
|
1106
|
+ homeSearchField = searchField
|
|
|
1107
|
+ homeSearchPill = searchPill
|
|
|
1108
|
+ searchTextObserver = NotificationCenter.default.addObserver(
|
|
|
1109
|
+ forName: NSControl.textDidChangeNotification,
|
|
|
1110
|
+ object: searchField,
|
|
|
1111
|
+ queue: .main
|
|
|
1112
|
+ ) { [weak self] _ in
|
|
|
1113
|
+ self?.applyFilteredMeetings()
|
|
|
1114
|
+ updateSearchHintVisibility()
|
|
|
1115
|
+ }
|
|
|
1116
|
+
|
|
956
|
1117
|
return root
|
|
957
|
1118
|
}
|
|
958
|
1119
|
|
|
|
1120
|
+ deinit {
|
|
|
1121
|
+ removeSearchFieldObserver()
|
|
|
1122
|
+ removeSearchShortcutMonitor()
|
|
|
1123
|
+ }
|
|
|
1124
|
+
|
|
959
|
1125
|
private func startClock() {
|
|
960
|
1126
|
clockTimer?.invalidate()
|
|
961
|
1127
|
clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
|
@@ -1234,6 +1400,34 @@ class ViewController: NSViewController {
|
|
1234
|
1400
|
}
|
|
1235
|
1401
|
}
|
|
1236
|
1402
|
|
|
|
1403
|
+private final class SearchPillTextField: NSTextField {
|
|
|
1404
|
+ var onFocusChange: ((Bool) -> Void)?
|
|
|
1405
|
+ private(set) var isSearchFocused = false
|
|
|
1406
|
+
|
|
|
1407
|
+ func forceClearFocusState() {
|
|
|
1408
|
+ isSearchFocused = false
|
|
|
1409
|
+ onFocusChange?(false)
|
|
|
1410
|
+ }
|
|
|
1411
|
+
|
|
|
1412
|
+ override func becomeFirstResponder() -> Bool {
|
|
|
1413
|
+ let ok = super.becomeFirstResponder()
|
|
|
1414
|
+ if ok {
|
|
|
1415
|
+ isSearchFocused = true
|
|
|
1416
|
+ onFocusChange?(true)
|
|
|
1417
|
+ }
|
|
|
1418
|
+ return ok
|
|
|
1419
|
+ }
|
|
|
1420
|
+
|
|
|
1421
|
+ override func resignFirstResponder() -> Bool {
|
|
|
1422
|
+ let ok = super.resignFirstResponder()
|
|
|
1423
|
+ if ok {
|
|
|
1424
|
+ isSearchFocused = false
|
|
|
1425
|
+ onFocusChange?(false)
|
|
|
1426
|
+ }
|
|
|
1427
|
+ return ok
|
|
|
1428
|
+ }
|
|
|
1429
|
+}
|
|
|
1430
|
+
|
|
1237
|
1431
|
struct GoogleOAuthTokens: Codable, Equatable {
|
|
1238
|
1432
|
var accessToken: String
|
|
1239
|
1433
|
var refreshToken: String?
|