浏览代码

Add AI Companion meeting filters and refresh.

Includes today default, this/previous week, previous month, custom date range, and refresh/reset controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 1 月之前
父节点
当前提交
5f406215fd
共有 1 个文件被更改,包括 282 次插入9 次删除
  1. 282 9
      meetings_app/ViewController.swift

+ 282 - 9
meetings_app/ViewController.swift

@@ -422,6 +422,16 @@ final class ViewController: NSViewController {
422 422
         case customRange = 4
423 423
     }
424 424
 
425
+    /// Saved meeting list filters on AI Companion (uses each recording’s `endedAt` day in the current calendar).
426
+    private enum AiCompanionRecordingsFilter: Int {
427
+        case all = 0
428
+        case today = 1
429
+        case week = 2
430
+        case previousWeek = 3
431
+        case previousMonth = 4
432
+        case customRange = 5
433
+    }
434
+
425 435
     private var scheduleFilter: ScheduleFilter = .all
426 436
     private weak var scheduleDateHeadingLabel: NSTextField?
427 437
     private weak var scheduleCardsStack: NSStackView?
@@ -449,6 +459,14 @@ final class ViewController: NSViewController {
449 459
     private var googleAccountPopover: NSPopover?
450 460
     private var scheduleCachedMeetings: [ScheduledMeeting] = []
451 461
     private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
462
+    private var aiCompanionRecordingsFilter: AiCompanionRecordingsFilter = .today
463
+    private var aiCompanionFilterFromDate: Date = Calendar.current.startOfDay(for: Date())
464
+    private var aiCompanionFilterToDate: Date = Calendar.current.startOfDay(for: Date())
465
+    private var aiCompanionRecordingsRangeErrorMessage: String?
466
+    private weak var aiCompanionRecordingsFilterDropdown: NSPopUpButton?
467
+    private weak var aiCompanionFilterFromDatePicker: NSDatePicker?
468
+    private weak var aiCompanionFilterToDatePicker: NSDatePicker?
469
+    private weak var aiCompanionRecordingsRangeErrorLabel: NSTextField?
452 470
     private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
453 471
     private var activeMeetingAudioRecorder: AVAudioRecorder?
454 472
     private var activeMeetingSystemAudioStopper: (() async -> Void)?
@@ -2824,17 +2842,102 @@ private extension ViewController {
2824 2842
             contentStack.addArrangedSubview(emptyLabel)
2825 2843
             emptyLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2826 2844
         } else {
2845
+            let filterRow = NSStackView()
2846
+            filterRow.translatesAutoresizingMaskIntoConstraints = false
2847
+            filterRow.userInterfaceLayoutDirection = .leftToRight
2848
+            filterRow.orientation = .horizontal
2849
+            filterRow.alignment = .centerY
2850
+            filterRow.spacing = 18
2851
+            filterRow.distribution = .fill
2852
+
2853
+            let filterDropdown = makeAiCompanionRecordingsFilterDropdown()
2854
+            aiCompanionRecordingsFilterDropdown = filterDropdown
2855
+            filterRow.addArrangedSubview(filterDropdown)
2856
+            filterRow.setCustomSpacing(20, after: filterDropdown)
2857
+
2858
+            let (fromShell, fromPicker) = makeFilterStyleDatePicker(
2859
+                date: aiCompanionFilterFromDate,
2860
+                changeAction: #selector(aiCompanionFilterDatePickerChanged(_:))
2861
+            )
2862
+            aiCompanionFilterFromDatePicker = fromPicker
2863
+            filterRow.addArrangedSubview(fromShell)
2864
+            filterRow.setCustomSpacing(16, after: fromShell)
2865
+
2866
+            let (toShell, toPicker) = makeFilterStyleDatePicker(
2867
+                date: aiCompanionFilterToDate,
2868
+                changeAction: #selector(aiCompanionFilterDatePickerChanged(_:))
2869
+            )
2870
+            aiCompanionFilterToDatePicker = toPicker
2871
+            filterRow.addArrangedSubview(toShell)
2872
+            NSLayoutConstraint.activate([
2873
+                fromShell.widthAnchor.constraint(equalTo: toShell.widthAnchor),
2874
+                fromShell.widthAnchor.constraint(greaterThanOrEqualToConstant: 152)
2875
+            ])
2876
+
2877
+            let filterRowSpacer = NSView()
2878
+            filterRowSpacer.translatesAutoresizingMaskIntoConstraints = false
2879
+            filterRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
2880
+            filterRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2881
+            filterRow.addArrangedSubview(filterRowSpacer)
2882
+
2883
+            let applyFilterButton = makeSchedulePagePillButton(title: "Apply", action: #selector(aiCompanionRecordingsApplyPressed(_:)))
2884
+            filterRow.addArrangedSubview(applyFilterButton)
2885
+            filterRow.setCustomSpacing(22, after: applyFilterButton)
2886
+            let resetFilterButton = makeSchedulePagePillButton(title: "Reset", action: #selector(aiCompanionRecordingsResetPressed(_:)))
2887
+            filterRow.addArrangedSubview(resetFilterButton)
2888
+            filterRow.setCustomSpacing(22, after: resetFilterButton)
2889
+            filterRow.addArrangedSubview(
2890
+                makeScheduleRefreshButton(
2891
+                    action: #selector(aiCompanionRecordingsRefreshPressed(_:)),
2892
+                    accessibilityDescription: "Refresh saved meetings"
2893
+                )
2894
+            )
2895
+
2896
+            contentStack.addArrangedSubview(filterRow)
2897
+            filterRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2898
+
2899
+            let rangeError = textLabel(
2900
+                aiCompanionRecordingsRangeErrorMessage ?? "",
2901
+                font: NSFont.systemFont(ofSize: 12, weight: .semibold),
2902
+                color: .systemRed
2903
+            )
2904
+            rangeError.alignment = .left
2905
+            rangeError.isHidden = aiCompanionRecordingsRangeErrorMessage == nil
2906
+            rangeError.maximumNumberOfLines = 3
2907
+            rangeError.lineBreakMode = .byWordWrapping
2908
+            aiCompanionRecordingsRangeErrorLabel = rangeError
2909
+            contentStack.addArrangedSubview(rangeError)
2910
+            rangeError.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2911
+
2827 2912
             let previousMeetingsLabel = textLabel("Previous meetings", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: palette.textPrimary)
2828 2913
             previousMeetingsLabel.alignment = .left
2829 2914
             contentStack.addArrangedSubview(previousMeetingsLabel)
2830 2915
             previousMeetingsLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2831 2916
             contentStack.setCustomSpacing(10, after: previousMeetingsLabel)
2832 2917
 
2833
-            for recording in aiCompanionLocalRecordings {
2834
-                let card = aiCompanionMeetingCard(recording)
2835
-                contentStack.addArrangedSubview(card)
2836
-                card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2918
+            let displayedRecordings = filteredAiCompanionRecordings()
2919
+            if displayedRecordings.isEmpty {
2920
+                let emptyFiltered = textLabel(
2921
+                    aiCompanionRecordingsRangeErrorMessage == nil
2922
+                        ? "No meetings match the selected filters."
2923
+                        : "Adjust the date range and tap Apply.",
2924
+                    font: typography.fieldLabel,
2925
+                    color: palette.textMuted
2926
+                )
2927
+                emptyFiltered.alignment = .left
2928
+                emptyFiltered.maximumNumberOfLines = 2
2929
+                emptyFiltered.lineBreakMode = .byWordWrapping
2930
+                contentStack.addArrangedSubview(emptyFiltered)
2931
+                emptyFiltered.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2932
+            } else {
2933
+                for recording in displayedRecordings {
2934
+                    let card = aiCompanionMeetingCard(recording)
2935
+                    contentStack.addArrangedSubview(card)
2936
+                    card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2937
+                }
2837 2938
             }
2939
+
2940
+            refreshAiCompanionRecordingsFilterChrome()
2838 2941
         }
2839 2942
 
2840 2943
         content.addSubview(contentStack)
@@ -2859,6 +2962,119 @@ private extension ViewController {
2859 2962
         return panel
2860 2963
     }
2861 2964
 
2965
+    private func makeAiCompanionRecordingsFilterDropdown() -> NSPopUpButton {
2966
+        let button = HoverPopUpButton(frame: .zero, pullsDown: false)
2967
+        button.translatesAutoresizingMaskIntoConstraints = false
2968
+        button.autoenablesItems = false
2969
+        button.isBordered = false
2970
+        button.bezelStyle = .regularSquare
2971
+        button.wantsLayer = true
2972
+        button.layer?.cornerRadius = 8
2973
+        button.layer?.masksToBounds = true
2974
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
2975
+        button.layer?.borderColor = palette.inputBorder.cgColor
2976
+        button.layer?.borderWidth = 1
2977
+        button.font = typography.filterText
2978
+        button.contentTintColor = palette.textSecondary
2979
+        button.target = self
2980
+        button.action = #selector(aiCompanionRecordingsFilterChanged(_:))
2981
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
2982
+        button.widthAnchor.constraint(equalToConstant: 268).isActive = true
2983
+
2984
+        button.removeAllItems()
2985
+        button.addItems(withTitles: ["All", "Today", "This week", "Previous week", "Previous month", "Custom range"])
2986
+        button.selectItem(at: aiCompanionRecordingsFilter.rawValue)
2987
+        if let menu = button.menu {
2988
+            for (index, item) in menu.items.enumerated() {
2989
+                item.tag = index
2990
+            }
2991
+        }
2992
+
2993
+        let baseColor = palette.inputBackground
2994
+        let baseBorder = palette.inputBorder
2995
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
2996
+        let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
2997
+        let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
2998
+        button.onHoverChanged = { [weak button] hovering in
2999
+            button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
3000
+            button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
3001
+        }
3002
+        button.onHoverChanged?(false)
3003
+        return button
3004
+    }
3005
+
3006
+    private func aiCompanionHasValidCustomFilterRange() -> Bool {
3007
+        let calendar = Calendar.current
3008
+        let start = calendar.startOfDay(for: aiCompanionFilterFromDate)
3009
+        let end = calendar.startOfDay(for: aiCompanionFilterToDate)
3010
+        return start <= end
3011
+    }
3012
+
3013
+    private func filteredAiCompanionRecordings() -> [MeetingRecordingSummary] {
3014
+        let calendar = Calendar.current
3015
+        let now = Date()
3016
+        let recordings = aiCompanionLocalRecordings
3017
+
3018
+        func previousCalendarWeekBounds(reference: Date) -> (start: Date, end: Date)? {
3019
+            guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: reference),
3020
+                  let prevStart = calendar.date(byAdding: .weekOfYear, value: -1, to: thisWeek.start)
3021
+            else { return nil }
3022
+            return (prevStart, thisWeek.start)
3023
+        }
3024
+
3025
+        func previousCalendarMonthBounds(reference: Date) -> (start: Date, end: Date)? {
3026
+            guard let thisMonth = calendar.dateInterval(of: .month, for: reference),
3027
+                  let prevStart = calendar.date(byAdding: .month, value: -1, to: thisMonth.start)
3028
+            else { return nil }
3029
+            return (prevStart, thisMonth.start)
3030
+        }
3031
+
3032
+        switch aiCompanionRecordingsFilter {
3033
+        case .all:
3034
+            return recordings
3035
+        case .today:
3036
+            let dayStart = calendar.startOfDay(for: now)
3037
+            guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return [] }
3038
+            return recordings.filter { $0.endedAt >= dayStart && $0.endedAt < nextDay }
3039
+        case .week:
3040
+            guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: now) else { return [] }
3041
+            return recordings.filter { $0.endedAt >= thisWeek.start && $0.endedAt < thisWeek.end }
3042
+        case .previousWeek:
3043
+            guard let bounds = previousCalendarWeekBounds(reference: now) else { return [] }
3044
+            return recordings.filter { $0.endedAt >= bounds.start && $0.endedAt < bounds.end }
3045
+        case .previousMonth:
3046
+            guard let bounds = previousCalendarMonthBounds(reference: now) else { return [] }
3047
+            return recordings.filter { $0.endedAt >= bounds.start && $0.endedAt < bounds.end }
3048
+        case .customRange:
3049
+            guard aiCompanionHasValidCustomFilterRange() else { return [] }
3050
+            let start = calendar.startOfDay(for: aiCompanionFilterFromDate)
3051
+            let inclusiveEndDay = calendar.startOfDay(for: aiCompanionFilterToDate)
3052
+            guard let endExclusive = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else { return [] }
3053
+            return recordings.filter { $0.endedAt >= start && $0.endedAt < endExclusive }
3054
+        }
3055
+    }
3056
+
3057
+    private func refreshAiCompanionRecordingsFilterChrome() {
3058
+        let isCustom = aiCompanionRecordingsFilter == .customRange
3059
+        aiCompanionFilterFromDatePicker?.isEnabled = isCustom
3060
+        aiCompanionFilterToDatePicker?.isEnabled = isCustom
3061
+        let dim: CGFloat = isCustom ? 1.0 : 0.65
3062
+        aiCompanionFilterFromDatePicker?.superview?.alphaValue = dim
3063
+        aiCompanionFilterToDatePicker?.superview?.alphaValue = dim
3064
+    }
3065
+
3066
+    private func setAiCompanionRecordingsRangeError(_ message: String?) {
3067
+        aiCompanionRecordingsRangeErrorMessage = message
3068
+        aiCompanionRecordingsRangeErrorLabel?.stringValue = message ?? ""
3069
+        aiCompanionRecordingsRangeErrorLabel?.isHidden = message == nil
3070
+    }
3071
+
3072
+    private func redrawAiCompanionPageIfNeeded() {
3073
+        guard selectedSidebarPage == .aiCompanion else { return }
3074
+        pageCache[.aiCompanion] = nil
3075
+        showSidebarPage(.aiCompanion)
3076
+    }
3077
+
2862 3078
     private func aiCompanionActiveRecordingCard(session: ActiveMeetingRecordingSession) -> NSView {
2863 3079
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
2864 3080
         card.translatesAutoresizingMaskIntoConstraints = false
@@ -6191,7 +6407,7 @@ private extension ViewController {
6191 6407
     }
6192 6408
 
6193 6409
     /// Rounded shell matching `makeSchedulePageFilterDropdown` (34pt, 8pt corner, border + hover). Both pickers use the same field+stepper style inside the shell.
6194
-    private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) {
6410
+    private func makeFilterStyleDatePicker(date: Date, changeAction: Selector) -> (NSView, NSDatePicker) {
6195 6411
         let shell = HoverSurfaceView()
6196 6412
         shell.translatesAutoresizingMaskIntoConstraints = false
6197 6413
         shell.wantsLayer = true
@@ -6229,7 +6445,7 @@ private extension ViewController {
6229 6445
         picker.textColor = palette.textSecondary
6230 6446
         picker.setContentHuggingPriority(.defaultLow, for: .horizontal)
6231 6447
         picker.target = self
6232
-        picker.action = #selector(schedulePageDatePickerChanged(_:))
6448
+        picker.action = changeAction
6233 6449
 
6234 6450
         shell.addSubview(picker)
6235 6451
         NSLayoutConstraint.activate([
@@ -6242,6 +6458,10 @@ private extension ViewController {
6242 6458
         return (shell, picker)
6243 6459
     }
6244 6460
 
6461
+    private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) {
6462
+        makeFilterStyleDatePicker(date: date, changeAction: #selector(schedulePageDatePickerChanged(_:)))
6463
+    }
6464
+
6245 6465
     private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton {
6246 6466
         let button = makeSchedulePillButton(title: title)
6247 6467
         button.target = self
@@ -6475,9 +6695,12 @@ private extension ViewController {
6475 6695
         return button
6476 6696
     }
6477 6697
 
6478
-    private func makeScheduleRefreshButton() -> NSButton {
6698
+    private func makeScheduleRefreshButton(
6699
+        action: Selector = #selector(scheduleReloadButtonPressed(_:)),
6700
+        accessibilityDescription: String = "Refresh meetings"
6701
+    ) -> NSButton {
6479 6702
         let diameter: CGFloat = 30
6480
-        let button = HoverButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
6703
+        let button = HoverButton(title: "", target: self, action: action)
6481 6704
         button.translatesAutoresizingMaskIntoConstraints = false
6482 6705
         button.isBordered = false
6483 6706
         button.bezelStyle = .regularSquare
@@ -6489,7 +6712,7 @@ private extension ViewController {
6489 6712
         button.layer?.borderWidth = 1
6490 6713
         button.setButtonType(.momentaryChange)
6491 6714
         button.contentTintColor = palette.textSecondary
6492
-        button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
6715
+        button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: accessibilityDescription)
6493 6716
         button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
6494 6717
         button.imagePosition = .imageOnly
6495 6718
         button.imageScaling = .scaleProportionallyDown
@@ -8954,6 +9177,56 @@ private extension ViewController {
8954 9177
         applySchedulePageFiltersAndRender()
8955 9178
     }
8956 9179
 
9180
+    @objc private func aiCompanionRecordingsFilterChanged(_ sender: NSPopUpButton) {
9181
+        guard let selectedItem = sender.selectedItem,
9182
+              let filter = AiCompanionRecordingsFilter(rawValue: selectedItem.tag) else { return }
9183
+        aiCompanionRecordingsFilter = filter
9184
+        if filter != .customRange {
9185
+            setAiCompanionRecordingsRangeError(nil)
9186
+        }
9187
+        refreshAiCompanionRecordingsFilterChrome()
9188
+        redrawAiCompanionPageIfNeeded()
9189
+    }
9190
+
9191
+    @objc private func aiCompanionFilterDatePickerChanged(_ sender: NSDatePicker) {
9192
+        aiCompanionFilterFromDate = aiCompanionFilterFromDatePicker?.dateValue ?? aiCompanionFilterFromDate
9193
+        aiCompanionFilterToDate = aiCompanionFilterToDatePicker?.dateValue ?? aiCompanionFilterToDate
9194
+    }
9195
+
9196
+    @objc private func aiCompanionRecordingsApplyPressed(_ sender: NSButton) {
9197
+        aiCompanionFilterFromDate = aiCompanionFilterFromDatePicker?.dateValue ?? aiCompanionFilterFromDate
9198
+        aiCompanionFilterToDate = aiCompanionFilterToDatePicker?.dateValue ?? aiCompanionFilterToDate
9199
+        if let selectedItem = aiCompanionRecordingsFilterDropdown?.selectedItem,
9200
+           let filter = AiCompanionRecordingsFilter(rawValue: selectedItem.tag) {
9201
+            aiCompanionRecordingsFilter = filter
9202
+        }
9203
+        if aiCompanionRecordingsFilter == .customRange && !aiCompanionHasValidCustomFilterRange() {
9204
+            setAiCompanionRecordingsRangeError("Start date must be on or before end date.")
9205
+        } else {
9206
+            setAiCompanionRecordingsRangeError(nil)
9207
+        }
9208
+        refreshAiCompanionRecordingsFilterChrome()
9209
+        redrawAiCompanionPageIfNeeded()
9210
+    }
9211
+
9212
+    @objc private func aiCompanionRecordingsResetPressed(_ sender: NSButton) {
9213
+        aiCompanionRecordingsFilter = .today
9214
+        aiCompanionRecordingsFilterDropdown?.selectItem(at: AiCompanionRecordingsFilter.today.rawValue)
9215
+        let today = Calendar.current.startOfDay(for: Date())
9216
+        aiCompanionFilterFromDate = today
9217
+        aiCompanionFilterToDate = today
9218
+        aiCompanionFilterFromDatePicker?.dateValue = today
9219
+        aiCompanionFilterToDatePicker?.dateValue = today
9220
+        setAiCompanionRecordingsRangeError(nil)
9221
+        refreshAiCompanionRecordingsFilterChrome()
9222
+        redrawAiCompanionPageIfNeeded()
9223
+    }
9224
+
9225
+    @objc private func aiCompanionRecordingsRefreshPressed(_ sender: NSButton) {
9226
+        loadAiCompanionLocalRecordings()
9227
+        redrawAiCompanionPageIfNeeded()
9228
+    }
9229
+
8957 9230
     @objc func schedulePageAddMeetingPressed(_ sender: NSButton) {
8958 9231
         guard requireGoogleLoginForCalendarScheduling() else { return }
8959 9232