|
|
@@ -38,6 +38,12 @@ private enum PremiumPlan: Int {
|
|
|
}
|
|
|
|
|
|
final class ViewController: NSViewController {
|
|
|
+ private struct GoogleProfileDisplay {
|
|
|
+ let name: String
|
|
|
+ let email: String
|
|
|
+ let pictureURL: URL?
|
|
|
+ }
|
|
|
+
|
|
|
private var palette = Palette(isDarkMode: true)
|
|
|
private let typography = Typography()
|
|
|
private let launchContentSize = NSSize(width: 920, height: 690)
|
|
|
@@ -78,6 +84,12 @@ final class ViewController: NSViewController {
|
|
|
private weak var scheduleScrollLeftButton: NSView?
|
|
|
private weak var scheduleScrollRightButton: NSView?
|
|
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
|
+ private weak var scheduleGoogleAuthButton: NSButton?
|
|
|
+ private weak var scheduleProfileContainer: NSView?
|
|
|
+ private weak var scheduleProfileNameLabel: NSTextField?
|
|
|
+ private weak var scheduleProfileEmailLabel: NSTextField?
|
|
|
+ private weak var scheduleProfileImageView: NSImageView?
|
|
|
+ private var scheduleProfileImageTask: Task<Void, Never>?
|
|
|
|
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
|
@@ -1805,9 +1817,15 @@ private extension ViewController {
|
|
|
|
|
|
row.addArrangedSubview(makeScheduleRefreshButton())
|
|
|
|
|
|
- let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Connect" : "Connected")
|
|
|
+ let profileBadge = makeScheduleProfileBadge()
|
|
|
+ scheduleProfileContainer = profileBadge
|
|
|
+ row.addArrangedSubview(profileBadge)
|
|
|
+
|
|
|
+ let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Login with Google" : "Logout")
|
|
|
connectButton.target = self
|
|
|
connectButton.action = #selector(scheduleConnectButtonPressed(_:))
|
|
|
+ scheduleGoogleAuthButton = connectButton
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
row.addArrangedSubview(connectButton)
|
|
|
|
|
|
row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
|
@@ -1859,10 +1877,58 @@ private extension ViewController {
|
|
|
button.contentTintColor = palette.textSecondary
|
|
|
button.setButtonType(.momentaryChange)
|
|
|
button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104).isActive = true
|
|
|
+ button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
|
|
|
return button
|
|
|
}
|
|
|
|
|
|
+ private func makeScheduleProfileBadge() -> NSView {
|
|
|
+ let chip = roundedContainer(cornerRadius: 16, color: palette.inputBackground)
|
|
|
+ chip.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ styleSurface(chip, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
+ chip.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
+ chip.widthAnchor.constraint(equalToConstant: 230).isActive = true
|
|
|
+ chip.isHidden = true
|
|
|
+
|
|
|
+ let avatar = NSImageView()
|
|
|
+ avatar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ avatar.wantsLayer = true
|
|
|
+ avatar.layer?.cornerRadius = 11
|
|
|
+ avatar.layer?.masksToBounds = true
|
|
|
+ avatar.imageScaling = .scaleAxesIndependently
|
|
|
+ avatar.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
+ avatar.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
+ avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
+ avatar.contentTintColor = palette.textSecondary
|
|
|
+
|
|
|
+ let name = textLabel("Google User", font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
|
|
|
+ let email = textLabel("Signed in", font: NSFont.systemFont(ofSize: 10, weight: .regular), color: palette.textMuted)
|
|
|
+ name.lineBreakMode = .byTruncatingTail
|
|
|
+ email.lineBreakMode = .byTruncatingTail
|
|
|
+ name.maximumNumberOfLines = 1
|
|
|
+ email.maximumNumberOfLines = 1
|
|
|
+
|
|
|
+ let textStack = NSStackView(views: [name, email])
|
|
|
+ textStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ textStack.orientation = .vertical
|
|
|
+ textStack.spacing = 0
|
|
|
+ textStack.alignment = .leading
|
|
|
+
|
|
|
+ chip.addSubview(avatar)
|
|
|
+ chip.addSubview(textStack)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ avatar.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 8),
|
|
|
+ avatar.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
|
|
|
+ textStack.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 7),
|
|
|
+ textStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
|
|
|
+ textStack.centerYAnchor.constraint(equalTo: chip.centerYAnchor)
|
|
|
+ ])
|
|
|
+
|
|
|
+ scheduleProfileNameLabel = name
|
|
|
+ scheduleProfileEmailLabel = email
|
|
|
+ scheduleProfileImageView = avatar
|
|
|
+ return chip
|
|
|
+ }
|
|
|
+
|
|
|
private func makeScheduleRefreshButton() -> NSButton {
|
|
|
let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
|
|
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -2772,6 +2838,8 @@ private extension ViewController {
|
|
|
do {
|
|
|
if googleOAuth.loadTokens() == nil {
|
|
|
await MainActor.run {
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ applyGoogleProfile(nil)
|
|
|
scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
if let stack = scheduleCardsStack {
|
|
|
renderScheduleCards(into: stack, meetings: [])
|
|
|
@@ -2781,10 +2849,13 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
+ let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
|
|
|
let filtered = filteredMeetings(meetings)
|
|
|
|
|
|
await MainActor.run {
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
|
|
|
if let stack = scheduleCardsStack {
|
|
|
renderScheduleCards(into: stack, meetings: filtered)
|
|
|
@@ -2792,6 +2863,10 @@ private extension ViewController {
|
|
|
}
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ if googleOAuth.loadTokens() == nil {
|
|
|
+ applyGoogleProfile(nil)
|
|
|
+ }
|
|
|
scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
|
if let stack = scheduleCardsStack {
|
|
|
renderScheduleCards(into: stack, meetings: [])
|
|
|
@@ -2813,17 +2888,17 @@ private extension ViewController {
|
|
|
Task { [weak self] in
|
|
|
guard let self else { return }
|
|
|
do {
|
|
|
- try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
|
|
|
- _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
+ _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
await MainActor.run {
|
|
|
- scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
|
- pageCache[.joinMeetings] = nil
|
|
|
- showSidebarPage(.joinMeetings)
|
|
|
+ self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
}
|
|
|
- await loadSchedule()
|
|
|
+ await self.loadSchedule()
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
- showSimpleError("Couldn’t refresh schedule.", error: error)
|
|
|
+ self.showSimpleError("Couldn’t refresh schedule.", error: error)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -2833,12 +2908,83 @@ private extension ViewController {
|
|
|
Task { [weak self] in
|
|
|
guard let self else { return }
|
|
|
do {
|
|
|
- try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
|
|
|
- _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
- pageCache[.joinMeetings] = nil
|
|
|
- showSidebarPage(.joinMeetings)
|
|
|
+ if self.googleOAuth.loadTokens() != nil {
|
|
|
+ try self.googleOAuth.signOut()
|
|
|
+ await MainActor.run {
|
|
|
+ self.updateGoogleAuthButtonTitle()
|
|
|
+ self.applyGoogleProfile(nil)
|
|
|
+ self.scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
+ if let stack = self.scheduleCardsStack {
|
|
|
+ self.renderScheduleCards(into: stack, meetings: [])
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
+ let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
+ await MainActor.run {
|
|
|
+ self.updateGoogleAuthButtonTitle()
|
|
|
+ self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ self.showSimpleError("Couldn’t connect Google account.", error: error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func updateGoogleAuthButtonTitle() {
|
|
|
+ let signedIn = (googleOAuth.loadTokens() != nil)
|
|
|
+ scheduleGoogleAuthButton?.title = signedIn ? "Logout" : "Login with Google"
|
|
|
+ scheduleGoogleAuthButton?.image = NSImage(systemSymbolName: signedIn ? "rectangle.portrait.and.arrow.right" : "person.crop.circle.badge.checkmark", accessibilityDescription: "Google Auth")
|
|
|
+ scheduleGoogleAuthButton?.imagePosition = .imageLeading
|
|
|
+ scheduleGoogleAuthButton?.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
|
+ let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ return GoogleProfileDisplay(
|
|
|
+ name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
|
|
|
+ email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
|
|
|
+ pictureURL: profile.picture.flatMap(URL.init(string:))
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
|
+ scheduleProfileImageTask?.cancel()
|
|
|
+ scheduleProfileImageTask = nil
|
|
|
+
|
|
|
+ guard let profile else {
|
|
|
+ scheduleProfileContainer?.isHidden = true
|
|
|
+ scheduleProfileNameLabel?.stringValue = "Google User"
|
|
|
+ scheduleProfileEmailLabel?.stringValue = "Signed in"
|
|
|
+ scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
+ scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ scheduleProfileContainer?.isHidden = false
|
|
|
+ scheduleProfileNameLabel?.stringValue = profile.name
|
|
|
+ scheduleProfileEmailLabel?.stringValue = profile.email
|
|
|
+ scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
+ scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
+
|
|
|
+ guard let pictureURL = profile.pictureURL else { return }
|
|
|
+ scheduleProfileImageTask = Task { [weak self] in
|
|
|
+ do {
|
|
|
+ let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
|
+ if Task.isCancelled { return }
|
|
|
+ guard let image = NSImage(data: data) else { return }
|
|
|
+ await MainActor.run {
|
|
|
+ self?.scheduleProfileImageView?.image = image
|
|
|
+ self?.scheduleProfileImageView?.contentTintColor = nil
|
|
|
+ }
|
|
|
} catch {
|
|
|
- showSimpleError("Couldn’t connect Google account.", error: error)
|
|
|
+ // Keep placeholder avatar if image fetch fails.
|
|
|
}
|
|
|
}
|
|
|
}
|