Parcourir la source

Improve schedule cards layout and scrolling behavior.

Upgrade card visuals with richer metadata, load more calendar events with pagination, and keep a fixed three-card viewport with working left/right controls for horizontal browsing.

Made-with: Cursor
huzaifahayat12 il y a 3 mois
Parent
commit
cb234b99df
2 fichiers modifiés avec 197 ajouts et 58 suppressions
  1. 53 35
      meetings_app/Google/GoogleCalendarClient.swift
  2. 144 23
      meetings_app/ViewController.swift

+ 53 - 35
meetings_app/Google/GoogleCalendarClient.swift

@@ -12,7 +12,7 @@ final class GoogleCalendarClient {
         var maxResults: Int
         var includeNonMeetEvents: Bool
 
-        init(daysAhead: Int = 7, maxResults: Int = 25, includeNonMeetEvents: Bool = false) {
+        init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
             self.daysAhead = daysAhead
             self.maxResults = maxResults
             self.includeNonMeetEvents = includeNonMeetEvents
@@ -31,41 +31,49 @@ final class GoogleCalendarClient {
 
     func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
         let now = Date()
-        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(7 * 24 * 60 * 60)
-
-        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
+        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60)
         let formatter = ISO8601DateFormatter()
         formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
-        components.queryItems = [
-            URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
-            URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
-            URLQueryItem(name: "singleEvents", value: "true"),
-            URLQueryItem(name: "orderBy", value: "startTime"),
-            URLQueryItem(name: "maxResults", value: String(options.maxResults)),
-            URLQueryItem(name: "conferenceDataVersion", value: "1")
-        ]
-
-        var request = URLRequest(url: components.url!)
-        request.httpMethod = "GET"
-        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
-
-        let (data, response) = try await session.data(for: request)
-        guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
-        guard (200..<300).contains(http.statusCode) else {
-            let body = String(data: data, encoding: .utf8) ?? "<no body>"
-            throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
-        }
-
-        let decoded: EventsList
-        do {
-            decoded = try JSONDecoder().decode(EventsList.self, from: data)
-        } catch {
-            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
-            throw GoogleCalendarClientError.decodeFailed(raw)
-        }
-
-        let meetings: [ScheduledMeeting] = decoded.items.compactMap { item in
+        let totalLimit = max(1, options.maxResults)
+        let pageSize = min(250, totalLimit)
+        var nextPageToken: String?
+        var meetings: [ScheduledMeeting] = []
+
+        repeat {
+            var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
+            var queryItems = [
+                URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
+                URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
+                URLQueryItem(name: "singleEvents", value: "true"),
+                URLQueryItem(name: "orderBy", value: "startTime"),
+                URLQueryItem(name: "maxResults", value: String(pageSize)),
+                URLQueryItem(name: "conferenceDataVersion", value: "1")
+            ]
+            if let nextPageToken {
+                queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
+            }
+            components.queryItems = queryItems
+
+            var request = URLRequest(url: components.url!)
+            request.httpMethod = "GET"
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+            let (data, response) = try await session.data(for: request)
+            guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
+            guard (200..<300).contains(http.statusCode) else {
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
+                throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
+            }
+
+            let decoded: EventsList
+            do {
+                decoded = try JSONDecoder().decode(EventsList.self, from: data)
+            } catch {
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+                throw GoogleCalendarClientError.decodeFailed(raw)
+            }
+
+            let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in
             let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
             let subtitle = item.organizer?.displayName ?? item.organizer?.email
 
@@ -78,6 +86,7 @@ final class GoogleCalendarClient {
                 if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
                 let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
                 if let uri = entry?.uri, let u = URL(string: uri) { return u }
+                if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u }
                 return nil
             }()
 
@@ -93,8 +102,15 @@ final class GoogleCalendarClient {
                 meetURL: meetURL,
                 isAllDay: isAllDay
             )
-        }
+            }
 
+            meetings.append(contentsOf: pageMeetings)
+            nextPageToken = decoded.nextPageToken
+        } while nextPageToken != nil && meetings.count < totalLimit
+
+        if meetings.count > totalLimit {
+            meetings = Array(meetings.prefix(totalLimit))
+        }
         return meetings
     }
 }
@@ -116,12 +132,14 @@ extension GoogleCalendarClientError: LocalizedError {
 
 private struct EventsList: Decodable {
     let items: [EventItem]
+    let nextPageToken: String?
 }
 
 private struct EventItem: Decodable {
     let id: String?
     let summary: String?
     let hangoutLink: String?
+    let htmlLink: String?
     let organizer: Organizer?
     let start: EventDateTime?
     let end: EventDateTime?

+ 144 - 23
meetings_app/ViewController.swift

@@ -74,6 +74,9 @@ final class ViewController: NSViewController {
     private var scheduleFilter: ScheduleFilter = .all
     private weak var scheduleDateHeadingLabel: NSTextField?
     private weak var scheduleCardsStack: NSStackView?
+    private weak var scheduleCardsScrollView: NSScrollView?
+    private weak var scheduleScrollLeftButton: NSView?
+    private weak var scheduleScrollRightButton: NSView?
     private weak var scheduleFilterDropdown: NSPopUpButton?
 
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
@@ -1883,24 +1886,40 @@ private extension ViewController {
     }
 
     func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
+        let cardWidth: CGFloat = 240
+        let cardsPerViewport: CGFloat = 3
+        let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
+
+        let wrapper = NSStackView()
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
+        wrapper.orientation = .horizontal
+        wrapper.alignment = .centerY
+        wrapper.spacing = 10
+        let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
+        scheduleScrollLeftButton = leftButton
+        wrapper.addArrangedSubview(leftButton)
+
         let scroll = NSScrollView()
+        scheduleCardsScrollView = scroll
         scroll.translatesAutoresizingMaskIntoConstraints = false
         scroll.drawsBackground = false
-        scroll.hasHorizontalScroller = true
+        scroll.hasHorizontalScroller = false
         scroll.hasVerticalScroller = false
         scroll.horizontalScrollElasticity = .allowed
         scroll.verticalScrollElasticity = .none
-        scroll.autohidesScrollers = true
+        scroll.autohidesScrollers = false
         scroll.borderType = .noBorder
+        scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
+        scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
 
         let row = NSStackView()
         row.translatesAutoresizingMaskIntoConstraints = false
         row.orientation = .horizontal
-        row.spacing = 10
+        row.spacing = 12
         row.alignment = .top
         row.distribution = .fill
         row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
-        row.heightAnchor.constraint(equalToConstant: 136).isActive = true
+        row.heightAnchor.constraint(equalToConstant: 150).isActive = true
         scheduleCardsStack = row
 
         scroll.documentView = row
@@ -1909,59 +1928,94 @@ private extension ViewController {
         // Ensure the stack view determines content size for horizontal scrolling.
         NSLayoutConstraint.activate([
             row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
-            row.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
+            row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
             row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
             row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
-            row.heightAnchor.constraint(equalToConstant: 136)
+            row.heightAnchor.constraint(equalToConstant: 150)
         ])
 
         renderScheduleCards(into: row, meetings: meetings)
-        return scroll
+        wrapper.addArrangedSubview(scroll)
+        let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
+        scheduleScrollRightButton = rightButton
+        wrapper.addArrangedSubview(rightButton)
+        scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
+        return wrapper
     }
 
     func scheduleCard(meeting: ScheduledMeeting) -> NSView {
-        let cardWidth: CGFloat = 264
+        let cardWidth: CGFloat = 240
 
-        let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
+        let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
         styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
         card.translatesAutoresizingMaskIntoConstraints = false
         card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
-        card.heightAnchor.constraint(equalToConstant: 136).isActive = true
+        card.heightAnchor.constraint(equalToConstant: 150).isActive = true
 
-        let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
+        let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
         icon.translatesAutoresizingMaskIntoConstraints = false
-        icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
-        icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
-        let iconText = textLabel("••", font: typography.cardIcon, color: .white)
-        iconText.translatesAutoresizingMaskIntoConstraints = false
-        icon.addSubview(iconText)
+        icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
+        icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
+        let iconView = NSImageView()
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
+        iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
+        iconView.contentTintColor = .white
+        icon.addSubview(iconView)
         NSLayoutConstraint.activate([
-            iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
-            iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
+            iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
+            iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
         ])
 
         let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
         let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
         let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
+        let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
+        let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
+        dayChip.translatesAutoresizingMaskIntoConstraints = false
+        dayChip.layer?.borderWidth = 1
+        dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
+        let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
+        dayText.translatesAutoresizingMaskIntoConstraints = false
+        dayChip.addSubview(dayText)
+        NSLayoutConstraint.activate([
+            dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
+            dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
+            dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
+            dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
+        ])
 
         card.addSubview(icon)
+        card.addSubview(dayChip)
         card.addSubview(title)
         card.addSubview(subtitle)
         card.addSubview(time)
+        card.addSubview(duration)
 
         NSLayoutConstraint.activate([
             icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
             icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
 
+            dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
+            dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
+
             title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
             title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
-            title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
+            title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
 
             subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
-            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
+            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
+            subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
 
             time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
-            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
+            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
+            time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
+
+            duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
+            duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
+            duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
         ])
 
         let hit = HoverTrackingView()
@@ -1987,6 +2041,27 @@ private extension ViewController {
 
         return hit
     }
+
+    private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
+        let button = NSButton(title: "", target: self, action: action)
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 16
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
+        button.layer?.borderColor = palette.inputBorder.cgColor
+        button.layer?.borderWidth = 1
+        button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
+        button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
+        button.imagePosition = .imageOnly
+        button.imageScaling = .scaleProportionallyDown
+        button.contentTintColor = palette.textSecondary
+        button.focusRingType = .none
+        button.heightAnchor.constraint(equalToConstant: 32).isActive = true
+        button.widthAnchor.constraint(equalToConstant: 32).isActive = true
+        return button
+    }
 }
 
 extension ViewController: NSTextFieldDelegate {
@@ -2538,6 +2613,14 @@ private extension ViewController {
         scheduleReloadClicked()
     }
 
+    @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
+        scrollScheduleCards(direction: -1)
+    }
+
+    @objc func scheduleScrollRightPressed(_ sender: NSButton) {
+        scrollScheduleCards(direction: 1)
+    }
+
     @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
         scheduleConnectClicked()
     }
@@ -2570,6 +2653,25 @@ private extension ViewController {
         return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
     }
 
+    private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
+        let f = DateFormatter()
+        f.locale = Locale.current
+        f.timeZone = TimeZone.current
+        f.dateFormat = "EEE, d MMM"
+        return f.string(from: meeting.startDate)
+    }
+
+    private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
+        if meeting.isAllDay { return "Duration: all day" }
+        let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
+        let totalMinutes = Int(duration / 60)
+        let hours = totalMinutes / 60
+        let minutes = totalMinutes % 60
+        if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
+        if hours > 0 { return "Duration: \(hours)h" }
+        return "Duration: \(minutes)m"
+    }
+
     private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
         guard let first = meetings.first else {
             return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
@@ -2588,6 +2690,14 @@ private extension ViewController {
     }
 
     private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
+        let shouldShowScrollControls = meetings.count > 3
+        scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
+        scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
+        scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
+        if let scroll = scheduleCardsScrollView {
+            scroll.reflectScrolledClipView(scroll.contentView)
+        }
+
         stack.arrangedSubviews.forEach { v in
             stack.removeArrangedSubview(v)
             v.removeFromSuperview()
@@ -2596,8 +2706,8 @@ private extension ViewController {
         if meetings.isEmpty {
             let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
             empty.translatesAutoresizingMaskIntoConstraints = false
-            empty.widthAnchor.constraint(equalToConstant: 264).isActive = true
-            empty.heightAnchor.constraint(equalToConstant: 136).isActive = true
+            empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
+            empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
 
             let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
@@ -2631,6 +2741,17 @@ private extension ViewController {
         }
     }
 
+    private func scrollScheduleCards(direction: Int) {
+        guard let scroll = scheduleCardsScrollView else { return }
+        let contentBounds = scroll.contentView.bounds
+        let step = max(220, contentBounds.width * 0.7)
+        let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
+        let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
+        let nextX = min(max(0, proposedX), maxX)
+        scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
+        scroll.reflectScrolledClipView(scroll.contentView)
+    }
+
     private func loadSchedule() async {
         do {
             if googleOAuth.loadTokens() == nil {