|
|
@@ -38,6 +38,12 @@ private enum PremiumPlan: Int {
|
|
38
|
38
|
}
|
|
39
|
39
|
|
|
40
|
40
|
final class ViewController: NSViewController {
|
|
|
41
|
+ private struct GoogleProfileDisplay {
|
|
|
42
|
+ let name: String
|
|
|
43
|
+ let email: String
|
|
|
44
|
+ let pictureURL: URL?
|
|
|
45
|
+ }
|
|
|
46
|
+
|
|
41
|
47
|
private var palette = Palette(isDarkMode: true)
|
|
42
|
48
|
private let typography = Typography()
|
|
43
|
49
|
private let launchContentSize = NSSize(width: 920, height: 690)
|
|
|
@@ -78,6 +84,12 @@ final class ViewController: NSViewController {
|
|
78
|
84
|
private weak var scheduleScrollLeftButton: NSView?
|
|
79
|
85
|
private weak var scheduleScrollRightButton: NSView?
|
|
80
|
86
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
|
87
|
+ private weak var scheduleGoogleAuthButton: NSButton?
|
|
|
88
|
+ private weak var scheduleProfileContainer: NSView?
|
|
|
89
|
+ private weak var scheduleProfileNameLabel: NSTextField?
|
|
|
90
|
+ private weak var scheduleProfileEmailLabel: NSTextField?
|
|
|
91
|
+ private weak var scheduleProfileImageView: NSImageView?
|
|
|
92
|
+ private var scheduleProfileImageTask: Task<Void, Never>?
|
|
81
|
93
|
|
|
82
|
94
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
83
|
95
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
|
@@ -1805,9 +1817,15 @@ private extension ViewController {
|
|
1805
|
1817
|
|
|
1806
|
1818
|
row.addArrangedSubview(makeScheduleRefreshButton())
|
|
1807
|
1819
|
|
|
1808
|
|
- let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Connect" : "Connected")
|
|
|
1820
|
+ let profileBadge = makeScheduleProfileBadge()
|
|
|
1821
|
+ scheduleProfileContainer = profileBadge
|
|
|
1822
|
+ row.addArrangedSubview(profileBadge)
|
|
|
1823
|
+
|
|
|
1824
|
+ let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Login with Google" : "Logout")
|
|
1809
|
1825
|
connectButton.target = self
|
|
1810
|
1826
|
connectButton.action = #selector(scheduleConnectButtonPressed(_:))
|
|
|
1827
|
+ scheduleGoogleAuthButton = connectButton
|
|
|
1828
|
+ updateGoogleAuthButtonTitle()
|
|
1811
|
1829
|
row.addArrangedSubview(connectButton)
|
|
1812
|
1830
|
|
|
1813
|
1831
|
row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
|
@@ -1859,10 +1877,58 @@ private extension ViewController {
|
|
1859
|
1877
|
button.contentTintColor = palette.textSecondary
|
|
1860
|
1878
|
button.setButtonType(.momentaryChange)
|
|
1861
|
1879
|
button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
1862
|
|
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104).isActive = true
|
|
|
1880
|
+ button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
|
|
1863
|
1881
|
return button
|
|
1864
|
1882
|
}
|
|
1865
|
1883
|
|
|
|
1884
|
+ private func makeScheduleProfileBadge() -> NSView {
|
|
|
1885
|
+ let chip = roundedContainer(cornerRadius: 16, color: palette.inputBackground)
|
|
|
1886
|
+ chip.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1887
|
+ styleSurface(chip, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
1888
|
+ chip.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
1889
|
+ chip.widthAnchor.constraint(equalToConstant: 230).isActive = true
|
|
|
1890
|
+ chip.isHidden = true
|
|
|
1891
|
+
|
|
|
1892
|
+ let avatar = NSImageView()
|
|
|
1893
|
+ avatar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1894
|
+ avatar.wantsLayer = true
|
|
|
1895
|
+ avatar.layer?.cornerRadius = 11
|
|
|
1896
|
+ avatar.layer?.masksToBounds = true
|
|
|
1897
|
+ avatar.imageScaling = .scaleAxesIndependently
|
|
|
1898
|
+ avatar.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
1899
|
+ avatar.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
1900
|
+ avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
1901
|
+ avatar.contentTintColor = palette.textSecondary
|
|
|
1902
|
+
|
|
|
1903
|
+ let name = textLabel("Google User", font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
|
|
|
1904
|
+ let email = textLabel("Signed in", font: NSFont.systemFont(ofSize: 10, weight: .regular), color: palette.textMuted)
|
|
|
1905
|
+ name.lineBreakMode = .byTruncatingTail
|
|
|
1906
|
+ email.lineBreakMode = .byTruncatingTail
|
|
|
1907
|
+ name.maximumNumberOfLines = 1
|
|
|
1908
|
+ email.maximumNumberOfLines = 1
|
|
|
1909
|
+
|
|
|
1910
|
+ let textStack = NSStackView(views: [name, email])
|
|
|
1911
|
+ textStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1912
|
+ textStack.orientation = .vertical
|
|
|
1913
|
+ textStack.spacing = 0
|
|
|
1914
|
+ textStack.alignment = .leading
|
|
|
1915
|
+
|
|
|
1916
|
+ chip.addSubview(avatar)
|
|
|
1917
|
+ chip.addSubview(textStack)
|
|
|
1918
|
+ NSLayoutConstraint.activate([
|
|
|
1919
|
+ avatar.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 8),
|
|
|
1920
|
+ avatar.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
|
|
|
1921
|
+ textStack.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 7),
|
|
|
1922
|
+ textStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
|
|
|
1923
|
+ textStack.centerYAnchor.constraint(equalTo: chip.centerYAnchor)
|
|
|
1924
|
+ ])
|
|
|
1925
|
+
|
|
|
1926
|
+ scheduleProfileNameLabel = name
|
|
|
1927
|
+ scheduleProfileEmailLabel = email
|
|
|
1928
|
+ scheduleProfileImageView = avatar
|
|
|
1929
|
+ return chip
|
|
|
1930
|
+ }
|
|
|
1931
|
+
|
|
1866
|
1932
|
private func makeScheduleRefreshButton() -> NSButton {
|
|
1867
|
1933
|
let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
|
|
1868
|
1934
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -2772,6 +2838,8 @@ private extension ViewController {
|
|
2772
|
2838
|
do {
|
|
2773
|
2839
|
if googleOAuth.loadTokens() == nil {
|
|
2774
|
2840
|
await MainActor.run {
|
|
|
2841
|
+ updateGoogleAuthButtonTitle()
|
|
|
2842
|
+ applyGoogleProfile(nil)
|
|
2775
|
2843
|
scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
2776
|
2844
|
if let stack = scheduleCardsStack {
|
|
2777
|
2845
|
renderScheduleCards(into: stack, meetings: [])
|
|
|
@@ -2781,10 +2849,13 @@ private extension ViewController {
|
|
2781
|
2849
|
}
|
|
2782
|
2850
|
|
|
2783
|
2851
|
let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
2852
|
+ let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
|
|
2784
|
2853
|
let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
|
|
2785
|
2854
|
let filtered = filteredMeetings(meetings)
|
|
2786
|
2855
|
|
|
2787
|
2856
|
await MainActor.run {
|
|
|
2857
|
+ updateGoogleAuthButtonTitle()
|
|
|
2858
|
+ applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
2788
|
2859
|
scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
|
|
2789
|
2860
|
if let stack = scheduleCardsStack {
|
|
2790
|
2861
|
renderScheduleCards(into: stack, meetings: filtered)
|
|
|
@@ -2792,6 +2863,10 @@ private extension ViewController {
|
|
2792
|
2863
|
}
|
|
2793
|
2864
|
} catch {
|
|
2794
|
2865
|
await MainActor.run {
|
|
|
2866
|
+ updateGoogleAuthButtonTitle()
|
|
|
2867
|
+ if googleOAuth.loadTokens() == nil {
|
|
|
2868
|
+ applyGoogleProfile(nil)
|
|
|
2869
|
+ }
|
|
2795
|
2870
|
scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
2796
|
2871
|
if let stack = scheduleCardsStack {
|
|
2797
|
2872
|
renderScheduleCards(into: stack, meetings: [])
|
|
|
@@ -2813,17 +2888,17 @@ private extension ViewController {
|
|
2813
|
2888
|
Task { [weak self] in
|
|
2814
|
2889
|
guard let self else { return }
|
|
2815
|
2890
|
do {
|
|
2816
|
|
- try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
|
|
2817
|
|
- _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
2891
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
2892
|
+ _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
2818
|
2893
|
await MainActor.run {
|
|
2819
|
|
- scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
2820
|
|
- pageCache[.joinMeetings] = nil
|
|
2821
|
|
- showSidebarPage(.joinMeetings)
|
|
|
2894
|
+ self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
|
2895
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
2896
|
+ self.showSidebarPage(.joinMeetings)
|
|
2822
|
2897
|
}
|
|
2823
|
|
- await loadSchedule()
|
|
|
2898
|
+ await self.loadSchedule()
|
|
2824
|
2899
|
} catch {
|
|
2825
|
2900
|
await MainActor.run {
|
|
2826
|
|
- showSimpleError("Couldn’t refresh schedule.", error: error)
|
|
|
2901
|
+ self.showSimpleError("Couldn’t refresh schedule.", error: error)
|
|
2827
|
2902
|
}
|
|
2828
|
2903
|
}
|
|
2829
|
2904
|
}
|
|
|
@@ -2833,12 +2908,83 @@ private extension ViewController {
|
|
2833
|
2908
|
Task { [weak self] in
|
|
2834
|
2909
|
guard let self else { return }
|
|
2835
|
2910
|
do {
|
|
2836
|
|
- try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
|
|
2837
|
|
- _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
2838
|
|
- pageCache[.joinMeetings] = nil
|
|
2839
|
|
- showSidebarPage(.joinMeetings)
|
|
|
2911
|
+ if self.googleOAuth.loadTokens() != nil {
|
|
|
2912
|
+ try self.googleOAuth.signOut()
|
|
|
2913
|
+ await MainActor.run {
|
|
|
2914
|
+ self.updateGoogleAuthButtonTitle()
|
|
|
2915
|
+ self.applyGoogleProfile(nil)
|
|
|
2916
|
+ self.scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
2917
|
+ if let stack = self.scheduleCardsStack {
|
|
|
2918
|
+ self.renderScheduleCards(into: stack, meetings: [])
|
|
|
2919
|
+ }
|
|
|
2920
|
+ }
|
|
|
2921
|
+ return
|
|
|
2922
|
+ }
|
|
|
2923
|
+
|
|
|
2924
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
2925
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
2926
|
+ let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
2927
|
+ await MainActor.run {
|
|
|
2928
|
+ self.updateGoogleAuthButtonTitle()
|
|
|
2929
|
+ self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
2930
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
2931
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
2932
|
+ }
|
|
|
2933
|
+ } catch {
|
|
|
2934
|
+ self.showSimpleError("Couldn’t connect Google account.", error: error)
|
|
|
2935
|
+ }
|
|
|
2936
|
+ }
|
|
|
2937
|
+ }
|
|
|
2938
|
+
|
|
|
2939
|
+ private func updateGoogleAuthButtonTitle() {
|
|
|
2940
|
+ let signedIn = (googleOAuth.loadTokens() != nil)
|
|
|
2941
|
+ scheduleGoogleAuthButton?.title = signedIn ? "Logout" : "Login with Google"
|
|
|
2942
|
+ scheduleGoogleAuthButton?.image = NSImage(systemSymbolName: signedIn ? "rectangle.portrait.and.arrow.right" : "person.crop.circle.badge.checkmark", accessibilityDescription: "Google Auth")
|
|
|
2943
|
+ scheduleGoogleAuthButton?.imagePosition = .imageLeading
|
|
|
2944
|
+ scheduleGoogleAuthButton?.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
2945
|
+ }
|
|
|
2946
|
+
|
|
|
2947
|
+ private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
|
2948
|
+ let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2949
|
+ let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2950
|
+ return GoogleProfileDisplay(
|
|
|
2951
|
+ name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
|
|
|
2952
|
+ email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
|
|
|
2953
|
+ pictureURL: profile.picture.flatMap(URL.init(string:))
|
|
|
2954
|
+ )
|
|
|
2955
|
+ }
|
|
|
2956
|
+
|
|
|
2957
|
+ private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
|
2958
|
+ scheduleProfileImageTask?.cancel()
|
|
|
2959
|
+ scheduleProfileImageTask = nil
|
|
|
2960
|
+
|
|
|
2961
|
+ guard let profile else {
|
|
|
2962
|
+ scheduleProfileContainer?.isHidden = true
|
|
|
2963
|
+ scheduleProfileNameLabel?.stringValue = "Google User"
|
|
|
2964
|
+ scheduleProfileEmailLabel?.stringValue = "Signed in"
|
|
|
2965
|
+ scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
2966
|
+ scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
2967
|
+ return
|
|
|
2968
|
+ }
|
|
|
2969
|
+
|
|
|
2970
|
+ scheduleProfileContainer?.isHidden = false
|
|
|
2971
|
+ scheduleProfileNameLabel?.stringValue = profile.name
|
|
|
2972
|
+ scheduleProfileEmailLabel?.stringValue = profile.email
|
|
|
2973
|
+ scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
2974
|
+ scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
2975
|
+
|
|
|
2976
|
+ guard let pictureURL = profile.pictureURL else { return }
|
|
|
2977
|
+ scheduleProfileImageTask = Task { [weak self] in
|
|
|
2978
|
+ do {
|
|
|
2979
|
+ let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
|
2980
|
+ if Task.isCancelled { return }
|
|
|
2981
|
+ guard let image = NSImage(data: data) else { return }
|
|
|
2982
|
+ await MainActor.run {
|
|
|
2983
|
+ self?.scheduleProfileImageView?.image = image
|
|
|
2984
|
+ self?.scheduleProfileImageView?.contentTintColor = nil
|
|
|
2985
|
+ }
|
|
2840
|
2986
|
} catch {
|
|
2841
|
|
- showSimpleError("Couldn’t connect Google account.", error: error)
|
|
|
2987
|
+ // Keep placeholder avatar if image fetch fails.
|
|
2842
|
2988
|
}
|
|
2843
|
2989
|
}
|
|
2844
|
2990
|
}
|