| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689 |
- //
- // ViewController.swift
- // meetings_app
- //
- // Created by Dev Mac 1 on 06/04/2026.
- //
- import Cocoa
- import WebKit
- import AuthenticationServices
- private enum SidebarPage: Int {
- case joinMeetings = 0
- case photo = 1
- case video = 2
- case tutorials = 3
- case settings = 4
- }
- private enum ZoomJoinMode: Int {
- case id = 0
- case url = 1
- }
- private enum SettingsAction: Int {
- case restore = 0
- case rateUs = 1
- case support = 2
- case moreApps = 3
- case shareApp = 4
- }
- private enum PremiumPlan: Int {
- case weekly = 0
- case monthly = 1
- case yearly = 2
- case lifetime = 3
- }
- 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)
- private let launchMinContentSize = NSSize(width: 760, height: 600)
- private var mainContentHost: NSView?
- private var sidebarRowViews: [SidebarPage: NSView] = [:]
- private var selectedSidebarPage: SidebarPage = .joinMeetings
- private var selectedZoomJoinMode: ZoomJoinMode = .id
- private var pageCache: [SidebarPage: NSView] = [:]
- private var sidebarPageByView = [ObjectIdentifier: SidebarPage]()
- private var zoomJoinModeByView = [ObjectIdentifier: ZoomJoinMode]()
- private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
- private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
- private weak var centeredTitleLabel: NSTextField?
- private var paywallWindow: NSWindow?
- private let paywallContentWidth: CGFloat = 520
- private var selectedPremiumPlan: PremiumPlan = .monthly
- private var paywallPlanViews: [PremiumPlan: NSView] = [:]
- private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
- private weak var paywallOfferLabel: NSTextField?
- private weak var meetLinkField: NSTextField?
- private weak var browseAddressField: NSTextField?
- private var inAppBrowserWindowController: InAppBrowserWindowController?
- private let googleOAuth = GoogleOAuthService.shared
- private let calendarClient = GoogleCalendarClient()
- private enum ScheduleFilter: Int {
- case all = 0
- case today = 1
- case week = 2
- }
- 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?
- private weak var scheduleGoogleAuthButton: NSButton?
- private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
- private var scheduleGoogleAuthHovering = false
- private var scheduleCurrentProfile: GoogleProfileDisplay?
- 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
- private let darkModeDefaultsKey = "settings.darkModeEnabled"
- private var darkModeEnabled: Bool {
- get {
- let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
- return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
- }
- set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
- }
- private func makeSettingsPopover() -> NSPopover {
- let popover = NSPopover()
- popover.behavior = .transient
- popover.animates = true
- popover.contentViewController = SettingsMenuViewController(
- palette: palette,
- typography: typography,
- darkModeEnabled: darkModeEnabled,
- onToggleDarkMode: { [weak self] enabled in
- self?.setDarkMode(enabled)
- },
- onAction: { [weak self] action in
- self?.handleSettingsAction(action)
- }
- )
- return popover
- }
- private var settingsPopover: NSPopover?
- override func viewDidLoad() {
- super.viewDidLoad()
- // Sync toggle + palette with current macOS appearance on launch.
- darkModeEnabled = systemPrefersDarkMode()
- palette = Palette(isDarkMode: darkModeEnabled)
- setupRootView()
- buildMainLayout()
- }
- override func viewDidAppear() {
- super.viewDidAppear()
- applyWindowTitle(for: selectedSidebarPage)
- guard let window = view.window else { return }
- // Ensure launch size is applied even when macOS tries to restore prior window state.
- window.isRestorable = false
- window.setFrameAutosaveName("")
- DispatchQueue.main.async { [weak self, weak window] in
- guard let self, let window else { return }
- let frameSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchContentSize)).size
- var newFrame = window.frame
- newFrame.size = frameSize
- window.setFrame(newFrame, display: true)
- window.center()
- window.minSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchMinContentSize)).size
- self.installCenteredTitleIfNeeded(on: window)
- }
- }
- override var representedObject: Any? {
- didSet {}
- }
- }
- private extension ViewController {
- func setupRootView() {
- view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- view.wantsLayer = true
- view.layer?.backgroundColor = palette.pageBackground.cgColor
- }
- func systemPrefersDarkMode() -> Bool {
- // Use the system-wide appearance setting (not app/window effective appearance).
- // When the key is missing, macOS is in Light mode.
- let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
- let style = global?["AppleInterfaceStyle"] as? String
- return style?.lowercased() == "dark"
- }
- func buildMainLayout() {
- let splitContainer = NSStackView()
- splitContainer.translatesAutoresizingMaskIntoConstraints = false
- splitContainer.orientation = .horizontal
- splitContainer.spacing = 0
- splitContainer.alignment = .top
- view.addSubview(splitContainer)
- NSLayoutConstraint.activate([
- splitContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- splitContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- splitContainer.topAnchor.constraint(equalTo: view.topAnchor),
- splitContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
- let sidebar = makeSidebar()
- let mainPanel = makeMainPanel()
- splitContainer.addArrangedSubview(sidebar)
- splitContainer.addArrangedSubview(mainPanel)
- }
- @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) {
- guard let view = sender.view else { return }
- activateSidebarItem(view)
- }
- private func activateSidebarItem(_ view: NSView) {
- guard let page = sidebarPageByView[ObjectIdentifier(view)],
- page != selectedSidebarPage || page == .settings else { return }
- if page == .settings {
- showSettingsPopover()
- return
- }
- showSidebarPage(page)
- }
- @objc private func zoomJoinModeClicked(_ sender: NSClickGestureRecognizer) {
- guard let view = sender.view,
- let mode = zoomJoinModeByView[ObjectIdentifier(view)],
- mode != selectedZoomJoinMode else { return }
- selectedZoomJoinMode = mode
- updateZoomJoinModeAppearance()
- if selectedSidebarPage == .joinMeetings {
- pageCache[.joinMeetings] = nil
- showSidebarPage(.joinMeetings)
- }
- }
- @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
- showPaywall()
- }
- @objc private func sidebarButtonClicked(_ sender: NSButton) {
- guard let page = SidebarPage(rawValue: sender.tag),
- page != selectedSidebarPage || page == .settings else { return }
- if page == .settings {
- showSettingsPopover()
- return
- }
- showSidebarPage(page)
- }
- @objc private func joinMeetClicked(_ sender: Any?) {
- let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard let url = normalizedMeetJoinURL(from: rawInput) else {
- showSimpleAlert(
- title: "Invalid Meet link",
- message: "Enter a valid Google Meet link or meeting code (for example nkd-grps-duv, meet.google.com/nkd-grps-duv, or https://meet.google.com/nkd-grps-duv)."
- )
- return
- }
- openInDefaultBrowser(url: url)
- }
- @objc private func cancelMeetJoinClicked(_ sender: Any?) {
- meetLinkField?.stringValue = ""
- }
- @objc private func browseOpenAddressClicked(_ sender: Any?) {
- let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard raw.isEmpty == false else {
- showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
- return
- }
- let normalized = normalizedURLString(from: raw)
- guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
- showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
- return
- }
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- }
- @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
- guard let url = URL(string: "https://meet.google.com/") else { return }
- openInDefaultBrowser(url: url)
- }
- @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
- guard let url = URL(string: "https://support.google.com/meet") else { return }
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- }
- @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
- guard let url = URL(string: "https://support.zoom.us") else { return }
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- }
- @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
- guard let url = URL(string: "https://meet.google.com/new") else { return }
- openInDefaultBrowser(url: url)
- }
- private func normalizedURLString(from value: String) -> String {
- if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
- return value
- }
- return "https://\(value)"
- }
- /// Typical Meet meeting code shape: three hyphen-separated groups (e.g. `nkd-grps-duv`).
- private func isValidMeetMeetingCode(_ code: String) -> Bool {
- let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return false }
- let pattern = "^[a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{3}$"
- return trimmed.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
- }
- /// Accepts `https://meet.google.com/...`, `meet.google.com/...`, or a bare code; returns canonical Meet URL or `nil`.
- private func normalizedMeetJoinURL(from rawInput: String) -> URL? {
- let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return nil }
- let lower = trimmed.lowercased()
- if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
- guard let url = URL(string: trimmed),
- let host = url.host?.lowercased(),
- host == "meet.google.com" || host.hasSuffix(".meet.google.com") else {
- return nil
- }
- let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
- guard path.isEmpty == false else { return nil }
- let firstSegment = path.split(separator: "/").first.map(String.init) ?? path
- guard isValidMeetMeetingCode(firstSegment) else { return nil }
- return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
- }
- if lower.hasPrefix("meet.google.com/") {
- let afterHost = trimmed.dropFirst("meet.google.com/".count)
- let beforeQuery = String(afterHost).split(separator: "?").first.map(String.init) ?? String(afterHost)
- let firstSegment = beforeQuery.split(separator: "/").first.map(String.init) ?? beforeQuery
- guard isValidMeetMeetingCode(firstSegment) else { return nil }
- return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
- }
- if isValidMeetMeetingCode(trimmed) {
- return URL(string: "https://meet.google.com/\(trimmed.lowercased())")
- }
- return nil
- }
- private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
- let browserController: InAppBrowserWindowController
- if let existing = inAppBrowserWindowController {
- browserController = existing
- } else {
- browserController = InAppBrowserWindowController()
- inAppBrowserWindowController = browserController
- }
- browserController.load(url: url, policy: policy)
- browserController.applyDefaultFrameCenteredOnVisibleScreen()
- browserController.showWindow(nil)
- browserController.window?.makeKeyAndOrderFront(nil)
- browserController.window?.orderFrontRegardless()
- NSApp.activate(ignoringOtherApps: true)
- }
- private func openInDefaultBrowser(url: URL) {
- NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
- if let error {
- DispatchQueue.main.async {
- self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription)
- }
- }
- }
- }
- private func openInSafari(url: URL) {
- guard let safariAppURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else {
- NSWorkspace.shared.open(url)
- return
- }
- let configuration = NSWorkspace.OpenConfiguration()
- NSWorkspace.shared.open([url], withApplicationAt: safariAppURL, configuration: configuration) { _, error in
- if let error {
- self.showSimpleAlert(title: "Unable to Open Safari", message: error.localizedDescription)
- }
- }
- }
- private func showSidebarPage(_ page: SidebarPage) {
- selectedSidebarPage = page
- updateSidebarAppearance()
- applyWindowTitle(for: page)
- guard let host = mainContentHost else { return }
- host.subviews.forEach { $0.removeFromSuperview() }
- let child = viewForPage(page)
- child.translatesAutoresizingMaskIntoConstraints = false
- host.addSubview(child)
- NSLayoutConstraint.activate([
- child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
- child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
- child.topAnchor.constraint(equalTo: host.topAnchor),
- child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
- ])
- }
- private func showSettingsPopover() {
- guard let anchor = sidebarRowViews[.settings] else { return }
- if settingsPopover?.isShown == true {
- settingsPopover?.performClose(nil)
- return
- }
- settingsPopover = makeSettingsPopover()
- if let menu = settingsPopover?.contentViewController as? SettingsMenuViewController {
- menu.setDarkModeEnabled(darkModeEnabled)
- }
- settingsPopover?.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
- }
- private func setDarkMode(_ enabled: Bool) {
- darkModeEnabled = enabled
- NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
- view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
- palette = Palette(isDarkMode: enabled)
- settingsPopover?.performClose(nil)
- settingsPopover = nil
- reloadTheme()
- }
- private func reloadTheme() {
- pageCache.removeAll()
- sidebarRowViews.removeAll()
- sidebarPageByView.removeAll()
- zoomJoinModeByView.removeAll()
- zoomJoinModeViews.removeAll()
- settingsActionByView.removeAll()
- paywallPlanViews.removeAll()
- premiumPlanByView.removeAll()
- mainContentHost = nil
- view.subviews.forEach { $0.removeFromSuperview() }
- setupRootView()
- buildMainLayout()
- showSidebarPage(selectedSidebarPage)
- }
- private func handleSettingsAction(_ action: SettingsAction) {
- switch action {
- case .restore:
- showSimpleAlert(title: "Restore", message: "Restore action tapped.")
- case .rateUs:
- settingsPopover?.performClose(nil)
- settingsPopover = nil
- // Replace with your App Store product URL when the app is listed.
- if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- }
- case .support:
- settingsPopover?.performClose(nil)
- settingsPopover = nil
- if let url = URL(string: "https://support.google.com/meet") {
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- }
- case .moreApps:
- settingsPopover?.performClose(nil)
- settingsPopover = nil
- // Replace with your App Store developer page URL.
- if let url = URL(string: "https://apps.apple.com/developer/id0000000000") {
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- }
- case .shareApp:
- let urlString = "https://example.com"
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(urlString, forType: .string)
- showSimpleAlert(title: "Share App", message: "Link copied to clipboard:\n\(urlString)")
- }
- }
- private func showSimpleAlert(title: String, message: String) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.addButton(withTitle: "OK")
- alert.runModal()
- }
- private func showPaywall() {
- if let existing = paywallWindow {
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let content = makePaywallContent()
- let controller = NSViewController()
- controller.view = content
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
- styleMask: [.titled, .closable, .fullSizeContentView],
- backing: .buffered,
- defer: false
- )
- panel.title = "Get Premium"
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
- panel.isFloatingPanel = false
- panel.level = .normal
- panel.hidesOnDeactivate = true
- panel.isReleasedWhenClosed = false
- panel.delegate = self
- panel.standardWindowButton(.closeButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.center()
- panel.contentViewController = controller
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- paywallWindow = panel
- }
- @objc private func closePaywallClicked(_ sender: Any?) {
- if let win = paywallWindow {
- win.performClose(nil)
- return
- }
- if let gesture = sender as? NSGestureRecognizer, let win = gesture.view?.window {
- win.performClose(nil)
- return
- }
- if let view = sender as? NSView, let win = view.window {
- win.performClose(nil)
- return
- }
- }
- @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
- guard let view = sender.view else { return }
- let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
- let map: [String: String] = [
- "Privacy Policy": "https://policies.google.com/privacy",
- "Support": "https://support.google.com/meet",
- "Terms of Services": "https://policies.google.com/terms"
- ]
- if let urlString = map[text], let url = URL(string: urlString) {
- openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
- return
- }
- showSimpleAlert(title: text, message: "\(text) tapped.")
- }
- @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
- guard let view = sender.view,
- let plan = premiumPlanByView[ObjectIdentifier(view)] else { return }
- selectedPremiumPlan = plan
- updatePaywallPlanSelection()
- }
- @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
- guard let plan = PremiumPlan(rawValue: sender.tag) else { return }
- selectedPremiumPlan = plan
- updatePaywallPlanSelection()
- }
- private func updatePaywallPlanSelection() {
- for (plan, view) in paywallPlanViews {
- applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
- }
- paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
- }
- private func paywallOfferText(for plan: PremiumPlan) -> String {
- switch plan {
- case .weekly:
- return "Rs 1,100.00/week"
- case .monthly:
- return "Free for 3 Days then Rs 2,500.00/month"
- case .yearly:
- return "Rs 9,900.00/year (about 190.38/week)"
- case .lifetime:
- return "Rs 14,900.00 one-time purchase"
- }
- }
- private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
- let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
- let idleBorder = palette.inputBorder
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverIdleBackground =
- palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
- let selectedBackground = darkModeEnabled
- ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
- : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
- card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
- card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
- card.layer?.borderWidth = isSelected ? 2 : 1
- card.layer?.shadowColor = NSColor.black.cgColor
- card.layer?.shadowOpacity = isSelected ? (darkModeEnabled ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
- card.layer?.shadowOffset = CGSize(width: 0, height: -1)
- card.layer?.shadowRadius = isSelected ? (darkModeEnabled ? 10 : 6) : (hovering ? 7 : 5)
- }
- private func viewForPage(_ page: SidebarPage) -> NSView {
- if let cached = pageCache[page] { return cached }
- let built: NSView
- switch page {
- case .joinMeetings:
- built = makeJoinMeetingsContent()
- case .photo:
- built = makePlaceholderPage(title: "Photo", subtitle: "Backgrounds — choose a photo background for your meetings.")
- case .video:
- built = makePlaceholderPage(title: "Video", subtitle: "Backgrounds — video background options.")
- case .tutorials:
- built = makePlaceholderPage(title: "Tutorials", subtitle: "Learn how to use the app.")
- case .settings:
- built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
- }
- pageCache[page] = built
- return built
- }
- private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary)
- sub.translatesAutoresizingMaskIntoConstraints = false
- panel.addSubview(titleLabel)
- panel.addSubview(sub)
- NSLayoutConstraint.activate([
- titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
- sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
- ])
- return panel
- }
- func makeBrowseWebContent() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- let sub = textLabel(
- "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
- font: typography.fieldLabel,
- color: palette.textSecondary
- )
- sub.translatesAutoresizingMaskIntoConstraints = false
- sub.maximumNumberOfLines = 0
- sub.lineBreakMode = .byWordWrapping
- let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
- fieldShell.translatesAutoresizingMaskIntoConstraints = false
- fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
- styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let field = NSTextField(string: "")
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isEditable = true
- field.isBordered = false
- field.drawsBackground = false
- field.focusRingType = .none
- field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
- field.textColor = palette.textPrimary
- field.placeholderString = "https://example.com or example.com"
- field.delegate = self
- browseAddressField = field
- fieldShell.addSubview(field)
- let openBtn = meetActionButton(
- title: "Open in app browser",
- color: palette.primaryBlue,
- textColor: .white,
- width: 220,
- action: #selector(browseOpenAddressClicked(_:))
- )
- let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
- quickTitle.translatesAutoresizingMaskIntoConstraints = false
- let quickRow = NSStackView()
- quickRow.translatesAutoresizingMaskIntoConstraints = false
- quickRow.orientation = .horizontal
- quickRow.spacing = 10
- quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
- quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
- quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
- panel.addSubview(titleLabel)
- panel.addSubview(sub)
- panel.addSubview(fieldShell)
- panel.addSubview(openBtn)
- panel.addSubview(quickTitle)
- panel.addSubview(quickRow)
- NSLayoutConstraint.activate([
- titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
- sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
- sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
- fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
- field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
- field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
- field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
- openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
- openBtn.heightAnchor.constraint(equalToConstant: 36),
- quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
- quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
- ])
- return panel
- }
- private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
- let b = NSButton(title: title, target: self, action: action)
- b.translatesAutoresizingMaskIntoConstraints = false
- b.bezelStyle = .rounded
- b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
- return b
- }
- private func applyWindowTitle(for page: SidebarPage) {
- let title: String
- switch page {
- case .joinMeetings:
- title = "App for Google Meet"
- case .photo:
- title = "Backgrounds — Photo"
- case .video:
- title = "Backgrounds — Video"
- case .tutorials:
- title = "Tutorials"
- case .settings:
- title = "Settings"
- }
- view.window?.title = title
- centeredTitleLabel?.stringValue = title
- }
- private func installCenteredTitleIfNeeded(on window: NSWindow) {
- guard centeredTitleLabel == nil else { return }
- guard let titlebarView = window.standardWindowButton(.closeButton)?.superview else { return }
- let label = NSTextField(labelWithString: window.title)
- label.translatesAutoresizingMaskIntoConstraints = false
- label.alignment = .center
- label.font = NSFont.titleBarFont(ofSize: 0)
- label.textColor = .labelColor
- label.lineBreakMode = .byTruncatingTail
- label.maximumNumberOfLines = 1
- titlebarView.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor),
- label.leadingAnchor.constraint(greaterThanOrEqualTo: titlebarView.leadingAnchor, constant: 90),
- label.trailingAnchor.constraint(lessThanOrEqualTo: titlebarView.trailingAnchor, constant: -90)
- ])
- window.titleVisibility = .hidden
- centeredTitleLabel = label
- }
- private func updateSidebarAppearance() {
- for (page, row) in sidebarRowViews {
- applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page))
- }
- }
- private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
- switch page {
- case .photo, .tutorials: return false
- case .joinMeetings, .video, .settings: return true
- }
- }
- func makeSidebar() -> NSView {
- let sidebar = NSView()
- sidebar.translatesAutoresizingMaskIntoConstraints = false
- sidebar.wantsLayer = true
- sidebar.layer?.backgroundColor = palette.sidebarBackground.cgColor
- sidebar.layer?.borderColor = palette.separator.cgColor
- sidebar.layer?.borderWidth = 1
- sidebar.layer?.shadowColor = NSColor.black.cgColor
- sidebar.layer?.shadowOpacity = 0.18
- sidebar.layer?.shadowOffset = CGSize(width: 2, height: 0)
- sidebar.layer?.shadowRadius = 10
- sidebar.widthAnchor.constraint(equalToConstant: 210).isActive = true
- let titleRow = NSStackView(views: [
- iconLabel("📅", size: 24),
- textLabel("Meetings", font: typography.sidebarBrand, color: palette.textPrimary)
- ])
- titleRow.translatesAutoresizingMaskIntoConstraints = false
- titleRow.orientation = .horizontal
- titleRow.alignment = .centerY
- titleRow.spacing = 8
- let menuStack = NSStackView()
- menuStack.translatesAutoresizingMaskIntoConstraints = false
- menuStack.orientation = .vertical
- menuStack.alignment = .leading
- menuStack.spacing = 10
- menuStack.addArrangedSubview(sidebarSectionTitle("Meetings"))
- let joinRow = sidebarItem("Join Meetings", icon: "", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0)
- menuStack.addArrangedSubview(joinRow)
- sidebarRowViews[.joinMeetings] = joinRow
- menuStack.addArrangedSubview(sidebarSectionTitle("Backgrounds"))
- let photoRow = sidebarItem("Photo", icon: "", page: .photo, logoImageName: "SidebarPhotoLogo", logoIconWidth: 24, logoHeightMultiplier: 82.0 / 62.0)
- menuStack.addArrangedSubview(photoRow)
- sidebarRowViews[.photo] = photoRow
- let videoRow = sidebarItem("Video", icon: "", page: .video, logoImageName: "SidebarVideoLogo", logoIconWidth: 28, logoHeightMultiplier: 52.0 / 60.0)
- menuStack.addArrangedSubview(videoRow)
- sidebarRowViews[.video] = videoRow
- menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
- let tutorialsRow = sidebarItem("Tutorials", icon: "", page: .tutorials, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0)
- menuStack.addArrangedSubview(tutorialsRow)
- sidebarRowViews[.tutorials] = tutorialsRow
- let settingsRow = sidebarItem("Settings", icon: "", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
- menuStack.addArrangedSubview(settingsRow)
- sidebarRowViews[.settings] = settingsRow
- let premiumButton = sidebarPremiumButton()
- sidebar.addSubview(titleRow)
- sidebar.addSubview(menuStack)
- sidebar.addSubview(premiumButton)
- NSLayoutConstraint.activate([
- titleRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 16),
- titleRow.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 24),
- titleRow.trailingAnchor.constraint(lessThanOrEqualTo: sidebar.trailingAnchor, constant: -16),
- menuStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
- menuStack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
- menuStack.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 20),
- menuStack.bottomAnchor.constraint(lessThanOrEqualTo: premiumButton.topAnchor, constant: -16),
- premiumButton.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
- premiumButton.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
- premiumButton.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -14)
- ])
- for subview in menuStack.arrangedSubviews {
- subview.widthAnchor.constraint(equalTo: menuStack.widthAnchor).isActive = true
- }
- return sidebar
- }
- func sidebarPremiumButton() -> NSView {
- let button = HoverTrackingView()
- button.translatesAutoresizingMaskIntoConstraints = false
- button.wantsLayer = true
- button.layer?.cornerRadius = 17
- button.layer?.backgroundColor = palette.primaryBlue.cgColor
- button.heightAnchor.constraint(equalToConstant: 34).isActive = true
- styleSurface(button, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: false)
- let icon = textLabel("★", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .white)
- let title = textLabel("Get Premium", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: .white)
- button.addSubview(icon)
- button.addSubview(title)
- NSLayoutConstraint.activate([
- icon.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
- icon.centerYAnchor.constraint(equalTo: button.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
- title.centerYAnchor.constraint(equalTo: button.centerYAnchor),
- title.trailingAnchor.constraint(lessThanOrEqualTo: button.trailingAnchor, constant: -12)
- ])
- let baseColor = palette.primaryBlue
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
- button.onHoverChanged = { hovering in
- button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
- }
- button.onHoverChanged?(false)
- let click = NSClickGestureRecognizer(target: self, action: #selector(premiumButtonClicked(_:)))
- button.addGestureRecognizer(click)
- return button
- }
- func makeMainPanel() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- panel.wantsLayer = true
- panel.layer?.backgroundColor = palette.pageBackground.cgColor
- let host = NSView()
- host.translatesAutoresizingMaskIntoConstraints = false
- panel.addSubview(host)
- NSLayoutConstraint.activate([
- host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
- host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
- host.topAnchor.constraint(equalTo: panel.topAnchor),
- host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
- ])
- mainContentHost = host
- showSidebarPage(.joinMeetings)
- return panel
- }
- func makeJoinMeetingsContent() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let contentStack = NSStackView()
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .vertical
- contentStack.spacing = 14
- contentStack.alignment = .leading
- contentStack.addArrangedSubview(scheduleTopAuthRow())
- if let authRow = contentStack.arrangedSubviews.last {
- contentStack.setCustomSpacing(20, after: authRow)
- }
- let joinActions = meetJoinActionsRow()
- contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
- contentStack.addArrangedSubview(meetJoinSectionRow())
- contentStack.addArrangedSubview(joinActions)
- contentStack.setCustomSpacing(26, after: joinActions)
- contentStack.addArrangedSubview(scheduleHeader())
- let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
- scheduleDateHeadingLabel = dateHeading
- contentStack.addArrangedSubview(dateHeading)
- let cardsRow = scheduleCardsRow(meetings: [])
- contentStack.addArrangedSubview(cardsRow)
- panel.addSubview(contentStack)
- NSLayoutConstraint.activate([
- contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
- ])
- Task { [weak self] in
- await self?.loadSchedule()
- }
- return panel
- }
- func meetJoinSectionRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.spacing = 12
- row.alignment = .top
- row.distribution = .fillEqually
- row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
- row.heightAnchor.constraint(equalToConstant: 140).isActive = true
- let instant = HoverSurfaceView()
- instant.translatesAutoresizingMaskIntoConstraints = false
- instant.wantsLayer = true
- instant.layer?.cornerRadius = 14
- instant.layer?.backgroundColor = palette.sectionCard.cgColor
- styleSurface(instant, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconWrap = roundedContainer(cornerRadius: 12, color: NSColor.clear)
- iconWrap.translatesAutoresizingMaskIntoConstraints = false
- iconWrap.widthAnchor.constraint(equalToConstant: 58).isActive = true
- iconWrap.heightAnchor.constraint(equalToConstant: 58).isActive = true
- iconWrap.layer?.borderWidth = 0
- let meetLogoImage = NSImage(named: "MeetLogo") ?? NSImage()
- meetLogoImage.isTemplate = false
- let meetLogo = NSImageView(image: meetLogoImage)
- meetLogo.translatesAutoresizingMaskIntoConstraints = false
- meetLogo.imageScaling = .scaleProportionallyDown
- meetLogo.contentTintColor = nil
- iconWrap.addSubview(meetLogo)
- let instantTitle = textLabel("New Instant Meet", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
- let instantSub = textLabel("Start instant Meet in more section with\nGoogle Meet meet.", font: NSFont.systemFont(ofSize: 16 / 2, weight: .medium), color: palette.textSecondary)
- instantSub.maximumNumberOfLines = 2
- instant.addSubview(iconWrap)
- instant.addSubview(instantTitle)
- instant.addSubview(instantSub)
- let codeCard = HoverSurfaceView()
- codeCard.translatesAutoresizingMaskIntoConstraints = false
- codeCard.wantsLayer = true
- codeCard.layer?.cornerRadius = 14
- codeCard.layer?.backgroundColor = palette.sectionCard.cgColor
- styleSurface(codeCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let codeTitle = textLabel("Join with Link", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
- let codeInputShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
- codeInputShell.translatesAutoresizingMaskIntoConstraints = false
- codeInputShell.heightAnchor.constraint(equalToConstant: 52).isActive = true
- styleSurface(codeInputShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let codeField = NSTextField(string: "")
- codeField.translatesAutoresizingMaskIntoConstraints = false
- codeField.isEditable = true
- codeField.isBordered = false
- codeField.drawsBackground = false
- codeField.focusRingType = .none
- codeField.font = NSFont.systemFont(ofSize: 36 / 2, weight: .regular)
- codeField.textColor = palette.textPrimary
- codeField.placeholderString = "Code or meet.google.com/…"
- codeInputShell.addSubview(codeField)
- meetLinkField = codeField
- codeCard.addSubview(codeTitle)
- codeCard.addSubview(codeInputShell)
- NSLayoutConstraint.activate([
- meetLogo.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
- meetLogo.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor),
- meetLogo.widthAnchor.constraint(equalToConstant: 46),
- meetLogo.heightAnchor.constraint(equalToConstant: 46),
- iconWrap.leadingAnchor.constraint(equalTo: instant.leadingAnchor, constant: 18),
- iconWrap.topAnchor.constraint(equalTo: instant.topAnchor, constant: 22),
- instantTitle.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 14),
- instantTitle.topAnchor.constraint(equalTo: instant.topAnchor, constant: 24),
- instantSub.leadingAnchor.constraint(equalTo: instantTitle.leadingAnchor),
- instantSub.topAnchor.constraint(equalTo: instantTitle.bottomAnchor, constant: 6),
- instantSub.trailingAnchor.constraint(lessThanOrEqualTo: instant.trailingAnchor, constant: -16),
- codeTitle.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
- codeTitle.topAnchor.constraint(equalTo: codeCard.topAnchor, constant: 22),
- codeInputShell.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
- codeInputShell.trailingAnchor.constraint(equalTo: codeCard.trailingAnchor, constant: -18),
- codeInputShell.topAnchor.constraint(equalTo: codeTitle.bottomAnchor, constant: 12),
- codeField.leadingAnchor.constraint(equalTo: codeInputShell.leadingAnchor, constant: 14),
- codeField.trailingAnchor.constraint(equalTo: codeInputShell.trailingAnchor, constant: -14),
- codeField.centerYAnchor.constraint(equalTo: codeInputShell.centerYAnchor)
- ])
- let baseColor = palette.sectionCard
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
- instant.onHoverChanged = { hovering in
- instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
- }
- codeCard.onHoverChanged = { hovering in
- codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
- }
- instant.onHoverChanged?(false)
- codeCard.onHoverChanged?(false)
- let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
- instant.addGestureRecognizer(instantClick)
- row.addArrangedSubview(instant)
- row.addArrangedSubview(codeCard)
- return row
- }
- func meetJoinActionsRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.spacing = 12
- row.alignment = .centerY
- row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- row.addArrangedSubview(spacer)
- row.addArrangedSubview(meetActionButton(
- title: "Cancel",
- color: palette.cancelButton,
- textColor: palette.textSecondary,
- width: 110,
- action: #selector(cancelMeetJoinClicked(_:))
- ))
- row.addArrangedSubview(meetActionButton(
- title: "Join",
- color: palette.primaryBlue,
- textColor: .white,
- width: 116,
- action: #selector(joinMeetClicked(_:))
- ))
- return row
- }
- func meetActionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat, action: Selector) -> NSButton {
- let button = HoverButton(title: title, target: self, action: action)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.bezelStyle = .regularSquare
- button.wantsLayer = true
- button.layer?.cornerRadius = 9
- let baseBackground = color
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverBackground = baseBackground.blended(withFraction: 0.10, of: hoverBlend) ?? baseBackground
- let baseBorder = (title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder)
- let hoverBorder = baseBorder.blended(withFraction: 0.18, of: hoverBlend) ?? baseBorder
- button.layer?.backgroundColor = baseBackground.cgColor
- button.layer?.borderColor = baseBorder.cgColor
- button.layer?.borderWidth = 1
- button.font = typography.buttonText
- button.contentTintColor = textColor
- button.widthAnchor.constraint(equalToConstant: width).isActive = true
- button.heightAnchor.constraint(equalToConstant: 36).isActive = true
- button.onHoverChanged = { [weak self, weak button] hovering in
- guard let self, let button else { return }
- button.layer?.backgroundColor = (hovering ? hoverBackground : baseBackground).cgColor
- button.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
- if title == "Cancel" {
- button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : textColor
- }
- }
- button.onHoverChanged?(false)
- return button
- }
- func makePaywallContent() -> NSView {
- paywallPlanViews.removeAll()
- premiumPlanByView.removeAll()
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- panel.wantsLayer = true
- panel.layer?.backgroundColor = palette.pageBackground.cgColor
- let contentStack = NSStackView()
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .vertical
- contentStack.spacing = 12
- contentStack.alignment = .leading
- panel.addSubview(contentStack)
- let topRow = NSStackView()
- topRow.translatesAutoresizingMaskIntoConstraints = false
- topRow.orientation = .horizontal
- topRow.alignment = .centerY
- topRow.distribution = .fill
- topRow.spacing = 10
- topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary))
- let topSpacer = NSView()
- topSpacer.translatesAutoresizingMaskIntoConstraints = false
- topRow.addArrangedSubview(topSpacer)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.layer?.backgroundColor = palette.inputBackground.cgColor
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = typography.iconButton
- closeButton.contentTintColor = palette.textSecondary
- closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.onHoverChanged = { [weak closeButton, weak self] hovering in
- guard let closeButton, let self else { return }
- let base = self.palette.inputBackground
- let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
- let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
- closeButton.layer?.backgroundColor = (hovering ? hover : base).cgColor
- closeButton.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
- }
- topRow.addArrangedSubview(closeButton)
- topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- contentStack.addArrangedSubview(topRow)
- contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textSecondary))
- let benefits = paywallBenefitsSection()
- contentStack.addArrangedSubview(benefits)
- contentStack.setCustomSpacing(18, after: benefits)
- let weeklyCard = paywallPlanCard(
- title: "Weekly",
- price: "Rs 1,100.00",
- badge: "Basic Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: nil,
- plan: .weekly,
- strikePrice: nil
- )
- contentStack.addArrangedSubview(weeklyCard)
- let monthlyCard = paywallPlanCard(
- title: "Monthly",
- price: "Rs 2,500.00",
- badge: "Free Trial",
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
- subtitle: "625.00/week",
- plan: .monthly,
- strikePrice: nil
- )
- contentStack.addArrangedSubview(monthlyCard)
- let yearlyCard = paywallPlanCard(
- title: "Yearly",
- price: "Rs 9,900.00",
- badge: "Best Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: "190.38/week",
- plan: .yearly,
- strikePrice: nil
- )
- contentStack.addArrangedSubview(yearlyCard)
- let lifetimeCard = paywallPlanCard(
- title: "Lifetime",
- price: "Rs 14,900.00",
- badge: "Save 50%",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: nil,
- plan: .lifetime,
- strikePrice: "Rs 29,800.00"
- )
- contentStack.addArrangedSubview(lifetimeCard)
- updatePaywallPlanSelection()
- contentStack.setCustomSpacing(20, after: lifetimeCard)
- let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
- offer.alignment = .center
- paywallOfferLabel = offer
- let offerWrap = NSView()
- offerWrap.translatesAutoresizingMaskIntoConstraints = false
- offerWrap.addSubview(offer)
- NSLayoutConstraint.activate([
- offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
- offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
- offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
- offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
- ])
- contentStack.addArrangedSubview(offerWrap)
- contentStack.setCustomSpacing(18, after: offerWrap)
- let continueButton = HoverTrackingView()
- continueButton.translatesAutoresizingMaskIntoConstraints = false
- continueButton.wantsLayer = true
- continueButton.layer?.cornerRadius = 14
- continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
- continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
- continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
- let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
- continueButton.addSubview(continueLabel)
- NSLayoutConstraint.activate([
- continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
- continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
- ])
- let baseBlue = palette.primaryBlue
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverBlue = baseBlue.blended(withFraction: 0.10, of: hoverBlend) ?? baseBlue
- continueButton.onHoverChanged = { hovering in
- continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
- }
- continueButton.onHoverChanged?(false)
- contentStack.addArrangedSubview(continueButton)
- contentStack.setCustomSpacing(16, after: continueButton)
- let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
- secure.alignment = .center
- let secureWrap = NSView()
- secureWrap.translatesAutoresizingMaskIntoConstraints = false
- secureWrap.addSubview(secure)
- NSLayoutConstraint.activate([
- secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
- secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
- secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
- secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
- ])
- contentStack.addArrangedSubview(secureWrap)
- contentStack.setCustomSpacing(16, after: secureWrap)
- let footer = paywallFooterLinks()
- contentStack.addArrangedSubview(footer)
- NSLayoutConstraint.activate([
- contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
- contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
- contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
- contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
- ])
- return panel
- }
- func paywallPlanCard(
- title: String,
- price: String,
- badge: String,
- badgeColor: NSColor,
- subtitle: String?,
- plan: PremiumPlan,
- strikePrice: String?
- ) -> NSView {
- let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- wrapper.isBordered = false
- wrapper.bezelStyle = .regularSquare
- wrapper.wantsLayer = true
- wrapper.layer?.backgroundColor = NSColor.clear.cgColor
- wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
- wrapper.tag = plan.rawValue
- let card = HoverTrackingView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 16
- card.layer?.backgroundColor = palette.sectionCard.cgColor
- card.heightAnchor.constraint(equalToConstant: 82).isActive = true
- wrapper.addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
- card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
- let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
- badgeWrap.translatesAutoresizingMaskIntoConstraints = false
- badgeWrap.wantsLayer = true
- badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
- badgeWrap.layer?.borderWidth = 1
- badgeWrap.layer?.shadowColor = NSColor.black.cgColor
- badgeWrap.layer?.shadowOpacity = 0.20
- badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
- badgeWrap.layer?.shadowRadius = 3
- badgeWrap.addSubview(badgeLabel)
- NSLayoutConstraint.activate([
- badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
- badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
- badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
- badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
- ])
- wrapper.addSubview(badgeWrap)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue)
- card.addSubview(titleLabel)
- let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
- card.addSubview(priceLabel)
- NSLayoutConstraint.activate([
- badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
- badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
- priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
- ])
- if let subtitle {
- let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
- card.addSubview(sub)
- NSLayoutConstraint.activate([
- sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
- sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
- ])
- }
- if let strikePrice {
- let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
- card.addSubview(strike)
- NSLayoutConstraint.activate([
- strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
- strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
- ])
- }
- paywallPlanViews[plan] = card
- wrapper.onHoverChanged = { [weak self, weak card] hovering in
- guard let self, let card else { return }
- self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
- }
- wrapper.onHoverChanged?(false)
- return wrapper
- }
- func paywallFooterLinks() -> NSView {
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true
- wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.distribution = .fillEqually
- row.alignment = .centerY
- row.spacing = 0
- wrap.addSubview(row)
- row.addArrangedSubview(footerLink("Privacy Policy"))
- row.addArrangedSubview(footerLink("Support"))
- row.addArrangedSubview(footerLink("Terms of Services"))
- NSLayoutConstraint.activate([
- row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
- row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
- row.topAnchor.constraint(equalTo: wrap.topAnchor),
- row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
- ])
- return wrap
- }
- func footerLink(_ title: String) -> NSView {
- let container = HoverTrackingView()
- container.translatesAutoresizingMaskIntoConstraints = false
- let label = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
- label.alignment = .center
- container.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
- ])
- let click = NSClickGestureRecognizer(target: self, action: #selector(paywallFooterLinkClicked(_:)))
- container.addGestureRecognizer(click)
- container.onHoverChanged = { hovering in
- label.textColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
- }
- container.onHoverChanged?(false)
- return container
- }
- func paywallBenefitsSection() -> NSView {
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- let rowOne = NSStackView()
- rowOne.translatesAutoresizingMaskIntoConstraints = false
- rowOne.orientation = .horizontal
- rowOne.spacing = 10
- rowOne.distribution = .fillEqually
- rowOne.alignment = .centerY
- rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
- rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
- let rowTwo = NSStackView()
- rowTwo.translatesAutoresizingMaskIntoConstraints = false
- rowTwo.orientation = .horizontal
- rowTwo.spacing = 10
- rowTwo.distribution = .fillEqually
- rowTwo.alignment = .centerY
- rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
- rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
- stack.addArrangedSubview(rowOne)
- stack.addArrangedSubview(rowTwo)
- return stack
- }
- func paywallBenefitItem(icon: String, text: String) -> NSView {
- let card = HoverTrackingView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 10
- card.layer?.backgroundColor = palette.inputBackground.cgColor
- card.heightAnchor.constraint(equalToConstant: 36).isActive = true
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
- iconWrap.translatesAutoresizingMaskIntoConstraints = false
- iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
- iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
- styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue)
- iconWrap.addSubview(iconLabel)
- NSLayoutConstraint.activate([
- iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
- iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
- ])
- let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary)
- card.addSubview(iconWrap)
- card.addSubview(title)
- NSLayoutConstraint.activate([
- iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
- iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
- title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
- title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
- ])
- let base = palette.inputBackground
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
- let hoverBorder = palette.primaryBlueBorder.withAlphaComponent(0.55)
- card.onHoverChanged = { [weak card, weak iconWrap] hovering in
- guard let card else { return }
- card.layer?.backgroundColor = (hovering ? hover : base).cgColor
- card.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
- iconWrap?.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
- }
- card.onHoverChanged?(false)
- return card
- }
- func zoomJoinModeTabs() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.alignment = .centerY
- row.spacing = 28
- row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
- let idTab = joinModeTab("Join with ID", mode: .id)
- let urlTab = joinModeTab("Join with URL", mode: .url)
- row.addArrangedSubview(idTab)
- row.addArrangedSubview(urlTab)
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- row.addArrangedSubview(spacer)
- zoomJoinModeViews[.id] = idTab
- zoomJoinModeViews[.url] = urlTab
- updateZoomJoinModeAppearance()
- return row
- }
- func joinModeTab(_ title: String, mode: ZoomJoinMode) -> NSView {
- let tab = HoverTrackingView()
- tab.translatesAutoresizingMaskIntoConstraints = false
- tab.wantsLayer = true
- tab.layer?.cornerRadius = 6
- tab.layer?.backgroundColor = NSColor.clear.cgColor
- tab.heightAnchor.constraint(equalToConstant: 30).isActive = true
- zoomJoinModeByView[ObjectIdentifier(tab)] = mode
- let label = textLabel(title, font: NSFont.systemFont(ofSize: 33 / 2, weight: .medium), color: palette.textPrimary)
- tab.addSubview(label)
- NSLayoutConstraint.activate([
- label.leadingAnchor.constraint(equalTo: tab.leadingAnchor, constant: 4),
- label.trailingAnchor.constraint(equalTo: tab.trailingAnchor, constant: -4),
- label.topAnchor.constraint(equalTo: tab.topAnchor, constant: 4),
- label.bottomAnchor.constraint(equalTo: tab.bottomAnchor, constant: -6)
- ])
- let click = NSClickGestureRecognizer(target: self, action: #selector(zoomJoinModeClicked(_:)))
- tab.addGestureRecognizer(click)
- return tab
- }
- func updateZoomJoinModeAppearance() {
- for (mode, tab) in zoomJoinModeViews {
- let selected = (mode == selectedZoomJoinMode)
- let textColor = selected ? palette.textPrimary : palette.textSecondary
- let label = tab.subviews.first { $0 is NSTextField } as? NSTextField
- label?.textColor = textColor
- // Keep the active tab visually underlined like the reference.
- if selected {
- if tab.subviews.contains(where: { $0.identifier?.rawValue == "modeUnderline" }) == false {
- let underline = NSView()
- underline.identifier = NSUserInterfaceItemIdentifier("modeUnderline")
- underline.translatesAutoresizingMaskIntoConstraints = false
- underline.wantsLayer = true
- underline.layer?.backgroundColor = palette.primaryBlue.cgColor
- tab.addSubview(underline)
- NSLayoutConstraint.activate([
- underline.leadingAnchor.constraint(equalTo: tab.leadingAnchor),
- underline.trailingAnchor.constraint(equalTo: tab.trailingAnchor),
- underline.bottomAnchor.constraint(equalTo: tab.bottomAnchor),
- underline.heightAnchor.constraint(equalToConstant: 2)
- ])
- }
- } else {
- tab.subviews
- .filter { $0.identifier?.rawValue == "modeUnderline" }
- .forEach { $0.removeFromSuperview() }
- }
- }
- }
- func joinWithIDHeading() -> NSView {
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- let title = textLabel("Join with ID", font: typography.joinWithURLTitle, color: palette.textPrimary)
- title.alignment = .left
- title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- title.setContentCompressionResistancePriority(.required, for: .horizontal)
- let bar = NSView()
- bar.translatesAutoresizingMaskIntoConstraints = false
- bar.wantsLayer = true
- bar.layer?.backgroundColor = palette.primaryBlue.cgColor
- bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
- container.addSubview(title)
- container.addSubview(bar)
- NSLayoutConstraint.activate([
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- title.topAnchor.constraint(equalTo: container.topAnchor),
- bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
- bar.widthAnchor.constraint(equalTo: title.widthAnchor),
- bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
- container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
- ])
- return container
- }
- func zoomMeetingIDSection() -> NSView {
- let wrapper = NSView()
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- let fieldsRow = NSStackView()
- fieldsRow.translatesAutoresizingMaskIntoConstraints = false
- fieldsRow.orientation = .horizontal
- fieldsRow.alignment = .top
- fieldsRow.distribution = .fillEqually
- fieldsRow.spacing = 12
- fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting ID", placeholder: "Enter meeting ID..."))
- fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting Passcode", placeholder: "Enter meeting passcode..."))
- let actions = NSStackView()
- actions.orientation = .horizontal
- actions.spacing = 10
- actions.translatesAutoresizingMaskIntoConstraints = false
- actions.alignment = .centerY
- actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
- actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
- wrapper.addSubview(fieldsRow)
- wrapper.addSubview(actions)
- NSLayoutConstraint.activate([
- wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
- fieldsRow.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- fieldsRow.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- fieldsRow.topAnchor.constraint(equalTo: wrapper.topAnchor),
- actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- actions.topAnchor.constraint(equalTo: fieldsRow.bottomAnchor, constant: 14),
- actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- return wrapper
- }
- func zoomInputField(title: String, placeholder: String) -> NSView {
- let wrapper = NSView()
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- let heading = textLabel(title, font: typography.fieldLabel, color: palette.textPrimary)
- let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
- textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
- styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let field = NSTextField(string: "")
- field.translatesAutoresizingMaskIntoConstraints = false
- field.isEditable = true
- field.isSelectable = true
- field.isBordered = false
- field.drawsBackground = false
- field.placeholderString = placeholder
- field.font = typography.inputPlaceholder
- field.textColor = palette.textPrimary
- field.focusRingType = .none
- textFieldContainer.addSubview(field)
- wrapper.addSubview(heading)
- wrapper.addSubview(textFieldContainer)
- NSLayoutConstraint.activate([
- heading.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- heading.topAnchor.constraint(equalTo: wrapper.topAnchor),
- textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- textFieldContainer.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 10),
- textFieldContainer.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
- field.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
- field.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
- field.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor)
- ])
- return wrapper
- }
- func joinWithURLHeading() -> NSView {
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- let title = textLabel("Join with URL", font: typography.joinWithURLTitle, color: palette.textPrimary)
- title.alignment = .left
- title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- title.setContentCompressionResistancePriority(.required, for: .horizontal)
- let bar = NSView()
- bar.translatesAutoresizingMaskIntoConstraints = false
- bar.wantsLayer = true
- bar.layer?.backgroundColor = palette.primaryBlue.cgColor
- bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
- container.addSubview(title)
- container.addSubview(bar)
- NSLayoutConstraint.activate([
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- title.topAnchor.constraint(equalTo: container.topAnchor),
- bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
- bar.widthAnchor.constraint(equalTo: title.widthAnchor),
- bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
- container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
- ])
- return container
- }
- func meetingUrlSection() -> NSView {
- let wrapper = NSView()
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- let title = textLabel("Meeting URL", font: typography.fieldLabel, color: palette.textSecondary)
- let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
- textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
- styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let urlField = NSTextField(string: "")
- urlField.translatesAutoresizingMaskIntoConstraints = false
- urlField.isEditable = true
- urlField.isSelectable = true
- urlField.isBordered = false
- urlField.drawsBackground = false
- urlField.placeholderString = "Enter meeting URL..."
- urlField.font = typography.inputPlaceholder
- urlField.textColor = palette.textPrimary
- urlField.focusRingType = .none
- textFieldContainer.addSubview(urlField)
- let actions = NSStackView()
- actions.orientation = .horizontal
- actions.spacing = 10
- actions.translatesAutoresizingMaskIntoConstraints = false
- actions.alignment = .centerY
- actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
- actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
- wrapper.addSubview(title)
- wrapper.addSubview(textFieldContainer)
- wrapper.addSubview(actions)
- NSLayoutConstraint.activate([
- wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
- title.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- title.topAnchor.constraint(equalTo: wrapper.topAnchor),
- textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- textFieldContainer.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10),
- urlField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
- urlField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
- urlField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor),
- actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- actions.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 14),
- actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- return wrapper
- }
- func scheduleHeader() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.alignment = .centerY
- row.distribution = .fill
- row.spacing = 12
- row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- row.addArrangedSubview(spacer)
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- row.addArrangedSubview(makeScheduleRefreshButton())
- row.addArrangedSubview(makeScheduleFilterDropdown())
- row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
- return row
- }
- private func scheduleTopAuthRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.alignment = .centerY
- row.spacing = 10
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- row.addArrangedSubview(spacer)
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let authButton = makeGoogleAuthButton()
- scheduleGoogleAuthButton = authButton
- updateGoogleAuthButtonTitle()
- row.addArrangedSubview(authButton)
- row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
- return row
- }
- private func makeScheduleFilterDropdown() -> NSPopUpButton {
- let button = NSPopUpButton(frame: .zero, pullsDown: false)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.autoenablesItems = false
- button.wantsLayer = true
- button.layer?.cornerRadius = 8
- 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(scheduleFilterDropdownChanged(_:))
- button.heightAnchor.constraint(equalToConstant: 34).isActive = true
- button.widthAnchor.constraint(equalToConstant: 156).isActive = true
- button.removeAllItems()
- button.addItems(withTitles: ["All", "Today", "This week"])
- button.selectItem(at: scheduleFilter.rawValue)
- if let menu = button.menu {
- for (index, item) in menu.items.enumerated() {
- item.tag = index
- }
- }
- scheduleFilterDropdown = button
- return button
- }
- private func makeSchedulePillButton(title: String) -> NSButton {
- let button = NSButton(title: title, target: nil, action: nil)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.bezelStyle = .regularSquare
- button.wantsLayer = true
- button.layer?.cornerRadius = 8
- 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.setButtonType(.momentaryChange)
- button.heightAnchor.constraint(equalToConstant: 34).isActive = true
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
- return button
- }
- private func makeGoogleAuthButton() -> NSButton {
- let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.bezelStyle = .regularSquare
- button.wantsLayer = true
- button.layer?.cornerRadius = 21
- button.layer?.borderWidth = 1
- button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
- button.imagePosition = .imageLeading
- button.alignment = .center
- button.imageHugsTitle = true
- button.lineBreakMode = .byTruncatingTail
- button.contentTintColor = palette.textPrimary
- button.imageScaling = .scaleNone
- button.heightAnchor.constraint(equalToConstant: 42).isActive = true
- let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
- widthConstraint.isActive = true
- scheduleGoogleAuthButtonWidthConstraint = widthConstraint
- button.onHoverChanged = { [weak self] hovering in
- self?.scheduleGoogleAuthHovering = hovering
- self?.applyGoogleAuthButtonSurface()
- }
- button.onHoverChanged?(false)
- return button
- }
- private func makeScheduleRefreshButton() -> NSButton {
- let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.bezelStyle = .regularSquare
- button.wantsLayer = true
- button.layer?.cornerRadius = 21
- button.layer?.backgroundColor = palette.inputBackground.cgColor
- button.layer?.borderColor = palette.inputBorder.cgColor
- button.layer?.borderWidth = 1
- button.setButtonType(.momentaryChange)
- button.contentTintColor = palette.textSecondary
- button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
- button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
- button.imagePosition = .imageOnly
- button.imageScaling = .scaleProportionallyDown
- button.focusRingType = .none
- button.heightAnchor.constraint(equalToConstant: 42).isActive = true
- button.widthAnchor.constraint(equalToConstant: 42).isActive = true
- return button
- }
- 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 = false
- scroll.hasVerticalScroller = false
- scroll.horizontalScrollElasticity = .allowed
- scroll.verticalScrollElasticity = .none
- 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 = 12
- row.alignment = .top
- row.distribution = .gravityAreas
- row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- row.heightAnchor.constraint(equalToConstant: 150).isActive = true
- scheduleCardsStack = row
- scroll.documentView = row
- scroll.contentView.postsBoundsChangedNotifications = true
- // Ensure the stack view determines content size for horizontal scrolling.
- NSLayoutConstraint.activate([
- row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
- 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: 150)
- ])
- renderScheduleCards(into: row, meetings: meetings)
- 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 = 240
- 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: 150).isActive = true
- card.setContentHuggingPriority(.required, for: .horizontal)
- card.setContentCompressionResistancePriority(.required, for: .horizontal)
- let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
- icon.translatesAutoresizingMaskIntoConstraints = false
- 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([
- iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
- ])
- let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
- title.lineBreakMode = .byTruncatingTail
- title.maximumNumberOfLines = 1
- title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- 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: dayChip.leadingAnchor, constant: -8),
- title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
- subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
- 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: 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 = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
- hit.translatesAutoresizingMaskIntoConstraints = false
- hit.isBordered = false
- hit.bezelStyle = .regularSquare
- hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
- hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
- hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
- hit.setContentHuggingPriority(.required, for: .horizontal)
- hit.setContentCompressionResistancePriority(.required, for: .horizontal)
- hit.addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
- card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
- card.topAnchor.constraint(equalTo: hit.topAnchor),
- card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
- ])
- hit.onHoverChanged = { [weak self] hovering in
- guard let self else { return }
- let base = self.palette.sectionCard
- let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
- let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
- card.layer?.backgroundColor = (hovering ? hover : base).cgColor
- }
- hit.onHoverChanged?(false)
- 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 {
- func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
- if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
- browseOpenAddressClicked(nil)
- return true
- }
- return false
- }
- }
- extension ViewController: NSWindowDelegate {
- func windowWillClose(_ notification: Notification) {
- guard let closingWindow = notification.object as? NSWindow else { return }
- if closingWindow === paywallWindow {
- paywallWindow = nil
- }
- }
- }
- /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
- private class RowHitTestView: NSView {
- override func hitTest(_ point: NSPoint) -> NSView? {
- return bounds.contains(point) ? self : nil
- }
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
- true
- }
- }
- private final class HoverTrackingView: RowHitTestView {
- var onHoverChanged: ((Bool) -> Void)?
- var onClick: (() -> Void)?
- var showsHandCursor = true
- private var trackingAreaRef: NSTrackingArea?
- private var isHovering = false {
- didSet {
- guard isHovering != oldValue else { return }
- onHoverChanged?(isHovering)
- }
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let trackingAreaRef {
- removeTrackingArea(trackingAreaRef)
- }
- let options: NSTrackingArea.Options = [
- .activeInKeyWindow,
- .inVisibleRect,
- .mouseEnteredAndExited
- ]
- let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
- addTrackingArea(area)
- trackingAreaRef = area
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- isHovering = true
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- isHovering = false
- }
- override func resetCursorRects() {
- super.resetCursorRects()
- guard showsHandCursor else { return }
- addCursorRect(bounds, cursor: .pointingHand)
- }
- override func mouseUp(with event: NSEvent) {
- super.mouseUp(with: event)
- guard event.type == .leftMouseUp else { return }
- onClick?()
- }
- }
- /// Hover tracking without overriding hit-testing; keeps controls like text fields interactive.
- private final class HoverSurfaceView: NSView {
- var onHoverChanged: ((Bool) -> Void)?
- private var trackingAreaRef: NSTrackingArea?
- private var isHovering = false {
- didSet {
- guard isHovering != oldValue else { return }
- onHoverChanged?(isHovering)
- }
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let trackingAreaRef {
- removeTrackingArea(trackingAreaRef)
- }
- let options: NSTrackingArea.Options = [
- .activeInKeyWindow,
- .inVisibleRect,
- .mouseEnteredAndExited
- ]
- let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
- addTrackingArea(area)
- trackingAreaRef = area
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- isHovering = true
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- isHovering = false
- }
- }
- private final class HoverButton: NSButton {
- var onHoverChanged: ((Bool) -> Void)?
- var showsHandCursor = true
- private var trackingAreaRef: NSTrackingArea?
- private var isHovering = false {
- didSet {
- guard isHovering != oldValue else { return }
- onHoverChanged?(isHovering)
- }
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let trackingAreaRef {
- removeTrackingArea(trackingAreaRef)
- }
- let options: NSTrackingArea.Options = [
- .activeInKeyWindow,
- .inVisibleRect,
- .mouseEnteredAndExited
- ]
- let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
- addTrackingArea(tracking)
- trackingAreaRef = tracking
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- if showsHandCursor {
- NSCursor.pointingHand.set()
- }
- isHovering = true
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- isHovering = false
- }
- }
- private final class SettingsMenuViewController: NSViewController {
- private let palette: Palette
- private let typography: Typography
- private let onToggleDarkMode: (Bool) -> Void
- private let onAction: (SettingsAction) -> Void
- private var darkToggle: NSSwitch?
- init(
- palette: Palette,
- typography: Typography,
- darkModeEnabled: Bool,
- onToggleDarkMode: @escaping (Bool) -> Void,
- onAction: @escaping (SettingsAction) -> Void
- ) {
- self.palette = palette
- self.typography = typography
- self.onToggleDarkMode = onToggleDarkMode
- self.onAction = onAction
- super.init(nibName: nil, bundle: nil)
- self.view = makeView(darkModeEnabled: darkModeEnabled)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
- func setDarkModeEnabled(_ enabled: Bool) {
- darkToggle?.state = enabled ? .on : .off
- }
- private func makeView(darkModeEnabled: Bool) -> NSView {
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- let card = roundedCard()
- root.addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- card.topAnchor.constraint(equalTo: root.topAnchor),
- card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
- root.widthAnchor.constraint(equalToConstant: 260)
- ])
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 6
- stack.alignment = .leading
- card.addSubview(stack)
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
- stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
- stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
- stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14)
- ])
- stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
- stack.addArrangedSubview(settingsActionRow(icon: "⟳", title: "Restore", action: .restore))
- stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
- stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
- stack.addArrangedSubview(settingsActionRow(icon: "⋯", title: "More Apps", action: .moreApps))
- stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
- for v in stack.arrangedSubviews {
- v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- return root
- }
- private func roundedCard() -> NSView {
- let view = NSView()
- view.translatesAutoresizingMaskIntoConstraints = false
- view.wantsLayer = true
- view.layer?.cornerRadius = 12
- view.layer?.backgroundColor = palette.sectionCard.cgColor
- view.layer?.borderColor = palette.inputBorder.cgColor
- view.layer?.borderWidth = 1
- view.layer?.shadowColor = NSColor.black.cgColor
- view.layer?.shadowOpacity = 0.28
- view.layer?.shadowOffset = CGSize(width: 0, height: -1)
- view.layer?.shadowRadius = 10
- return view
- }
- private func settingsDarkModeRow(enabled: Bool) -> NSView {
- let row = NSView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 44).isActive = true
- row.wantsLayer = true
- row.layer?.cornerRadius = 10
- let icon = NSTextField(labelWithString: "◐")
- icon.translatesAutoresizingMaskIntoConstraints = false
- icon.font = NSFont.systemFont(ofSize: 18, weight: .medium)
- icon.textColor = palette.textPrimary
- let title = NSTextField(labelWithString: "Dark Mode")
- title.translatesAutoresizingMaskIntoConstraints = false
- title.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
- title.textColor = palette.textPrimary
- let toggle = NSSwitch()
- toggle.translatesAutoresizingMaskIntoConstraints = false
- toggle.state = enabled ? .on : .off
- toggle.target = self
- toggle.action = #selector(darkModeToggled(_:))
- darkToggle = toggle
- row.addSubview(icon)
- row.addSubview(title)
- row.addSubview(toggle)
- row.layer?.backgroundColor = NSColor.clear.cgColor
- NSLayoutConstraint.activate([
- icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
- icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
- title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
- toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
- ])
- return row
- }
- private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView {
- let row = HoverTrackingView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 42).isActive = true
- let iconLabel = NSTextField(labelWithString: icon)
- iconLabel.translatesAutoresizingMaskIntoConstraints = false
- iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
- iconLabel.textColor = palette.textPrimary
- let titleLabel = NSTextField(labelWithString: title)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
- titleLabel.textColor = palette.textPrimary
- row.addSubview(iconLabel)
- row.addSubview(titleLabel)
- NSLayoutConstraint.activate([
- iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
- iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
- titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor)
- ])
- let click = NSClickGestureRecognizer(target: self, action: #selector(settingsActionClicked(_:)))
- row.addGestureRecognizer(click)
- row.identifier = NSUserInterfaceItemIdentifier(rawValue: "\(action.rawValue)")
- row.onHoverChanged = { hovering in
- row.wantsLayer = true
- row.layer?.cornerRadius = 10
- row.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
- }
- row.onHoverChanged?(false)
- return row
- }
- @objc private func darkModeToggled(_ sender: NSSwitch) {
- onToggleDarkMode(sender.state == .on)
- }
- @objc private func settingsActionClicked(_ sender: NSClickGestureRecognizer) {
- guard let view = sender.view,
- let raw = Int(view.identifier?.rawValue ?? ""),
- let action = SettingsAction(rawValue: raw) else { return }
- onAction(action)
- }
- }
- private extension ViewController {
- func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
- let view = NSView()
- view.wantsLayer = true
- view.layer?.backgroundColor = color.cgColor
- view.layer?.cornerRadius = cornerRadius
- return view
- }
- func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
- view.layer?.borderColor = borderColor.cgColor
- view.layer?.borderWidth = borderWidth
- if shadow {
- view.layer?.shadowColor = NSColor.black.cgColor
- view.layer?.shadowOpacity = 0.18
- view.layer?.shadowOffset = CGSize(width: 0, height: -1)
- view.layer?.shadowRadius = 5
- }
- }
- func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.translatesAutoresizingMaskIntoConstraints = false
- label.textColor = color
- label.font = font
- return label
- }
- func iconLabel(_ text: String, size: CGFloat) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.translatesAutoresizingMaskIntoConstraints = false
- label.font = NSFont.systemFont(ofSize: size)
- return label
- }
- func sidebarSectionTitle(_ text: String) -> NSTextField {
- let field = textLabel(text, font: typography.sidebarSection, color: palette.textMuted)
- field.alignment = .left
- return field
- }
- func sidebarItem(_ text: String, icon: String, page: SidebarPage, logoImageName: String? = nil, logoIconWidth: CGFloat = 18, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true, showsDisclosure: Bool = false) -> NSView {
- let item = HoverButton(title: "", target: self, action: #selector(sidebarButtonClicked(_:)))
- item.tag = page.rawValue
- item.isBordered = false
- item.wantsLayer = true
- item.layer?.cornerRadius = 10
- item.layer?.backgroundColor = NSColor.clear.cgColor
- item.translatesAutoresizingMaskIntoConstraints = false
- item.heightAnchor.constraint(equalToConstant: 36).isActive = true
- item.layer?.borderWidth = 0
- sidebarPageByView[ObjectIdentifier(item)] = page
- let leadingView: NSView
- if let name = logoImageName, let logo = NSImage(named: name) {
- logo.isTemplate = true
- let imageView = NSImageView(image: logo)
- imageView.translatesAutoresizingMaskIntoConstraints = false
- imageView.imageScaling = .scaleProportionallyDown
- imageView.imageAlignment = .alignCenter
- imageView.isEditable = false
- leadingView = imageView
- } else {
- leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary)
- }
- let titleLabel = textLabel(text, font: typography.sidebarItem, color: palette.textSecondary)
- titleLabel.alignment = .left
- item.addSubview(leadingView)
- item.addSubview(titleLabel)
- var constraints: [NSLayoutConstraint] = [
- leadingView.leadingAnchor.constraint(equalTo: item.leadingAnchor, constant: 12),
- leadingView.centerYAnchor.constraint(equalTo: item.centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8),
- titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor)
- ]
- if showsDisclosure {
- let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: palette.textSecondary)
- chevron.translatesAutoresizingMaskIntoConstraints = false
- chevron.alignment = .right
- item.addSubview(chevron)
- constraints.append(contentsOf: [
- chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10),
- chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8)
- ])
- } else {
- constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10))
- }
- if logoImageName != nil {
- let h = logoIconWidth * logoHeightMultiplier
- constraints.append(contentsOf: [
- leadingView.widthAnchor.constraint(equalToConstant: logoIconWidth),
- leadingView.heightAnchor.constraint(equalToConstant: h)
- ])
- }
- NSLayoutConstraint.activate(constraints)
- applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate)
- item.onHoverChanged = { [weak self, weak item] hovering in
- guard let self, let item else { return }
- self.applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate, hovering: hovering)
- }
- return item
- }
- func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool, hovering: Bool = false) {
- let selected = (page == selectedSidebarPage)
- let hoverColor = darkModeEnabled ? NSColor(calibratedWhite: 1, alpha: 0.07) : NSColor(calibratedWhite: 0, alpha: 0.08)
- item.layer?.backgroundColor = (selected ? palette.primaryBlue : (hovering ? hoverColor : NSColor.clear)).cgColor
- let tint = selected ? NSColor.white : palette.textSecondary
- let sidebarIconTint = darkModeEnabled ? tint : NSColor.black
- guard item.subviews.count >= 2 else { return }
- let leading = item.subviews[0]
- let title = item.subviews.first { $0 is NSTextField } as? NSTextField
- title?.textColor = tint
- // Optional disclosure chevron (if present) is the last text field.
- if let chevron = item.subviews.last as? NSTextField, chevron !== title {
- chevron.textColor = sidebarIconTint
- }
- if let imageView = leading as? NSImageView {
- if logoTemplate {
- imageView.contentTintColor = sidebarIconTint
- }
- } else if let iconField = leading as? NSTextField {
- iconField.textColor = sidebarIconTint
- }
- }
- func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView {
- let button = HoverTrackingView()
- button.wantsLayer = true
- button.layer?.cornerRadius = 9
- button.layer?.backgroundColor = color.cgColor
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: width).isActive = true
- button.heightAnchor.constraint(equalToConstant: 36).isActive = true
- styleSurface(button, borderColor: title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder, borderWidth: 1, shadow: false)
- if title == "Cancel" {
- button.layer?.backgroundColor = palette.cancelButton.cgColor
- }
- let label = textLabel(title, font: typography.buttonText, color: textColor)
- button.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
- ])
- let baseColor = (title == "Cancel") ? palette.cancelButton : color
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverColor = baseColor.blended(withFraction: 0.12, of: hoverBlend) ?? baseColor
- button.onHoverChanged = { hovering in
- button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
- }
- button.onHoverChanged?(false)
- return button
- }
- func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
- let button = HoverTrackingView()
- button.wantsLayer = true
- button.layer?.cornerRadius = size / 2
- button.layer?.backgroundColor = palette.inputBackground.cgColor
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: size).isActive = true
- button.heightAnchor.constraint(equalToConstant: size).isActive = true
- styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
- let iconView = NSImageView()
- iconView.translatesAutoresizingMaskIntoConstraints = false
- iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
- iconView.symbolConfiguration = symbolConfig
- iconView.contentTintColor = palette.textSecondary
- button.addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
- ])
- let baseColor = palette.inputBackground
- let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
- let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
- button.onHoverChanged = { hovering in
- button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
- }
- button.onHoverChanged?(false)
- button.onClick = onClick
- return button
- }
- }
- // MARK: - Schedule actions (OAuth entry)
- private extension ViewController {
- @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
- scheduleReloadClicked()
- }
- @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
- scrollScheduleCards(direction: -1)
- }
- @objc func scheduleScrollRightPressed(_ sender: NSButton) {
- scrollScheduleCards(direction: 1)
- }
- @objc func scheduleCardButtonPressed(_ sender: NSButton) {
- guard let raw = sender.identifier?.rawValue,
- let url = URL(string: raw) else { return }
- openMeetingURL(url)
- }
- @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
- scheduleConnectClicked()
- }
- private func scheduleInitialHeadingText() -> String {
- googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
- }
- @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
- guard let selectedItem = sender.selectedItem,
- let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
- applyScheduleFilter(filter)
- }
- private func applyScheduleFilter(_ filter: ScheduleFilter) {
- scheduleFilter = filter
- scheduleFilterDropdown?.selectItem(at: filter.rawValue)
- Task { [weak self] in
- await self?.loadSchedule()
- }
- }
- private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
- if meeting.isAllDay { return "All day" }
- let f = DateFormatter()
- f.locale = Locale.current
- f.timeZone = TimeZone.current
- f.dateStyle = .none
- f.timeStyle = .short
- 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"
- }
- let day = Calendar.current.startOfDay(for: first.startDate)
- let f = DateFormatter()
- f.locale = Locale.current
- f.timeZone = TimeZone.current
- f.dateFormat = "EEEE, d MMM"
- return f.string(from: day)
- }
- private func openMeetingURL(_ url: URL) {
- NSWorkspace.shared.open(url)
- }
- 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()
- }
- if meetings.isEmpty {
- let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
- empty.translatesAutoresizingMaskIntoConstraints = false
- 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)
- label.translatesAutoresizingMaskIntoConstraints = false
- empty.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
- ])
- stack.addArrangedSubview(empty)
- return
- }
- for meeting in meetings {
- stack.addArrangedSubview(scheduleCard(meeting: meeting))
- }
- }
- private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
- switch scheduleFilter {
- case .all:
- return meetings
- case .today:
- let start = Calendar.current.startOfDay(for: Date())
- let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
- return meetings.filter { $0.startDate >= start && $0.startDate < end }
- case .week:
- let now = Date()
- let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
- return meetings.filter { $0.startDate >= now && $0.startDate <= end }
- }
- }
- 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 {
- await MainActor.run {
- updateGoogleAuthButtonTitle()
- applyGoogleProfile(nil)
- scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
- if let stack = scheduleCardsStack {
- renderScheduleCards(into: stack, meetings: [])
- }
- }
- return
- }
- 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)
- }
- }
- } 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: [])
- }
- showSimpleError("Couldn’t load schedule.", error: error)
- }
- }
- }
- func showScheduleHelp() {
- let alert = NSAlert()
- alert.messageText = "Google schedule"
- alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
- alert.addButton(withTitle: "OK")
- alert.runModal()
- }
- func scheduleReloadClicked() {
- Task { [weak self] in
- guard let self else { return }
- do {
- try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
- _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
- await MainActor.run {
- self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
- self.pageCache[.joinMeetings] = nil
- self.showSidebarPage(.joinMeetings)
- }
- await self.loadSchedule()
- } catch {
- await MainActor.run {
- self.showSimpleError("Couldn’t refresh schedule.", error: error)
- }
- }
- }
- }
- func scheduleConnectClicked() {
- Task { [weak self] in
- guard let self else { return }
- do {
- if self.googleOAuth.loadTokens() != nil {
- await MainActor.run { self.showGoogleAccountMenu() }
- 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 showGoogleAccountMenu() {
- guard let button = scheduleGoogleAuthButton else { return }
- let menu = NSMenu()
- let name = scheduleCurrentProfile?.name ?? "Google account"
- let email = scheduleCurrentProfile?.email ?? "Signed in"
- let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
- accountItem.isEnabled = false
- menu.addItem(accountItem)
- menu.addItem(.separator())
- let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
- logoutItem.target = self
- menu.addItem(logoutItem)
- let point = NSPoint(x: 0, y: button.bounds.height + 2)
- menu.popUp(positioning: nil, at: point, in: button)
- }
- @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
- do {
- try googleOAuth.signOut()
- updateGoogleAuthButtonTitle()
- applyGoogleProfile(nil)
- scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
- if let stack = scheduleCardsStack {
- renderScheduleCards(into: stack, meetings: [])
- }
- } catch {
- showSimpleError("Couldn’t logout Google account.", error: error)
- }
- }
- private func updateGoogleAuthButtonTitle() {
- let signedIn = (googleOAuth.loadTokens() != nil)
- guard let button = scheduleGoogleAuthButton else { return }
- let profileName = scheduleCurrentProfile?.name ?? "Google account"
- let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
- let title = signedIn ? "\(profileName) · \(profileEmail)" : "Sign in with Google"
- let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
- let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .font: titleFont,
- .foregroundColor: titleColor
- ])
- let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
- let idealWidth = ceil(textWidth + 80) // icon + spacing + side padding
- scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
- if signedIn {
- let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
- button.image = symbol.flatMap { paddedTrailingImage($0, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) }
- button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .regular)
- button.contentTintColor = palette.textPrimary
- } else {
- if let g = NSImage(named: "GoogleGLogo") {
- button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
- } else {
- button.image = nil
- }
- button.contentTintColor = nil
- }
- button.contentTintColor = signedIn ? palette.textPrimary : nil
- applyGoogleAuthButtonSurface()
- }
- 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
- scheduleCurrentProfile = profile
- updateGoogleAuthButtonTitle()
- guard let profile, 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?.scheduleGoogleAuthButton?.image = self?.paddedTrailingImage(image, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
- self?.scheduleGoogleAuthButton?.contentTintColor = nil
- }
- } catch {
- // Keep placeholder avatar if image fetch fails.
- }
- }
- }
- private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
- let result = NSImage(size: size)
- result.lockFocus()
- image.draw(in: NSRect(origin: .zero, size: size),
- from: NSRect(origin: .zero, size: image.size),
- operation: .copy,
- fraction: 1.0)
- result.unlockFocus()
- result.isTemplate = false
- return result
- }
- private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
- let base = resizedImage(image, to: iconSize)
- let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
- let result = NSImage(size: canvas)
- result.lockFocus()
- base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
- from: NSRect(origin: .zero, size: base.size),
- operation: .copy,
- fraction: 1.0)
- result.unlockFocus()
- result.isTemplate = false
- return result
- }
- private func applyGoogleAuthButtonSurface() {
- guard let button = scheduleGoogleAuthButton else { return }
- let isDark = darkModeEnabled
- let baseBackground = isDark
- ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
- : NSColor.white
- let hoverBlend = isDark ? NSColor.white : NSColor.black
- let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
- let baseBorder = isDark
- ? NSColor(calibratedWhite: 0.50, alpha: 1)
- : NSColor(calibratedWhite: 0.72, alpha: 1)
- let hoverBorder = isDark
- ? NSColor(calibratedWhite: 0.62, alpha: 1)
- : NSColor(calibratedWhite: 0.56, alpha: 1)
- button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
- button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
- }
- @MainActor
- func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
- if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
- let alert = NSAlert()
- alert.messageText = "Enter Google OAuth credentials"
- alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
- let accessory = NSStackView()
- accessory.orientation = .vertical
- accessory.spacing = 8
- accessory.alignment = .leading
- let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
- idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
- idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
- let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
- secretField.placeholderString = "Client Secret (GOCSPX-...)"
- secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
- accessory.addArrangedSubview(idField)
- accessory.addArrangedSubview(secretField)
- alert.accessoryView = accessory
- alert.addButton(withTitle: "Save")
- alert.addButton(withTitle: "Cancel")
- // Keep this synchronous to avoid additional sheet state handling.
- let response = alert.runModal()
- if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
- let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
- if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
- googleOAuth.setClientIdForTesting(idValue)
- googleOAuth.setClientSecretForTesting(secretValue)
- }
- func showSimpleError(_ title: String, error: Error) {
- DispatchQueue.main.async {
- let alert = NSAlert()
- alert.alertStyle = .warning
- alert.messageText = title
- alert.informativeText = error.localizedDescription
- alert.addButton(withTitle: "OK")
- alert.runModal()
- }
- }
- }
- private struct Palette {
- let pageBackground: NSColor
- let sidebarBackground: NSColor
- let sectionCard: NSColor
- let tabBarBackground: NSColor
- let tabIdleBackground: NSColor
- let inputBackground: NSColor
- let inputBorder: NSColor
- let primaryBlue: NSColor
- let primaryBlueBorder: NSColor
- let cancelButton: NSColor
- let meetingBadge: NSColor
- let separator: NSColor
- let textPrimary: NSColor
- let textSecondary: NSColor
- let textTertiary: NSColor
- let textMuted: NSColor
- init(isDarkMode: Bool) {
- if isDarkMode {
- pageBackground = NSColor(calibratedRed: 10.0 / 255.0, green: 11.0 / 255.0, blue: 12.0 / 255.0, alpha: 1)
- sidebarBackground = NSColor(calibratedRed: 16.0 / 255.0, green: 17.0 / 255.0, blue: 19.0 / 255.0, alpha: 1)
- sectionCard = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
- tabBarBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
- tabIdleBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
- inputBackground = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
- inputBorder = NSColor(calibratedRed: 38.0 / 255.0, green: 40.0 / 255.0, blue: 44.0 / 255.0, alpha: 1)
- primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
- primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
- cancelButton = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
- meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
- separator = NSColor(calibratedRed: 26.0 / 255.0, green: 27.0 / 255.0, blue: 30.0 / 255.0, alpha: 1)
- textPrimary = NSColor(calibratedWhite: 0.98, alpha: 1)
- textSecondary = NSColor(calibratedWhite: 0.78, alpha: 1)
- textTertiary = NSColor(calibratedWhite: 0.66, alpha: 1)
- textMuted = NSColor(calibratedWhite: 0.44, alpha: 1)
- } else {
- pageBackground = NSColor(calibratedRed: 244.0 / 255.0, green: 246.0 / 255.0, blue: 249.0 / 255.0, alpha: 1)
- sidebarBackground = NSColor(calibratedRed: 232.0 / 255.0, green: 236.0 / 255.0, blue: 242.0 / 255.0, alpha: 1)
- sectionCard = NSColor.white
- tabBarBackground = NSColor.white
- tabIdleBackground = NSColor.white
- inputBackground = NSColor(calibratedRed: 247.0 / 255.0, green: 249.0 / 255.0, blue: 252.0 / 255.0, alpha: 1)
- inputBorder = NSColor(calibratedRed: 211.0 / 255.0, green: 218.0 / 255.0, blue: 228.0 / 255.0, alpha: 1)
- primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
- primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
- cancelButton = NSColor(calibratedRed: 240.0 / 255.0, green: 243.0 / 255.0, blue: 248.0 / 255.0, alpha: 1)
- meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
- separator = NSColor(calibratedRed: 212.0 / 255.0, green: 219.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
- textPrimary = NSColor(calibratedRed: 32.0 / 255.0, green: 38.0 / 255.0, blue: 47.0 / 255.0, alpha: 1)
- textSecondary = NSColor(calibratedRed: 82.0 / 255.0, green: 92.0 / 255.0, blue: 107.0 / 255.0, alpha: 1)
- textTertiary = NSColor(calibratedRed: 110.0 / 255.0, green: 120.0 / 255.0, blue: 136.0 / 255.0, alpha: 1)
- textMuted = NSColor(calibratedRed: 134.0 / 255.0, green: 145.0 / 255.0, blue: 162.0 / 255.0, alpha: 1)
- }
- }
- }
- private struct Typography {
- let sidebarBrand = NSFont.systemFont(ofSize: 26, weight: .bold)
- let sidebarSection = NSFont.systemFont(ofSize: 11, weight: .medium)
- let sidebarIcon = NSFont.systemFont(ofSize: 12, weight: .medium)
- let sidebarItem = NSFont.systemFont(ofSize: 16, weight: .medium)
- let pageTitle = NSFont.systemFont(ofSize: 27, weight: .semibold)
- let joinWithURLTitle = NSFont.systemFont(ofSize: 17, weight: .semibold)
- let sectionTitleBold = NSFont.systemFont(ofSize: 25, weight: .bold)
- let dateHeading = NSFont.systemFont(ofSize: 18, weight: .medium)
- let tabIcon = NSFont.systemFont(ofSize: 13, weight: .regular)
- let tabTitle = NSFont.systemFont(ofSize: 31 / 2, weight: .semibold)
- let fieldLabel = NSFont.systemFont(ofSize: 15, weight: .medium)
- let inputPlaceholder = NSFont.systemFont(ofSize: 14, weight: .regular)
- let buttonText = NSFont.systemFont(ofSize: 16, weight: .medium)
- let filterText = NSFont.systemFont(ofSize: 15, weight: .regular)
- let filterArrow = NSFont.systemFont(ofSize: 12, weight: .regular)
- let iconButton = NSFont.systemFont(ofSize: 14, weight: .medium)
- let cardIcon = NSFont.systemFont(ofSize: 8, weight: .bold)
- let cardTitle = NSFont.systemFont(ofSize: 15, weight: .semibold)
- let cardSubtitle = NSFont.systemFont(ofSize: 13, weight: .bold)
- let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
- }
- // MARK: - In-app browser (macOS WKWebView + chrome)
- // Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
- private enum InAppBrowserURLPolicy: Equatable {
- case allowAll
- case whitelist(hostSuffixes: [String])
- }
- private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
- let scheme = (url.scheme ?? "").lowercased()
- if scheme == "about" { return true }
- guard scheme == "http" || scheme == "https" else { return false }
- guard let host = url.host?.lowercased() else { return false }
- switch policy {
- case .allowAll:
- return true
- case .whitelist(let suffixes):
- for suffix in suffixes {
- let s = suffix.lowercased()
- if host == s || host.hasSuffix("." + s) { return true }
- }
- return false
- }
- }
- private enum InAppBrowserWebKitSupport {
- static func makeWebViewConfiguration() -> WKWebViewConfiguration {
- let config = WKWebViewConfiguration()
- config.websiteDataStore = .default()
- config.preferences.javaScriptCanOpenWindowsAutomatically = true
- if #available(macOS 12.3, *) {
- config.preferences.isElementFullscreenEnabled = true
- }
- config.mediaTypesRequiringUserActionForPlayback = []
- if #available(macOS 11.0, *) {
- config.defaultWebpagePreferences.allowsContentJavaScript = true
- }
- config.applicationNameForUserAgent = "MeetingsApp/1.0"
- return config
- }
- }
- private final class InAppBrowserWindowController: NSWindowController {
- private static let defaultContentSize = NSSize(width: 1100, height: 760)
- private static let minimumContentSize = NSSize(width: 800, height: 520)
- private let browserViewController = InAppBrowserContainerViewController()
- init() {
- let browserWindow = NSWindow(
- contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
- backing: .buffered,
- defer: false
- )
- browserWindow.title = "Browser"
- browserWindow.isRestorable = false
- browserWindow.setFrameAutosaveName("")
- browserWindow.minSize = browserWindow.frameRect(forContentRect: NSRect(origin: .zero, size: Self.minimumContentSize)).size
- browserWindow.center()
- browserWindow.contentViewController = browserViewController
- super.init(window: browserWindow)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
- /// Resets size and position each time the browser is shown so a previously tiny window is never reused.
- func applyDefaultFrameCenteredOnVisibleScreen() {
- guard let w = window, let screen = w.screen ?? NSScreen.main else { return }
- let windowFrame = w.frameRect(forContentRect: NSRect(origin: .zero, size: Self.defaultContentSize))
- let vf = screen.visibleFrame
- var frame = windowFrame
- frame.origin.x = vf.midX - frame.width / 2
- frame.origin.y = vf.midY - frame.height / 2
- if frame.maxX > vf.maxX { frame.origin.x = vf.maxX - frame.width }
- if frame.minX < vf.minX { frame.origin.x = vf.minX }
- if frame.maxY > vf.maxY { frame.origin.y = vf.maxY - frame.height }
- if frame.minY < vf.minY { frame.origin.y = vf.minY }
- w.setFrame(frame, display: true)
- }
- func load(url: URL, policy: InAppBrowserURLPolicy) {
- browserViewController.setNavigationPolicy(policy)
- browserViewController.load(url: url)
- }
- }
- private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
- private var webView: WKWebView!
- private var webContainerView: NSView!
- private weak var urlField: NSTextField?
- private var backButton: NSButton!
- private var forwardButton: NSButton!
- private var reloadStopButton: NSButton!
- private var goButton: NSButton!
- private var progressBar: NSProgressIndicator!
- private var lastLoadedURL: URL?
- private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
- private var processTerminateRetryCount = 0
- /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
- private let maxProcessTerminateRetries = 3
- private var kvoTokens: [NSKeyValueObservation] = []
- deinit {
- kvoTokens.removeAll()
- }
- func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
- navigationPolicy = policy
- }
- override func loadView() {
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- let wv = makeWebView()
- webView = wv
- let webHost = NSView()
- webHost.translatesAutoresizingMaskIntoConstraints = false
- webHost.wantsLayer = true
- webHost.addSubview(wv)
- NSLayoutConstraint.activate([
- wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
- wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
- wv.topAnchor.constraint(equalTo: webHost.topAnchor),
- wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
- ])
- webContainerView = webHost
- let toolbar = NSStackView()
- toolbar.translatesAutoresizingMaskIntoConstraints = false
- toolbar.orientation = .horizontal
- toolbar.spacing = 8
- toolbar.alignment = .centerY
- toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
- backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
- backButton.target = self
- backButton.action = #selector(goBack)
- forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
- forwardButton.target = self
- forwardButton.action = #selector(goForward)
- reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
- reloadStopButton.target = self
- reloadStopButton.action = #selector(reloadOrStop)
- let field = NSTextField(string: "")
- field.translatesAutoresizingMaskIntoConstraints = false
- field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
- field.placeholderString = "Address"
- field.cell?.sendsActionOnEndEditing = false
- field.delegate = self
- urlField = field
- goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
- goButton.translatesAutoresizingMaskIntoConstraints = false
- goButton.bezelStyle = .rounded
- toolbar.addArrangedSubview(backButton)
- toolbar.addArrangedSubview(forwardButton)
- toolbar.addArrangedSubview(reloadStopButton)
- toolbar.addArrangedSubview(field)
- toolbar.addArrangedSubview(goButton)
- field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
- let bar = NSProgressIndicator()
- bar.translatesAutoresizingMaskIntoConstraints = false
- bar.style = .bar
- bar.isIndeterminate = false
- bar.minValue = 0
- bar.maxValue = 1
- bar.doubleValue = 0
- bar.isHidden = true
- progressBar = bar
- let separator = NSBox()
- separator.translatesAutoresizingMaskIntoConstraints = false
- separator.boxType = .separator
- webView.navigationDelegate = self
- webView.uiDelegate = self
- root.addSubview(toolbar)
- root.addSubview(bar)
- root.addSubview(separator)
- root.addSubview(webHost)
- NSLayoutConstraint.activate([
- toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- toolbar.topAnchor.constraint(equalTo: root.topAnchor),
- bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
- bar.heightAnchor.constraint(equalToConstant: 3),
- separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
- webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
- webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
- ])
- view = root
- installWebViewObservers()
- syncToolbarFromWebView()
- }
- private func makeWebView() -> WKWebView {
- let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
- wv.translatesAutoresizingMaskIntoConstraints = false
- return wv
- }
- private func teardownWebViewObservers() {
- kvoTokens.removeAll()
- }
- /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
- private func replaceWebViewAndLoad(url: URL) {
- teardownWebViewObservers()
- webView.navigationDelegate = nil
- webView.uiDelegate = nil
- webView.removeFromSuperview()
- let wv = makeWebView()
- webView = wv
- webContainerView.addSubview(wv)
- NSLayoutConstraint.activate([
- wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
- wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
- wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
- wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
- ])
- webView.navigationDelegate = self
- webView.uiDelegate = self
- installWebViewObservers()
- syncToolbarFromWebView()
- webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
- }
- private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
- let b = NSButton()
- b.translatesAutoresizingMaskIntoConstraints = false
- b.bezelStyle = .texturedRounded
- b.setAccessibilityLabel(accessibilityDescription)
- if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
- b.image = img
- b.imagePosition = .imageOnly
- } else {
- b.title = title
- }
- b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
- return b
- }
- private func installWebViewObservers() {
- kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
- self?.syncToolbarFromWebView()
- })
- kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
- self?.syncToolbarFromWebView()
- })
- kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
- self?.syncToolbarFromWebView()
- })
- kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
- self?.syncProgressFromWebView()
- })
- kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
- self?.syncWindowTitle()
- })
- kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
- self?.syncAddressFieldFromWebView()
- })
- }
- private func syncToolbarFromWebView() {
- backButton?.isEnabled = webView.canGoBack
- forwardButton?.isEnabled = webView.canGoForward
- if webView.isLoading {
- if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
- reloadStopButton.image = img
- reloadStopButton.imagePosition = .imageOnly
- reloadStopButton.title = ""
- } else {
- reloadStopButton.title = "Stop"
- }
- } else {
- if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
- reloadStopButton.image = img
- reloadStopButton.imagePosition = .imageOnly
- reloadStopButton.title = ""
- } else {
- reloadStopButton.title = "Reload"
- }
- }
- syncProgressFromWebView()
- }
- private func syncProgressFromWebView() {
- guard let progressBar else { return }
- if webView.isLoading {
- progressBar.isHidden = false
- progressBar.doubleValue = webView.estimatedProgress
- } else {
- progressBar.isHidden = true
- progressBar.doubleValue = 0
- }
- }
- private func syncAddressFieldFromWebView() {
- guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
- urlField.stringValue = url.absoluteString
- }
- private func syncWindowTitle() {
- let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- let host = webView.url?.host ?? ""
- view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
- }
- func load(url: URL) {
- lastLoadedURL = url
- processTerminateRetryCount = 0
- urlField?.stringValue = url.absoluteString
- webView.load(URLRequest(url: url))
- syncWindowTitle()
- }
- @objc private func goBack() {
- webView.goBack()
- }
- @objc private func goForward() {
- webView.goForward()
- }
- @objc private func reloadOrStop() {
- if webView.isLoading {
- webView.stopLoading()
- } else {
- webView.reload()
- }
- }
- @objc private func addressFieldSubmitted() {
- let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard raw.isEmpty == false else { return }
- var normalized = raw
- if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
- normalized = "https://\(normalized)"
- }
- guard let url = URL(string: normalized),
- let scheme = url.scheme?.lowercased(),
- scheme == "http" || scheme == "https",
- url.host != nil
- else {
- let alert = NSAlert()
- alert.messageText = "Invalid address"
- alert.informativeText = "Enter a valid web address, for example https://example.com"
- alert.addButton(withTitle: "OK")
- alert.runModal()
- return
- }
- guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
- presentBlockedHostAlert()
- return
- }
- load(url: url)
- }
- private func presentBlockedHostAlert() {
- let alert = NSAlert()
- alert.messageText = "Address not allowed"
- alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
- alert.addButton(withTitle: "OK")
- alert.runModal()
- }
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- processTerminateRetryCount = 0
- syncAddressFieldFromWebView()
- syncWindowTitle()
- }
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
- let nsError = error as NSError
- if nsError.code == NSURLErrorCancelled {
- return
- }
- let alert = NSAlert()
- alert.messageText = "Unable to load page"
- alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
- alert.addButton(withTitle: "Try Again")
- alert.addButton(withTitle: "OK")
- if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
- processTerminateRetryCount = 0
- webView.load(URLRequest(url: url))
- }
- }
- func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
- guard let url = lastLoadedURL else { return }
- if processTerminateRetryCount < maxProcessTerminateRetries {
- processTerminateRetryCount += 1
- replaceWebViewAndLoad(url: url)
- return
- }
- let alert = NSAlert()
- alert.messageText = "Page stopped loading"
- alert.informativeText =
- "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
- alert.addButton(withTitle: "Try Again")
- alert.addButton(withTitle: "OK")
- if alert.runModal() == .alertFirstButtonReturn {
- processTerminateRetryCount = 0
- replaceWebViewAndLoad(url: url)
- }
- }
- func webView(
- _ webView: WKWebView,
- decidePolicyFor navigationAction: WKNavigationAction,
- decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
- ) {
- guard let url = navigationAction.request.url else {
- decisionHandler(.allow)
- return
- }
- let scheme = (url.scheme ?? "").lowercased()
- if scheme == "mailto" || scheme == "tel" {
- decisionHandler(.cancel)
- return
- }
- if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
- if navigationAction.targetFrame?.isMainFrame != false {
- DispatchQueue.main.async { [weak self] in
- self?.presentBlockedHostAlert()
- }
- }
- decisionHandler(.cancel)
- return
- }
- decisionHandler(.allow)
- }
- func webView(
- _ webView: WKWebView,
- createWebViewWith configuration: WKWebViewConfiguration,
- for navigationAction: WKNavigationAction,
- windowFeatures: WKWindowFeatures
- ) -> WKWebView? {
- if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
- if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
- webView.load(URLRequest(url: requestURL))
- } else {
- presentBlockedHostAlert()
- }
- }
- return nil
- }
- func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
- if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
- addressFieldSubmitted()
- return true
- }
- return false
- }
- }
|