Kaynağa Gözat

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 2 ay önce
ebeveyn
işleme
5f406215fd
1 değiştirilmiş dosya ile 282 ekleme ve 9 silme
  1. 282 9
      meetings_app/ViewController.swift

+ 282 - 9
meetings_app/ViewController.swift

@@ -422,6 +422,16 @@ final class ViewController: NSViewController {
         case customRange = 4
     }
 
+    /// Saved meeting list filters on AI Companion (uses each recording’s `endedAt` day in the current calendar).
+    private enum AiCompanionRecordingsFilter: Int {
+        case all = 0
+        case today = 1
+        case week = 2
+        case previousWeek = 3
+        case previousMonth = 4
+        case customRange = 5
+    }
+
     private var scheduleFilter: ScheduleFilter = .all
     private weak var scheduleDateHeadingLabel: NSTextField?
     private weak var scheduleCardsStack: NSStackView?
@@ -449,6 +459,14 @@ final class ViewController: NSViewController {
     private var googleAccountPopover: NSPopover?
     private var scheduleCachedMeetings: [ScheduledMeeting] = []
     private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
+    private var aiCompanionRecordingsFilter: AiCompanionRecordingsFilter = .today
+    private var aiCompanionFilterFromDate: Date = Calendar.current.startOfDay(for: Date())
+    private var aiCompanionFilterToDate: Date = Calendar.current.startOfDay(for: Date())
+    private var aiCompanionRecordingsRangeErrorMessage: String?
+    private weak var aiCompanionRecordingsFilterDropdown: NSPopUpButton?
+    private weak var aiCompanionFilterFromDatePicker: NSDatePicker?
+    private weak var aiCompanionFilterToDatePicker: NSDatePicker?
+    private weak var aiCompanionRecordingsRangeErrorLabel: NSTextField?
     private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
     private var activeMeetingAudioRecorder: AVAudioRecorder?
     private var activeMeetingSystemAudioStopper: (() async -> Void)?
@@ -2824,17 +2842,102 @@ private extension ViewController {
             contentStack.addArrangedSubview(emptyLabel)
             emptyLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
         } else {
+            let filterRow = NSStackView()
+            filterRow.translatesAutoresizingMaskIntoConstraints = false
+            filterRow.userInterfaceLayoutDirection = .leftToRight
+            filterRow.orientation = .horizontal
+            filterRow.alignment = .centerY
+            filterRow.spacing = 18
+            filterRow.distribution = .fill
+
+            let filterDropdown = makeAiCompanionRecordingsFilterDropdown()
+            aiCompanionRecordingsFilterDropdown = filterDropdown
+            filterRow.addArrangedSubview(filterDropdown)
+            filterRow.setCustomSpacing(20, after: filterDropdown)
+
+            let (fromShell, fromPicker) = makeFilterStyleDatePicker(
+                date: aiCompanionFilterFromDate,
+                changeAction: #selector(aiCompanionFilterDatePickerChanged(_:))
+            )
+            aiCompanionFilterFromDatePicker = fromPicker
+            filterRow.addArrangedSubview(fromShell)
+            filterRow.setCustomSpacing(16, after: fromShell)
+
+            let (toShell, toPicker) = makeFilterStyleDatePicker(
+                date: aiCompanionFilterToDate,
+                changeAction: #selector(aiCompanionFilterDatePickerChanged(_:))
+            )
+            aiCompanionFilterToDatePicker = toPicker
+            filterRow.addArrangedSubview(toShell)
+            NSLayoutConstraint.activate([
+                fromShell.widthAnchor.constraint(equalTo: toShell.widthAnchor),
+                fromShell.widthAnchor.constraint(greaterThanOrEqualToConstant: 152)
+            ])
+
+            let filterRowSpacer = NSView()
+            filterRowSpacer.translatesAutoresizingMaskIntoConstraints = false
+            filterRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+            filterRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+            filterRow.addArrangedSubview(filterRowSpacer)
+
+            let applyFilterButton = makeSchedulePagePillButton(title: "Apply", action: #selector(aiCompanionRecordingsApplyPressed(_:)))
+            filterRow.addArrangedSubview(applyFilterButton)
+            filterRow.setCustomSpacing(22, after: applyFilterButton)
+            let resetFilterButton = makeSchedulePagePillButton(title: "Reset", action: #selector(aiCompanionRecordingsResetPressed(_:)))
+            filterRow.addArrangedSubview(resetFilterButton)
+            filterRow.setCustomSpacing(22, after: resetFilterButton)
+            filterRow.addArrangedSubview(
+                makeScheduleRefreshButton(
+                    action: #selector(aiCompanionRecordingsRefreshPressed(_:)),
+                    accessibilityDescription: "Refresh saved meetings"
+                )
+            )
+
+            contentStack.addArrangedSubview(filterRow)
+            filterRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+
+            let rangeError = textLabel(
+                aiCompanionRecordingsRangeErrorMessage ?? "",
+                font: NSFont.systemFont(ofSize: 12, weight: .semibold),
+                color: .systemRed
+            )
+            rangeError.alignment = .left
+            rangeError.isHidden = aiCompanionRecordingsRangeErrorMessage == nil
+            rangeError.maximumNumberOfLines = 3
+            rangeError.lineBreakMode = .byWordWrapping
+            aiCompanionRecordingsRangeErrorLabel = rangeError
+            contentStack.addArrangedSubview(rangeError)
+            rangeError.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+
             let previousMeetingsLabel = textLabel("Previous meetings", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: palette.textPrimary)
             previousMeetingsLabel.alignment = .left
             contentStack.addArrangedSubview(previousMeetingsLabel)
             previousMeetingsLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
             contentStack.setCustomSpacing(10, after: previousMeetingsLabel)
 
-            for recording in aiCompanionLocalRecordings {
-                let card = aiCompanionMeetingCard(recording)
-                contentStack.addArrangedSubview(card)
-                card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+            let displayedRecordings = filteredAiCompanionRecordings()
+            if displayedRecordings.isEmpty {
+                let emptyFiltered = textLabel(
+                    aiCompanionRecordingsRangeErrorMessage == nil
+                        ? "No meetings match the selected filters."
+                        : "Adjust the date range and tap Apply.",
+                    font: typography.fieldLabel,
+                    color: palette.textMuted
+                )
+                emptyFiltered.alignment = .left
+                emptyFiltered.maximumNumberOfLines = 2
+                emptyFiltered.lineBreakMode = .byWordWrapping
+                contentStack.addArrangedSubview(emptyFiltered)
+                emptyFiltered.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+            } else {
+                for recording in displayedRecordings {
+                    let card = aiCompanionMeetingCard(recording)
+                    contentStack.addArrangedSubview(card)
+                    card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+                }
             }
+
+            refreshAiCompanionRecordingsFilterChrome()
         }
 
         content.addSubview(contentStack)
@@ -2859,6 +2962,119 @@ private extension ViewController {
         return panel
     }
 
+    private func makeAiCompanionRecordingsFilterDropdown() -> NSPopUpButton {
+        let button = HoverPopUpButton(frame: .zero, pullsDown: false)
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.autoenablesItems = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 8
+        button.layer?.masksToBounds = true
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
+        button.layer?.borderColor = palette.inputBorder.cgColor
+        button.layer?.borderWidth = 1
+        button.font = typography.filterText
+        button.contentTintColor = palette.textSecondary
+        button.target = self
+        button.action = #selector(aiCompanionRecordingsFilterChanged(_:))
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
+        button.widthAnchor.constraint(equalToConstant: 268).isActive = true
+
+        button.removeAllItems()
+        button.addItems(withTitles: ["All", "Today", "This week", "Previous week", "Previous month", "Custom range"])
+        button.selectItem(at: aiCompanionRecordingsFilter.rawValue)
+        if let menu = button.menu {
+            for (index, item) in menu.items.enumerated() {
+                item.tag = index
+            }
+        }
+
+        let baseColor = palette.inputBackground
+        let baseBorder = palette.inputBorder
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
+        let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
+        let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
+        button.onHoverChanged = { [weak button] hovering in
+            button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
+            button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
+        }
+        button.onHoverChanged?(false)
+        return button
+    }
+
+    private func aiCompanionHasValidCustomFilterRange() -> Bool {
+        let calendar = Calendar.current
+        let start = calendar.startOfDay(for: aiCompanionFilterFromDate)
+        let end = calendar.startOfDay(for: aiCompanionFilterToDate)
+        return start <= end
+    }
+
+    private func filteredAiCompanionRecordings() -> [MeetingRecordingSummary] {
+        let calendar = Calendar.current
+        let now = Date()
+        let recordings = aiCompanionLocalRecordings
+
+        func previousCalendarWeekBounds(reference: Date) -> (start: Date, end: Date)? {
+            guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: reference),
+                  let prevStart = calendar.date(byAdding: .weekOfYear, value: -1, to: thisWeek.start)
+            else { return nil }
+            return (prevStart, thisWeek.start)
+        }
+
+        func previousCalendarMonthBounds(reference: Date) -> (start: Date, end: Date)? {
+            guard let thisMonth = calendar.dateInterval(of: .month, for: reference),
+                  let prevStart = calendar.date(byAdding: .month, value: -1, to: thisMonth.start)
+            else { return nil }
+            return (prevStart, thisMonth.start)
+        }
+
+        switch aiCompanionRecordingsFilter {
+        case .all:
+            return recordings
+        case .today:
+            let dayStart = calendar.startOfDay(for: now)
+            guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return [] }
+            return recordings.filter { $0.endedAt >= dayStart && $0.endedAt < nextDay }
+        case .week:
+            guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: now) else { return [] }
+            return recordings.filter { $0.endedAt >= thisWeek.start && $0.endedAt < thisWeek.end }
+        case .previousWeek:
+            guard let bounds = previousCalendarWeekBounds(reference: now) else { return [] }
+            return recordings.filter { $0.endedAt >= bounds.start && $0.endedAt < bounds.end }
+        case .previousMonth:
+            guard let bounds = previousCalendarMonthBounds(reference: now) else { return [] }
+            return recordings.filter { $0.endedAt >= bounds.start && $0.endedAt < bounds.end }
+        case .customRange:
+            guard aiCompanionHasValidCustomFilterRange() else { return [] }
+            let start = calendar.startOfDay(for: aiCompanionFilterFromDate)
+            let inclusiveEndDay = calendar.startOfDay(for: aiCompanionFilterToDate)
+            guard let endExclusive = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else { return [] }
+            return recordings.filter { $0.endedAt >= start && $0.endedAt < endExclusive }
+        }
+    }
+
+    private func refreshAiCompanionRecordingsFilterChrome() {
+        let isCustom = aiCompanionRecordingsFilter == .customRange
+        aiCompanionFilterFromDatePicker?.isEnabled = isCustom
+        aiCompanionFilterToDatePicker?.isEnabled = isCustom
+        let dim: CGFloat = isCustom ? 1.0 : 0.65
+        aiCompanionFilterFromDatePicker?.superview?.alphaValue = dim
+        aiCompanionFilterToDatePicker?.superview?.alphaValue = dim
+    }
+
+    private func setAiCompanionRecordingsRangeError(_ message: String?) {
+        aiCompanionRecordingsRangeErrorMessage = message
+        aiCompanionRecordingsRangeErrorLabel?.stringValue = message ?? ""
+        aiCompanionRecordingsRangeErrorLabel?.isHidden = message == nil
+    }
+
+    private func redrawAiCompanionPageIfNeeded() {
+        guard selectedSidebarPage == .aiCompanion else { return }
+        pageCache[.aiCompanion] = nil
+        showSidebarPage(.aiCompanion)
+    }
+
     private func aiCompanionActiveRecordingCard(session: ActiveMeetingRecordingSession) -> NSView {
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
         card.translatesAutoresizingMaskIntoConstraints = false
@@ -6191,7 +6407,7 @@ private extension ViewController {
     }
 
     /// Rounded shell matching `makeSchedulePageFilterDropdown` (34pt, 8pt corner, border + hover). Both pickers use the same field+stepper style inside the shell.
-    private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) {
+    private func makeFilterStyleDatePicker(date: Date, changeAction: Selector) -> (NSView, NSDatePicker) {
         let shell = HoverSurfaceView()
         shell.translatesAutoresizingMaskIntoConstraints = false
         shell.wantsLayer = true
@@ -6229,7 +6445,7 @@ private extension ViewController {
         picker.textColor = palette.textSecondary
         picker.setContentHuggingPriority(.defaultLow, for: .horizontal)
         picker.target = self
-        picker.action = #selector(schedulePageDatePickerChanged(_:))
+        picker.action = changeAction
 
         shell.addSubview(picker)
         NSLayoutConstraint.activate([
@@ -6242,6 +6458,10 @@ private extension ViewController {
         return (shell, picker)
     }
 
+    private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) {
+        makeFilterStyleDatePicker(date: date, changeAction: #selector(schedulePageDatePickerChanged(_:)))
+    }
+
     private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton {
         let button = makeSchedulePillButton(title: title)
         button.target = self
@@ -6475,9 +6695,12 @@ private extension ViewController {
         return button
     }
 
-    private func makeScheduleRefreshButton() -> NSButton {
+    private func makeScheduleRefreshButton(
+        action: Selector = #selector(scheduleReloadButtonPressed(_:)),
+        accessibilityDescription: String = "Refresh meetings"
+    ) -> NSButton {
         let diameter: CGFloat = 30
-        let button = HoverButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
+        let button = HoverButton(title: "", target: self, action: action)
         button.translatesAutoresizingMaskIntoConstraints = false
         button.isBordered = false
         button.bezelStyle = .regularSquare
@@ -6489,7 +6712,7 @@ private extension ViewController {
         button.layer?.borderWidth = 1
         button.setButtonType(.momentaryChange)
         button.contentTintColor = palette.textSecondary
-        button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
+        button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: accessibilityDescription)
         button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
         button.imagePosition = .imageOnly
         button.imageScaling = .scaleProportionallyDown
@@ -8954,6 +9177,56 @@ private extension ViewController {
         applySchedulePageFiltersAndRender()
     }
 
+    @objc private func aiCompanionRecordingsFilterChanged(_ sender: NSPopUpButton) {
+        guard let selectedItem = sender.selectedItem,
+              let filter = AiCompanionRecordingsFilter(rawValue: selectedItem.tag) else { return }
+        aiCompanionRecordingsFilter = filter
+        if filter != .customRange {
+            setAiCompanionRecordingsRangeError(nil)
+        }
+        refreshAiCompanionRecordingsFilterChrome()
+        redrawAiCompanionPageIfNeeded()
+    }
+
+    @objc private func aiCompanionFilterDatePickerChanged(_ sender: NSDatePicker) {
+        aiCompanionFilterFromDate = aiCompanionFilterFromDatePicker?.dateValue ?? aiCompanionFilterFromDate
+        aiCompanionFilterToDate = aiCompanionFilterToDatePicker?.dateValue ?? aiCompanionFilterToDate
+    }
+
+    @objc private func aiCompanionRecordingsApplyPressed(_ sender: NSButton) {
+        aiCompanionFilterFromDate = aiCompanionFilterFromDatePicker?.dateValue ?? aiCompanionFilterFromDate
+        aiCompanionFilterToDate = aiCompanionFilterToDatePicker?.dateValue ?? aiCompanionFilterToDate
+        if let selectedItem = aiCompanionRecordingsFilterDropdown?.selectedItem,
+           let filter = AiCompanionRecordingsFilter(rawValue: selectedItem.tag) {
+            aiCompanionRecordingsFilter = filter
+        }
+        if aiCompanionRecordingsFilter == .customRange && !aiCompanionHasValidCustomFilterRange() {
+            setAiCompanionRecordingsRangeError("Start date must be on or before end date.")
+        } else {
+            setAiCompanionRecordingsRangeError(nil)
+        }
+        refreshAiCompanionRecordingsFilterChrome()
+        redrawAiCompanionPageIfNeeded()
+    }
+
+    @objc private func aiCompanionRecordingsResetPressed(_ sender: NSButton) {
+        aiCompanionRecordingsFilter = .today
+        aiCompanionRecordingsFilterDropdown?.selectItem(at: AiCompanionRecordingsFilter.today.rawValue)
+        let today = Calendar.current.startOfDay(for: Date())
+        aiCompanionFilterFromDate = today
+        aiCompanionFilterToDate = today
+        aiCompanionFilterFromDatePicker?.dateValue = today
+        aiCompanionFilterToDatePicker?.dateValue = today
+        setAiCompanionRecordingsRangeError(nil)
+        refreshAiCompanionRecordingsFilterChrome()
+        redrawAiCompanionPageIfNeeded()
+    }
+
+    @objc private func aiCompanionRecordingsRefreshPressed(_ sender: NSButton) {
+        loadAiCompanionLocalRecordings()
+        redrawAiCompanionPageIfNeeded()
+    }
+
     @objc func schedulePageAddMeetingPressed(_ sender: NSButton) {
         guard requireGoogleLoginForCalendarScheduling() else { return }