Nessuna descrizione

ViewController.swift 205KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762
  1. //
  2. // ViewController.swift
  3. // meetings_app
  4. //
  5. // Created by Dev Mac 1 on 06/04/2026.
  6. //
  7. import Cocoa
  8. import QuartzCore
  9. import WebKit
  10. import AuthenticationServices
  11. import StoreKit
  12. private enum SidebarPage: Int {
  13. case joinMeetings = 0
  14. case photo = 1
  15. case video = 2
  16. case tutorials = 3
  17. case settings = 4
  18. }
  19. private enum ZoomJoinMode: Int {
  20. case id = 0
  21. case url = 1
  22. }
  23. private enum SettingsAction: Int {
  24. case restore = 0
  25. case rateUs = 1
  26. case support = 2
  27. case moreApps = 3
  28. case shareApp = 4
  29. case upgrade = 5
  30. }
  31. private enum PremiumPlan: Int {
  32. case weekly = 0
  33. case monthly = 1
  34. case yearly = 2
  35. case lifetime = 3
  36. }
  37. private enum PremiumStoreProduct {
  38. static let weekly = "com.mqldev.meetingsapp.premium.weekly"
  39. static let monthly = "com.mqldev.meetingsapp.premium.monthly"
  40. static let yearly = "com.mqldev.meetingsapp.premium.yearly"
  41. static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
  42. static let allIDs = [weekly, monthly, yearly, lifetime]
  43. static func productID(for plan: PremiumPlan) -> String {
  44. switch plan {
  45. case .weekly: return weekly
  46. case .monthly: return monthly
  47. case .yearly: return yearly
  48. case .lifetime: return lifetime
  49. }
  50. }
  51. static func plan(for productID: String) -> PremiumPlan? {
  52. switch productID {
  53. case weekly: return .weekly
  54. case monthly: return .monthly
  55. case yearly: return .yearly
  56. case lifetime: return .lifetime
  57. default: return nil
  58. }
  59. }
  60. }
  61. @MainActor
  62. private final class StoreKitCoordinator {
  63. enum PurchaseOutcome {
  64. case success
  65. case cancelled
  66. case pending
  67. case unavailable
  68. case alreadyOwned
  69. case failed(String)
  70. }
  71. private(set) var productsByID: [String: Product] = [:]
  72. private(set) var activeEntitlementProductIDs: Set<String> = []
  73. private(set) var lastProductLoadError: String?
  74. var onEntitlementsChanged: ((Bool) -> Void)?
  75. var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
  76. var hasLifetimeAccess: Bool { activeEntitlementProductIDs.contains(PremiumStoreProduct.lifetime) }
  77. var activeNonLifetimePlan: PremiumPlan? {
  78. activeEntitlementProductIDs
  79. .compactMap { PremiumStoreProduct.plan(for: $0) }
  80. .filter { $0 != .lifetime }
  81. .max(by: { $0.rawValue < $1.rawValue })
  82. }
  83. private var transactionUpdatesTask: Task<Void, Never>?
  84. deinit {
  85. transactionUpdatesTask?.cancel()
  86. }
  87. func start() async {
  88. if transactionUpdatesTask == nil {
  89. transactionUpdatesTask = Task { [weak self] in
  90. await self?.observeTransactionUpdates()
  91. }
  92. }
  93. await refreshProducts()
  94. await refreshEntitlements()
  95. }
  96. func refreshProducts() async {
  97. do {
  98. let products = try await Product.products(for: PremiumStoreProduct.allIDs)
  99. productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
  100. lastProductLoadError = nil
  101. } catch {
  102. productsByID = [:]
  103. lastProductLoadError = error.localizedDescription
  104. }
  105. }
  106. func refreshEntitlements() async {
  107. let previousHasPremiumAccess = hasPremiumAccess
  108. var active = Set<String>()
  109. for await entitlement in Transaction.currentEntitlements {
  110. guard case .verified(let transaction) = entitlement else { continue }
  111. guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
  112. if Self.isTransactionActive(transaction) {
  113. active.insert(transaction.productID)
  114. }
  115. }
  116. // Some StoreKit test timelines can briefly report empty current entitlements
  117. // even though a latest verified transaction exists for a non-consumable.
  118. // Merge in latest transactions to keep launch access state accurate.
  119. for productID in PremiumStoreProduct.allIDs {
  120. guard let latest = await Transaction.latest(for: productID),
  121. case .verified(let transaction) = latest,
  122. Self.isTransactionActive(transaction) else { continue }
  123. active.insert(productID)
  124. }
  125. activeEntitlementProductIDs = active
  126. let newHasPremiumAccess = hasPremiumAccess
  127. if newHasPremiumAccess != previousHasPremiumAccess {
  128. onEntitlementsChanged?(newHasPremiumAccess)
  129. }
  130. }
  131. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  132. let productID = PremiumStoreProduct.productID(for: plan)
  133. if activeEntitlementProductIDs.contains(productID) {
  134. return .alreadyOwned
  135. }
  136. guard let product = productsByID[productID] else {
  137. await refreshProducts()
  138. guard let refreshed = productsByID[productID] else {
  139. if let lastProductLoadError, !lastProductLoadError.isEmpty {
  140. return .failed("Unable to load products: \(lastProductLoadError)")
  141. }
  142. let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
  143. let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
  144. return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
  145. }
  146. return await purchase(product: refreshed)
  147. }
  148. return await purchase(product: product)
  149. }
  150. func restorePurchases() async -> String {
  151. do {
  152. try await AppStore.sync()
  153. await refreshEntitlements()
  154. if hasPremiumAccess {
  155. return "Purchases restored successfully."
  156. }
  157. return "No previous premium purchase was found for this Apple ID."
  158. } catch {
  159. return "Restore failed: \(error.localizedDescription)"
  160. }
  161. }
  162. private func purchase(product: Product) async -> PurchaseOutcome {
  163. do {
  164. let result = try await product.purchase()
  165. switch result {
  166. case .success(let verificationResult):
  167. guard case .verified(let transaction) = verificationResult else {
  168. return .failed("Purchase verification failed.")
  169. }
  170. await transaction.finish()
  171. await refreshEntitlements()
  172. return .success
  173. case .pending:
  174. return .pending
  175. case .userCancelled:
  176. return .cancelled
  177. @unknown default:
  178. return .failed("Unknown purchase state.")
  179. }
  180. } catch {
  181. return .failed(error.localizedDescription)
  182. }
  183. }
  184. private func observeTransactionUpdates() async {
  185. for await update in Transaction.updates {
  186. guard case .verified(let transaction) = update else { continue }
  187. if PremiumStoreProduct.allIDs.contains(transaction.productID) {
  188. await refreshEntitlements()
  189. }
  190. await transaction.finish()
  191. }
  192. }
  193. private static func isTransactionActive(_ transaction: Transaction) -> Bool {
  194. if transaction.revocationDate != nil { return false }
  195. if let expirationDate = transaction.expirationDate {
  196. return expirationDate > Date()
  197. }
  198. return true
  199. }
  200. }
  201. final class ViewController: NSViewController {
  202. private struct GoogleProfileDisplay {
  203. let name: String
  204. let email: String
  205. let pictureURL: URL?
  206. }
  207. private var palette = Palette(isDarkMode: true)
  208. private let typography = Typography()
  209. private let launchContentSize = NSSize(width: 920, height: 690)
  210. private let launchMinContentSize = NSSize(width: 760, height: 600)
  211. private var mainContentHost: NSView?
  212. private var sidebarRowViews: [SidebarPage: NSView] = [:]
  213. private var selectedSidebarPage: SidebarPage = .joinMeetings
  214. private var selectedZoomJoinMode: ZoomJoinMode = .id
  215. private var pageCache: [SidebarPage: NSView] = [:]
  216. private var sidebarPageByView = [ObjectIdentifier: SidebarPage]()
  217. private var zoomJoinModeByView = [ObjectIdentifier: ZoomJoinMode]()
  218. private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
  219. private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
  220. private weak var centeredTitleLabel: NSTextField?
  221. private var paywallWindow: NSWindow?
  222. private let paywallContentWidth: CGFloat = 520
  223. private var selectedPremiumPlan: PremiumPlan = .monthly
  224. private var paywallPlanViews: [PremiumPlan: NSView] = [:]
  225. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  226. private weak var paywallOfferLabel: NSTextField?
  227. private weak var paywallContinueLabel: NSTextField?
  228. private weak var paywallContinueButton: NSView?
  229. private weak var sidebarPremiumTitleLabel: NSTextField?
  230. private weak var sidebarPremiumIconView: NSImageView?
  231. private weak var sidebarPremiumButtonView: HoverTrackingView?
  232. private weak var instantMeetCardView: HoverSurfaceView?
  233. private weak var instantMeetTitleLabel: NSTextField?
  234. private weak var instantMeetSubtitleLabel: NSTextField?
  235. private weak var joinWithLinkCardView: HoverSurfaceView?
  236. private weak var joinWithLinkTitleLabel: NSTextField?
  237. private weak var joinMeetPrimaryButton: NSButton?
  238. private weak var meetLinkField: NSTextField?
  239. private weak var browseAddressField: NSTextField?
  240. private var inAppBrowserWindowController: InAppBrowserWindowController?
  241. private let googleOAuth = GoogleOAuthService.shared
  242. private let calendarClient = GoogleCalendarClient()
  243. private let storeKitCoordinator = StoreKitCoordinator()
  244. private var storeKitStartupTask: Task<Void, Never>?
  245. private var paywallPurchaseTask: Task<Void, Never>?
  246. private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
  247. private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
  248. private var paywallContinueEnabled = true
  249. private var paywallUpgradeFlowEnabled = false
  250. private var hasCompletedInitialStoreKitSync = false
  251. private var hasPresentedLaunchPaywall = false
  252. private var hasViewAppearedOnce = false
  253. private var lastKnownPremiumAccess = false
  254. private var displayedScheduleMeetings: [ScheduledMeeting] = []
  255. private enum ScheduleFilter: Int {
  256. case all = 0
  257. case today = 1
  258. case week = 2
  259. }
  260. private var scheduleFilter: ScheduleFilter = .all
  261. private weak var scheduleDateHeadingLabel: NSTextField?
  262. private weak var scheduleCardsStack: NSStackView?
  263. private weak var scheduleCardsScrollView: NSScrollView?
  264. private weak var scheduleScrollLeftButton: NSView?
  265. private weak var scheduleScrollRightButton: NSView?
  266. private weak var scheduleFilterDropdown: NSPopUpButton?
  267. private weak var scheduleGoogleAuthButton: NSButton?
  268. private weak var scheduleGoogleAuthHostView: GoogleProfileAuthHostView?
  269. private var scheduleGoogleAuthHostPadWidthConstraint: NSLayoutConstraint?
  270. private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
  271. private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
  272. private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
  273. /// Circular avatar size when signed in (top-right, Google-style).
  274. private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
  275. private var scheduleGoogleAuthHovering = false
  276. private var scheduleCurrentProfile: GoogleProfileDisplay?
  277. /// Larger copy of the header avatar for the account popover (optional).
  278. private var scheduleProfileMenuAvatar: NSImage?
  279. private var scheduleProfileImageTask: Task<Void, Never>?
  280. private var googleAccountPopover: NSPopover?
  281. /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
  282. private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
  283. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  284. private var darkModeEnabled: Bool {
  285. get {
  286. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  287. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  288. }
  289. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  290. }
  291. private func makeSettingsPopover() -> NSPopover {
  292. let popover = NSPopover()
  293. popover.behavior = .transient
  294. popover.animates = true
  295. let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
  296. popover.contentViewController = SettingsMenuViewController(
  297. palette: palette,
  298. typography: typography,
  299. darkModeEnabled: darkModeEnabled,
  300. showUpgradeInSettings: showUpgradeInSettings,
  301. onToggleDarkMode: { [weak self] enabled in
  302. self?.setDarkMode(enabled)
  303. },
  304. onAction: { [weak self] action in
  305. self?.handleSettingsAction(action)
  306. }
  307. )
  308. return popover
  309. }
  310. private var settingsPopover: NSPopover?
  311. override func viewDidLoad() {
  312. super.viewDidLoad()
  313. // Sync toggle + palette with current macOS appearance on launch.
  314. darkModeEnabled = systemPrefersDarkMode()
  315. palette = Palette(isDarkMode: darkModeEnabled)
  316. storeKitCoordinator.onEntitlementsChanged = { [weak self] hasPremiumAccess in
  317. guard let self else { return }
  318. self.handlePremiumAccessChanged(hasPremiumAccess)
  319. }
  320. setupRootView()
  321. buildMainLayout()
  322. startStoreKit()
  323. }
  324. override func viewDidAppear() {
  325. super.viewDidAppear()
  326. hasViewAppearedOnce = true
  327. presentLaunchPaywallIfNeeded()
  328. applyWindowTitle(for: selectedSidebarPage)
  329. guard let window = view.window else { return }
  330. // Ensure launch size is applied even when macOS tries to restore prior window state.
  331. window.isRestorable = false
  332. window.setFrameAutosaveName("")
  333. DispatchQueue.main.async { [weak self, weak window] in
  334. guard let self, let window else { return }
  335. let frameSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchContentSize)).size
  336. var newFrame = window.frame
  337. newFrame.size = frameSize
  338. window.setFrame(newFrame, display: true)
  339. window.center()
  340. window.minSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchMinContentSize)).size
  341. self.installCenteredTitleIfNeeded(on: window)
  342. }
  343. }
  344. override var representedObject: Any? {
  345. didSet {}
  346. }
  347. deinit {
  348. storeKitStartupTask?.cancel()
  349. paywallPurchaseTask?.cancel()
  350. }
  351. }
  352. private extension ViewController {
  353. func setupRootView() {
  354. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  355. view.wantsLayer = true
  356. view.layer?.backgroundColor = palette.pageBackground.cgColor
  357. }
  358. func systemPrefersDarkMode() -> Bool {
  359. // Use the system-wide appearance setting (not app/window effective appearance).
  360. // When the key is missing, macOS is in Light mode.
  361. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  362. let style = global?["AppleInterfaceStyle"] as? String
  363. return style?.lowercased() == "dark"
  364. }
  365. func buildMainLayout() {
  366. let splitContainer = NSStackView()
  367. splitContainer.translatesAutoresizingMaskIntoConstraints = false
  368. splitContainer.orientation = .horizontal
  369. splitContainer.spacing = 0
  370. splitContainer.alignment = .top
  371. view.addSubview(splitContainer)
  372. NSLayoutConstraint.activate([
  373. splitContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  374. splitContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  375. splitContainer.topAnchor.constraint(equalTo: view.topAnchor),
  376. splitContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  377. ])
  378. let sidebar = makeSidebar()
  379. let mainPanel = makeMainPanel()
  380. splitContainer.addArrangedSubview(sidebar)
  381. splitContainer.addArrangedSubview(mainPanel)
  382. }
  383. @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) {
  384. guard let view = sender.view else { return }
  385. activateSidebarItem(view)
  386. }
  387. private func activateSidebarItem(_ view: NSView) {
  388. guard let page = sidebarPageByView[ObjectIdentifier(view)],
  389. page != selectedSidebarPage || page == .settings else { return }
  390. if page == .settings {
  391. showSettingsPopover()
  392. return
  393. }
  394. showSidebarPage(page)
  395. }
  396. @objc private func zoomJoinModeClicked(_ sender: NSClickGestureRecognizer) {
  397. guard let view = sender.view,
  398. let mode = zoomJoinModeByView[ObjectIdentifier(view)],
  399. mode != selectedZoomJoinMode else { return }
  400. selectedZoomJoinMode = mode
  401. updateZoomJoinModeAppearance()
  402. if selectedSidebarPage == .joinMeetings {
  403. pageCache[.joinMeetings] = nil
  404. showSidebarPage(.joinMeetings)
  405. }
  406. }
  407. @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
  408. if storeKitCoordinator.hasPremiumAccess {
  409. openManageSubscriptions()
  410. } else {
  411. showPaywall()
  412. }
  413. }
  414. @objc private func sidebarButtonClicked(_ sender: NSButton) {
  415. guard let page = SidebarPage(rawValue: sender.tag),
  416. page != selectedSidebarPage || page == .settings else { return }
  417. if page == .settings {
  418. showSettingsPopover()
  419. return
  420. }
  421. showSidebarPage(page)
  422. }
  423. @objc private func joinMeetClicked(_ sender: Any?) {
  424. guard storeKitCoordinator.hasPremiumAccess else {
  425. showPaywall()
  426. return
  427. }
  428. let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  429. guard let url = normalizedMeetJoinURL(from: rawInput) else {
  430. showSimpleAlert(
  431. title: "Invalid Meet link",
  432. 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)."
  433. )
  434. return
  435. }
  436. openInDefaultBrowser(url: url)
  437. }
  438. @objc private func joinWithLinkCardClicked(_ sender: NSClickGestureRecognizer) {
  439. guard storeKitCoordinator.hasPremiumAccess else {
  440. showPaywall()
  441. return
  442. }
  443. meetLinkField?.window?.makeFirstResponder(meetLinkField)
  444. }
  445. @objc private func cancelMeetJoinClicked(_ sender: Any?) {
  446. meetLinkField?.stringValue = ""
  447. }
  448. @objc private func browseOpenAddressClicked(_ sender: Any?) {
  449. let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  450. guard raw.isEmpty == false else {
  451. showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
  452. return
  453. }
  454. let normalized = normalizedURLString(from: raw)
  455. guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
  456. showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
  457. return
  458. }
  459. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  460. }
  461. @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
  462. guard let url = URL(string: "https://meet.google.com/") else { return }
  463. openInDefaultBrowser(url: url)
  464. }
  465. @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
  466. guard let url = URL(string: "https://support.google.com/meet") else { return }
  467. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  468. }
  469. @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
  470. guard let url = URL(string: "https://support.zoom.us") else { return }
  471. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  472. }
  473. @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
  474. guard storeKitCoordinator.hasPremiumAccess else {
  475. showPaywall()
  476. return
  477. }
  478. guard let url = URL(string: "https://meet.google.com/new") else { return }
  479. openInDefaultBrowser(url: url)
  480. }
  481. private func normalizedURLString(from value: String) -> String {
  482. if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
  483. return value
  484. }
  485. return "https://\(value)"
  486. }
  487. /// Typical Meet meeting code shape: three hyphen-separated groups (e.g. `nkd-grps-duv`).
  488. private func isValidMeetMeetingCode(_ code: String) -> Bool {
  489. let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
  490. guard trimmed.isEmpty == false else { return false }
  491. let pattern = "^[a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{3}$"
  492. return trimmed.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
  493. }
  494. /// Accepts `https://meet.google.com/...`, `meet.google.com/...`, or a bare code; returns canonical Meet URL or `nil`.
  495. private func normalizedMeetJoinURL(from rawInput: String) -> URL? {
  496. let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
  497. guard trimmed.isEmpty == false else { return nil }
  498. let lower = trimmed.lowercased()
  499. if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
  500. guard let url = URL(string: trimmed),
  501. let host = url.host?.lowercased(),
  502. host == "meet.google.com" || host.hasSuffix(".meet.google.com") else {
  503. return nil
  504. }
  505. let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  506. guard path.isEmpty == false else { return nil }
  507. let firstSegment = path.split(separator: "/").first.map(String.init) ?? path
  508. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  509. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  510. }
  511. if lower.hasPrefix("meet.google.com/") {
  512. let afterHost = trimmed.dropFirst("meet.google.com/".count)
  513. let beforeQuery = String(afterHost).split(separator: "?").first.map(String.init) ?? String(afterHost)
  514. let firstSegment = beforeQuery.split(separator: "/").first.map(String.init) ?? beforeQuery
  515. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  516. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  517. }
  518. if isValidMeetMeetingCode(trimmed) {
  519. return URL(string: "https://meet.google.com/\(trimmed.lowercased())")
  520. }
  521. return nil
  522. }
  523. private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
  524. let browserController: InAppBrowserWindowController
  525. if let existing = inAppBrowserWindowController {
  526. browserController = existing
  527. } else {
  528. browserController = InAppBrowserWindowController()
  529. inAppBrowserWindowController = browserController
  530. }
  531. browserController.load(url: url, policy: policy)
  532. browserController.applyDefaultFrameCenteredOnVisibleScreen()
  533. browserController.showWindow(nil)
  534. browserController.window?.makeKeyAndOrderFront(nil)
  535. browserController.window?.orderFrontRegardless()
  536. NSApp.activate(ignoringOtherApps: true)
  537. }
  538. private func openInDefaultBrowser(url: URL) {
  539. NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
  540. if let error {
  541. DispatchQueue.main.async {
  542. self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription)
  543. }
  544. }
  545. }
  546. }
  547. private func openInSafari(url: URL) {
  548. guard let safariAppURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else {
  549. NSWorkspace.shared.open(url)
  550. return
  551. }
  552. let configuration = NSWorkspace.OpenConfiguration()
  553. NSWorkspace.shared.open([url], withApplicationAt: safariAppURL, configuration: configuration) { _, error in
  554. if let error {
  555. self.showSimpleAlert(title: "Unable to Open Safari", message: error.localizedDescription)
  556. }
  557. }
  558. }
  559. private func openManageSubscriptions() {
  560. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else {
  561. showSimpleAlert(title: "Unable to Open Subscriptions", message: "The subscriptions URL is invalid.")
  562. return
  563. }
  564. openInDefaultBrowser(url: url)
  565. }
  566. private func showSidebarPage(_ page: SidebarPage) {
  567. selectedSidebarPage = page
  568. updateSidebarAppearance()
  569. applyWindowTitle(for: page)
  570. guard let host = mainContentHost else { return }
  571. host.subviews.forEach { $0.removeFromSuperview() }
  572. let child = viewForPage(page)
  573. child.translatesAutoresizingMaskIntoConstraints = false
  574. host.addSubview(child)
  575. NSLayoutConstraint.activate([
  576. child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  577. child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  578. child.topAnchor.constraint(equalTo: host.topAnchor),
  579. child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
  580. ])
  581. }
  582. private func showSettingsPopover() {
  583. guard let anchor = sidebarRowViews[.settings] else { return }
  584. if settingsPopover?.isShown == true {
  585. settingsPopover?.performClose(nil)
  586. return
  587. }
  588. settingsPopover = makeSettingsPopover()
  589. if let menu = settingsPopover?.contentViewController as? SettingsMenuViewController {
  590. menu.setDarkModeEnabled(darkModeEnabled)
  591. }
  592. settingsPopover?.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
  593. }
  594. private func setDarkMode(_ enabled: Bool) {
  595. darkModeEnabled = enabled
  596. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  597. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  598. palette = Palette(isDarkMode: enabled)
  599. settingsPopover?.performClose(nil)
  600. settingsPopover = nil
  601. reloadTheme()
  602. }
  603. private func reloadTheme() {
  604. pageCache.removeAll()
  605. sidebarRowViews.removeAll()
  606. sidebarPageByView.removeAll()
  607. zoomJoinModeByView.removeAll()
  608. zoomJoinModeViews.removeAll()
  609. settingsActionByView.removeAll()
  610. paywallPlanViews.removeAll()
  611. premiumPlanByView.removeAll()
  612. paywallPriceLabels.removeAll()
  613. paywallSubtitleLabels.removeAll()
  614. paywallContinueLabel = nil
  615. paywallContinueButton = nil
  616. paywallContinueEnabled = true
  617. googleAccountPopover?.performClose(nil)
  618. googleAccountPopover = nil
  619. mainContentHost = nil
  620. view.subviews.forEach { $0.removeFromSuperview() }
  621. setupRootView()
  622. buildMainLayout()
  623. showSidebarPage(selectedSidebarPage)
  624. }
  625. private func handleSettingsAction(_ action: SettingsAction) {
  626. switch action {
  627. case .restore:
  628. settingsPopover?.performClose(nil)
  629. settingsPopover = nil
  630. Task { [weak self] in
  631. guard let self else { return }
  632. let message = await self.storeKitCoordinator.restorePurchases()
  633. self.refreshPaywallStoreUI()
  634. self.showSimpleAlert(title: "Restore Purchases", message: message)
  635. }
  636. case .rateUs:
  637. settingsPopover?.performClose(nil)
  638. settingsPopover = nil
  639. // Replace with your App Store product URL when the app is listed.
  640. if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
  641. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  642. }
  643. case .support:
  644. settingsPopover?.performClose(nil)
  645. settingsPopover = nil
  646. if let url = URL(string: "https://support.google.com/meet") {
  647. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  648. }
  649. case .moreApps:
  650. settingsPopover?.performClose(nil)
  651. settingsPopover = nil
  652. // Replace with your App Store developer page URL.
  653. if let url = URL(string: "https://apps.apple.com/developer/id0000000000") {
  654. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  655. }
  656. case .shareApp:
  657. let urlString = "https://example.com"
  658. NSPasteboard.general.clearContents()
  659. NSPasteboard.general.setString(urlString, forType: .string)
  660. showSimpleAlert(title: "Share App", message: "Link copied to clipboard:\n\(urlString)")
  661. case .upgrade:
  662. settingsPopover?.performClose(nil)
  663. settingsPopover = nil
  664. showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
  665. }
  666. }
  667. private func showSimpleAlert(title: String, message: String) {
  668. let alert = NSAlert()
  669. alert.messageText = title
  670. alert.informativeText = message
  671. alert.addButton(withTitle: "OK")
  672. alert.runModal()
  673. }
  674. private func showPaywall(upgradeFlow: Bool = false, preferredPlan: PremiumPlan? = nil) {
  675. paywallUpgradeFlowEnabled = upgradeFlow
  676. if let preferredPlan {
  677. selectedPremiumPlan = preferredPlan
  678. }
  679. if let existing = paywallWindow {
  680. refreshPaywallStoreUI()
  681. animatePaywallPresentation(existing)
  682. existing.makeKeyAndOrderFront(nil)
  683. NSApp.activate(ignoringOtherApps: true)
  684. return
  685. }
  686. let content = makePaywallContent()
  687. let controller = NSViewController()
  688. controller.view = content
  689. let panel = NSPanel(
  690. contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
  691. styleMask: [.titled, .closable, .fullSizeContentView],
  692. backing: .buffered,
  693. defer: false
  694. )
  695. panel.title = "Get Premium"
  696. panel.titleVisibility = .hidden
  697. panel.titlebarAppearsTransparent = true
  698. panel.isFloatingPanel = false
  699. panel.level = .normal
  700. panel.hidesOnDeactivate = true
  701. panel.isReleasedWhenClosed = false
  702. panel.delegate = self
  703. panel.standardWindowButton(.closeButton)?.isHidden = true
  704. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  705. panel.standardWindowButton(.zoomButton)?.isHidden = true
  706. panel.center()
  707. panel.contentViewController = controller
  708. panel.alphaValue = 0
  709. panel.makeKeyAndOrderFront(nil)
  710. NSApp.activate(ignoringOtherApps: true)
  711. paywallWindow = panel
  712. animatePaywallPresentation(panel)
  713. Task { [weak self] in
  714. guard let self else { return }
  715. await self.storeKitCoordinator.refreshProducts()
  716. self.refreshPaywallStoreUI()
  717. }
  718. }
  719. private func animatePaywallPresentation(_ window: NSWindow) {
  720. let finalFrame = window.frame
  721. let targetScreen = window.screen ?? NSScreen.main
  722. let startY: CGFloat
  723. if let screen = targetScreen {
  724. startY = screen.visibleFrame.maxY + 12
  725. } else {
  726. startY = finalFrame.origin.y + 120
  727. }
  728. let startFrame = NSRect(x: finalFrame.origin.x, y: startY, width: finalFrame.width, height: finalFrame.height)
  729. window.setFrame(startFrame, display: false)
  730. window.alphaValue = 0
  731. NSAnimationContext.runAnimationGroup { context in
  732. context.duration = 0.28
  733. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  734. window.animator().alphaValue = 1
  735. window.animator().setFrame(finalFrame, display: true)
  736. }
  737. }
  738. @objc private func closePaywallClicked(_ sender: Any?) {
  739. if let win = paywallWindow {
  740. win.performClose(nil)
  741. return
  742. }
  743. if let gesture = sender as? NSGestureRecognizer, let win = gesture.view?.window {
  744. win.performClose(nil)
  745. return
  746. }
  747. if let view = sender as? NSView, let win = view.window {
  748. win.performClose(nil)
  749. return
  750. }
  751. }
  752. @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
  753. guard let view = sender.view else { return }
  754. let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
  755. let map: [String: String] = [
  756. "Privacy Policy": "https://policies.google.com/privacy",
  757. "Support": "https://support.google.com/meet",
  758. "Terms of Services": "https://policies.google.com/terms"
  759. ]
  760. if let urlString = map[text], let url = URL(string: urlString) {
  761. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  762. return
  763. }
  764. showSimpleAlert(title: text, message: "\(text) tapped.")
  765. }
  766. @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
  767. guard let view = sender.view,
  768. let plan = premiumPlanByView[ObjectIdentifier(view)] else { return }
  769. selectedPremiumPlan = plan
  770. updatePaywallPlanSelection()
  771. }
  772. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  773. guard let plan = PremiumPlan(rawValue: sender.tag) else { return }
  774. selectedPremiumPlan = plan
  775. updatePaywallPlanSelection()
  776. }
  777. private func updatePaywallPlanSelection() {
  778. for (plan, view) in paywallPlanViews {
  779. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  780. }
  781. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  782. }
  783. private func paywallOfferText(for plan: PremiumPlan) -> String {
  784. if storeKitCoordinator.hasPremiumAccess {
  785. if storeKitCoordinator.hasLifetimeAccess {
  786. return "Lifetime premium is active on this Apple ID."
  787. }
  788. if paywallUpgradeFlowEnabled {
  789. let currentPlanName = storeKitCoordinator.activeNonLifetimePlan?.displayName ?? "Premium"
  790. if plan == .lifetime {
  791. return "Current plan: \(currentPlanName). Tap Continue to upgrade to Lifetime."
  792. }
  793. return "Current plan: \(currentPlanName). Select Lifetime to upgrade."
  794. }
  795. return "Premium is active on this Apple ID."
  796. }
  797. let productID = PremiumStoreProduct.productID(for: plan)
  798. if let product = storeKitCoordinator.productsByID[productID] {
  799. let pkrPrice = pkrDisplayPrice(product.displayPrice)
  800. if product.type == .nonConsumable {
  801. return "\(pkrPrice) one-time purchase"
  802. }
  803. if let period = product.subscription?.subscriptionPeriod {
  804. return "\(pkrPrice)/\(subscriptionUnitText(period.unit))"
  805. }
  806. return pkrPrice
  807. }
  808. switch plan {
  809. case .weekly:
  810. return "PKR 1,100.00/week"
  811. case .monthly:
  812. return "Free for 3 Days then PKR 2,500.00/month"
  813. case .yearly:
  814. return "PKR 9,900.00/year (about 190.38/week)"
  815. case .lifetime:
  816. return "PKR 14,900.00 one-time purchase"
  817. }
  818. }
  819. private func pkrDisplayPrice(_ value: String) -> String {
  820. if value.hasPrefix("PKR ") { return value }
  821. if value.hasPrefix("Rs ") {
  822. return "PKR " + value.dropFirst(3)
  823. }
  824. if value.contains("PKR") { return value }
  825. return "PKR \(value)"
  826. }
  827. private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
  828. switch unit {
  829. case .day: return "day"
  830. case .week: return "week"
  831. case .month: return "month"
  832. case .year: return "year"
  833. @unknown default: return "period"
  834. }
  835. }
  836. private func startStoreKit() {
  837. storeKitStartupTask?.cancel()
  838. storeKitStartupTask = Task { [weak self] in
  839. guard let self else { return }
  840. await self.storeKitCoordinator.start()
  841. self.hasCompletedInitialStoreKitSync = true
  842. self.refreshPaywallStoreUI()
  843. self.presentLaunchPaywallIfNeeded()
  844. }
  845. }
  846. private func refreshPaywallStoreUI() {
  847. for (plan, label) in paywallPriceLabels {
  848. let productID = PremiumStoreProduct.productID(for: plan)
  849. if let product = storeKitCoordinator.productsByID[productID] {
  850. label.stringValue = pkrDisplayPrice(product.displayPrice)
  851. }
  852. }
  853. for (plan, label) in paywallSubtitleLabels {
  854. let productID = PremiumStoreProduct.productID(for: plan)
  855. guard let product = storeKitCoordinator.productsByID[productID],
  856. let period = product.subscription?.subscriptionPeriod else { continue }
  857. label.stringValue = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
  858. }
  859. refreshSidebarPremiumButton()
  860. refreshInstantMeetPremiumState()
  861. updatePaywallPlanSelection()
  862. updatePaywallContinueState(isLoading: false)
  863. }
  864. private func refreshInstantMeetPremiumState() {
  865. let isPremium = storeKitCoordinator.hasPremiumAccess
  866. instantMeetCardView?.alphaValue = isPremium ? 1.0 : 0.65
  867. instantMeetTitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
  868. instantMeetSubtitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
  869. instantMeetCardView?.toolTip = isPremium ? nil : "Premium required. Click to open paywall."
  870. instantMeetCardView?.onHoverChanged?(false)
  871. joinWithLinkCardView?.alphaValue = isPremium ? 1.0 : 0.65
  872. joinWithLinkTitleLabel?.alphaValue = isPremium ? 1.0 : 0.75
  873. meetLinkField?.isEditable = isPremium
  874. meetLinkField?.isSelectable = isPremium
  875. meetLinkField?.alphaValue = isPremium ? 1.0 : 0.75
  876. // Keep button enabled so non-premium taps can still trigger paywall.
  877. joinMeetPrimaryButton?.isEnabled = true
  878. joinMeetPrimaryButton?.alphaValue = isPremium ? 1.0 : 0.80
  879. joinWithLinkCardView?.toolTip = isPremium ? nil : "Premium required. Click to open paywall."
  880. }
  881. private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
  882. let hadPremiumAccess = lastKnownPremiumAccess
  883. lastKnownPremiumAccess = hasPremiumAccess
  884. refreshPaywallStoreUI()
  885. refreshScheduleCardsForPremiumStateChange()
  886. if !hadPremiumAccess && hasPremiumAccess {
  887. Task { [weak self] in
  888. await self?.loadSchedule()
  889. }
  890. }
  891. if hadPremiumAccess && !hasPremiumAccess {
  892. showPaywall()
  893. }
  894. }
  895. private func refreshScheduleCardsForPremiumStateChange() {
  896. guard let stack = scheduleCardsStack else { return }
  897. renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
  898. }
  899. private func refreshSidebarPremiumButton() {
  900. let isPremium = storeKitCoordinator.hasPremiumAccess
  901. if isPremium {
  902. sidebarPremiumTitleLabel?.stringValue = "Manage Subscription"
  903. sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "crown.fill")
  904. } else {
  905. sidebarPremiumTitleLabel?.stringValue = "Get Premium"
  906. sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "star.fill")
  907. }
  908. sidebarPremiumIconView?.contentTintColor = .white
  909. sidebarPremiumButtonView?.onHoverChanged?(false)
  910. }
  911. private func premiumButtonSymbolImage(named symbolName: String) -> NSImage? {
  912. let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  913. return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
  914. .withSymbolConfiguration(configuration)
  915. }
  916. private func presentLaunchPaywallIfNeeded() {
  917. guard hasCompletedInitialStoreKitSync, hasViewAppearedOnce, !hasPresentedLaunchPaywall else { return }
  918. hasPresentedLaunchPaywall = true
  919. if !storeKitCoordinator.hasPremiumAccess {
  920. showPaywall()
  921. }
  922. }
  923. @objc private func paywallContinueClicked(_ sender: Any?) {
  924. startSelectedPlanPurchase()
  925. }
  926. private func startSelectedPlanPurchase() {
  927. guard paywallContinueEnabled else {
  928. if storeKitCoordinator.hasPremiumAccess {
  929. showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
  930. } else {
  931. showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
  932. }
  933. return
  934. }
  935. paywallPurchaseTask?.cancel()
  936. updatePaywallContinueState(isLoading: true)
  937. let selectedPlan = selectedPremiumPlan
  938. paywallPurchaseTask = Task { [weak self] in
  939. guard let self else { return }
  940. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  941. self.updatePaywallContinueState(isLoading: false)
  942. self.refreshPaywallStoreUI()
  943. switch result {
  944. case .success:
  945. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  946. self.paywallWindow?.performClose(nil)
  947. case .cancelled:
  948. break
  949. case .pending:
  950. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  951. case .unavailable:
  952. self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
  953. case .alreadyOwned:
  954. self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
  955. case .failed(let message):
  956. self.showSimpleAlert(title: "Purchase Failed", message: message)
  957. }
  958. }
  959. }
  960. private func updatePaywallContinueState(isLoading: Bool) {
  961. if isLoading {
  962. paywallContinueEnabled = false
  963. paywallContinueLabel?.stringValue = "Processing..."
  964. paywallContinueButton?.alphaValue = 0.75
  965. return
  966. }
  967. if storeKitCoordinator.hasLifetimeAccess {
  968. paywallContinueEnabled = false
  969. paywallContinueLabel?.stringValue = "Premium Active"
  970. paywallContinueButton?.alphaValue = 0.75
  971. } else if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess {
  972. if selectedPremiumPlan == .lifetime {
  973. paywallContinueEnabled = true
  974. paywallContinueLabel?.stringValue = "Continue"
  975. paywallContinueButton?.alphaValue = 1.0
  976. } else {
  977. paywallContinueEnabled = false
  978. paywallContinueLabel?.stringValue = "Select Lifetime to Upgrade"
  979. paywallContinueButton?.alphaValue = 0.75
  980. }
  981. } else {
  982. paywallContinueEnabled = true
  983. paywallContinueLabel?.stringValue = "Continue"
  984. paywallContinueButton?.alphaValue = 1.0
  985. }
  986. }
  987. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  988. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  989. let idleBorder = palette.inputBorder
  990. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  991. let hoverIdleBackground =
  992. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  993. let selectedBackground = darkModeEnabled
  994. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  995. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  996. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  997. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  998. card.layer?.borderWidth = isSelected ? 2 : 1
  999. card.layer?.shadowColor = NSColor.black.cgColor
  1000. card.layer?.shadowOpacity = isSelected ? (darkModeEnabled ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  1001. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1002. card.layer?.shadowRadius = isSelected ? (darkModeEnabled ? 10 : 6) : (hovering ? 7 : 5)
  1003. }
  1004. private func viewForPage(_ page: SidebarPage) -> NSView {
  1005. if let cached = pageCache[page] { return cached }
  1006. let built: NSView
  1007. switch page {
  1008. case .joinMeetings:
  1009. built = makeJoinMeetingsContent()
  1010. case .photo:
  1011. built = makePlaceholderPage(title: "Photo", subtitle: "Backgrounds — choose a photo background for your meetings.")
  1012. case .video:
  1013. built = makePlaceholderPage(title: "Video", subtitle: "Backgrounds — video background options.")
  1014. case .tutorials:
  1015. built = makePlaceholderPage(title: "Tutorials", subtitle: "Learn how to use the app.")
  1016. case .settings:
  1017. built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
  1018. }
  1019. pageCache[page] = built
  1020. return built
  1021. }
  1022. private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
  1023. let panel = NSView()
  1024. panel.translatesAutoresizingMaskIntoConstraints = false
  1025. let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary)
  1026. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1027. let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary)
  1028. sub.translatesAutoresizingMaskIntoConstraints = false
  1029. panel.addSubview(titleLabel)
  1030. panel.addSubview(sub)
  1031. NSLayoutConstraint.activate([
  1032. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1033. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor),
  1034. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1035. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  1036. ])
  1037. return panel
  1038. }
  1039. func makeBrowseWebContent() -> NSView {
  1040. let panel = NSView()
  1041. panel.translatesAutoresizingMaskIntoConstraints = false
  1042. let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
  1043. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1044. let sub = textLabel(
  1045. "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
  1046. font: typography.fieldLabel,
  1047. color: palette.textSecondary
  1048. )
  1049. sub.translatesAutoresizingMaskIntoConstraints = false
  1050. sub.maximumNumberOfLines = 0
  1051. sub.lineBreakMode = .byWordWrapping
  1052. let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  1053. fieldShell.translatesAutoresizingMaskIntoConstraints = false
  1054. fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
  1055. styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1056. let field = NSTextField(string: "")
  1057. field.translatesAutoresizingMaskIntoConstraints = false
  1058. field.isEditable = true
  1059. field.isBordered = false
  1060. field.drawsBackground = false
  1061. field.focusRingType = .none
  1062. field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
  1063. field.textColor = palette.textPrimary
  1064. field.placeholderString = "https://example.com or example.com"
  1065. field.delegate = self
  1066. browseAddressField = field
  1067. fieldShell.addSubview(field)
  1068. let openBtn = meetActionButton(
  1069. title: "Open in app browser",
  1070. color: palette.primaryBlue,
  1071. textColor: .white,
  1072. width: 220,
  1073. action: #selector(browseOpenAddressClicked(_:))
  1074. )
  1075. let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1076. quickTitle.translatesAutoresizingMaskIntoConstraints = false
  1077. let quickRow = NSStackView()
  1078. quickRow.translatesAutoresizingMaskIntoConstraints = false
  1079. quickRow.orientation = .horizontal
  1080. quickRow.spacing = 10
  1081. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
  1082. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
  1083. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
  1084. panel.addSubview(titleLabel)
  1085. panel.addSubview(sub)
  1086. panel.addSubview(fieldShell)
  1087. panel.addSubview(openBtn)
  1088. panel.addSubview(quickTitle)
  1089. panel.addSubview(quickRow)
  1090. NSLayoutConstraint.activate([
  1091. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1092. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  1093. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  1094. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1095. sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  1096. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  1097. fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1098. fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1099. fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
  1100. field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
  1101. field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
  1102. field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
  1103. openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1104. openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
  1105. openBtn.heightAnchor.constraint(equalToConstant: 36),
  1106. quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1107. quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
  1108. quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1109. quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
  1110. ])
  1111. return panel
  1112. }
  1113. private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
  1114. let b = NSButton(title: title, target: self, action: action)
  1115. b.translatesAutoresizingMaskIntoConstraints = false
  1116. b.bezelStyle = .rounded
  1117. b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
  1118. return b
  1119. }
  1120. private func applyWindowTitle(for page: SidebarPage) {
  1121. let title: String
  1122. switch page {
  1123. case .joinMeetings:
  1124. title = "App for Google Meet"
  1125. case .photo:
  1126. title = "Backgrounds — Photo"
  1127. case .video:
  1128. title = "Backgrounds — Video"
  1129. case .tutorials:
  1130. title = "Tutorials"
  1131. case .settings:
  1132. title = "Settings"
  1133. }
  1134. view.window?.title = title
  1135. centeredTitleLabel?.stringValue = title
  1136. }
  1137. private func installCenteredTitleIfNeeded(on window: NSWindow) {
  1138. guard centeredTitleLabel == nil else { return }
  1139. guard let titlebarView = window.standardWindowButton(.closeButton)?.superview else { return }
  1140. let label = NSTextField(labelWithString: window.title)
  1141. label.translatesAutoresizingMaskIntoConstraints = false
  1142. label.alignment = .center
  1143. label.font = NSFont.titleBarFont(ofSize: 0)
  1144. label.textColor = .labelColor
  1145. label.lineBreakMode = .byTruncatingTail
  1146. label.maximumNumberOfLines = 1
  1147. titlebarView.addSubview(label)
  1148. NSLayoutConstraint.activate([
  1149. label.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor),
  1150. label.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor),
  1151. label.leadingAnchor.constraint(greaterThanOrEqualTo: titlebarView.leadingAnchor, constant: 90),
  1152. label.trailingAnchor.constraint(lessThanOrEqualTo: titlebarView.trailingAnchor, constant: -90)
  1153. ])
  1154. window.titleVisibility = .hidden
  1155. centeredTitleLabel = label
  1156. }
  1157. private func updateSidebarAppearance() {
  1158. for (page, row) in sidebarRowViews {
  1159. applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page))
  1160. }
  1161. }
  1162. private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
  1163. switch page {
  1164. case .photo, .tutorials: return false
  1165. case .joinMeetings, .video, .settings: return true
  1166. }
  1167. }
  1168. func makeSidebar() -> NSView {
  1169. let sidebar = NSView()
  1170. sidebar.translatesAutoresizingMaskIntoConstraints = false
  1171. sidebar.wantsLayer = true
  1172. sidebar.layer?.backgroundColor = palette.sidebarBackground.cgColor
  1173. sidebar.layer?.borderColor = palette.separator.cgColor
  1174. sidebar.layer?.borderWidth = 1
  1175. sidebar.layer?.shadowColor = NSColor.black.cgColor
  1176. sidebar.layer?.shadowOpacity = 0.18
  1177. sidebar.layer?.shadowOffset = CGSize(width: 2, height: 0)
  1178. sidebar.layer?.shadowRadius = 10
  1179. sidebar.widthAnchor.constraint(equalToConstant: 210).isActive = true
  1180. let titleRow = NSStackView(views: [
  1181. iconLabel("📅", size: 24),
  1182. textLabel("Meetings", font: typography.sidebarBrand, color: palette.textPrimary)
  1183. ])
  1184. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1185. titleRow.orientation = .horizontal
  1186. titleRow.alignment = .centerY
  1187. titleRow.spacing = 8
  1188. let menuStack = NSStackView()
  1189. menuStack.translatesAutoresizingMaskIntoConstraints = false
  1190. menuStack.orientation = .vertical
  1191. menuStack.alignment = .leading
  1192. menuStack.spacing = 10
  1193. menuStack.addArrangedSubview(sidebarSectionTitle("Meetings"))
  1194. let joinRow = sidebarItem("Join Meetings", icon: "􀉣", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0)
  1195. menuStack.addArrangedSubview(joinRow)
  1196. sidebarRowViews[.joinMeetings] = joinRow
  1197. menuStack.addArrangedSubview(sidebarSectionTitle("Backgrounds"))
  1198. let photoRow = sidebarItem("Photo", icon: "􀏂", page: .photo, logoImageName: "SidebarPhotoLogo", logoIconWidth: 24, logoHeightMultiplier: 82.0 / 62.0)
  1199. menuStack.addArrangedSubview(photoRow)
  1200. sidebarRowViews[.photo] = photoRow
  1201. let videoRow = sidebarItem("Video", icon: "􀎚", page: .video, logoImageName: "SidebarVideoLogo", logoIconWidth: 28, logoHeightMultiplier: 52.0 / 60.0)
  1202. menuStack.addArrangedSubview(videoRow)
  1203. sidebarRowViews[.video] = videoRow
  1204. menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
  1205. let tutorialsRow = sidebarItem("Tutorials", icon: "􀛩", page: .tutorials, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0)
  1206. menuStack.addArrangedSubview(tutorialsRow)
  1207. sidebarRowViews[.tutorials] = tutorialsRow
  1208. let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
  1209. menuStack.addArrangedSubview(settingsRow)
  1210. sidebarRowViews[.settings] = settingsRow
  1211. let premiumButton = sidebarPremiumButton()
  1212. sidebar.addSubview(titleRow)
  1213. sidebar.addSubview(menuStack)
  1214. sidebar.addSubview(premiumButton)
  1215. NSLayoutConstraint.activate([
  1216. titleRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 16),
  1217. titleRow.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 24),
  1218. titleRow.trailingAnchor.constraint(lessThanOrEqualTo: sidebar.trailingAnchor, constant: -16),
  1219. menuStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  1220. menuStack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  1221. menuStack.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 20),
  1222. menuStack.bottomAnchor.constraint(lessThanOrEqualTo: premiumButton.topAnchor, constant: -16),
  1223. premiumButton.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  1224. premiumButton.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  1225. premiumButton.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -14)
  1226. ])
  1227. for subview in menuStack.arrangedSubviews {
  1228. subview.widthAnchor.constraint(equalTo: menuStack.widthAnchor).isActive = true
  1229. }
  1230. return sidebar
  1231. }
  1232. func sidebarPremiumButton() -> NSView {
  1233. let button = HoverTrackingView()
  1234. button.translatesAutoresizingMaskIntoConstraints = false
  1235. button.wantsLayer = true
  1236. button.layer?.cornerRadius = 17
  1237. button.layer?.backgroundColor = palette.primaryBlue.cgColor
  1238. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  1239. styleSurface(button, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  1240. let icon = NSImageView()
  1241. icon.translatesAutoresizingMaskIntoConstraints = false
  1242. icon.imageScaling = .scaleProportionallyUpOrDown
  1243. icon.contentTintColor = .white
  1244. icon.image = premiumButtonSymbolImage(named: "star.fill")
  1245. let title = textLabel("Get Premium", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: .white)
  1246. button.addSubview(icon)
  1247. button.addSubview(title)
  1248. NSLayoutConstraint.activate([
  1249. icon.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
  1250. icon.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1251. icon.widthAnchor.constraint(equalToConstant: 14),
  1252. icon.heightAnchor.constraint(equalToConstant: 14),
  1253. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
  1254. title.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1255. title.trailingAnchor.constraint(lessThanOrEqualTo: button.trailingAnchor, constant: -12)
  1256. ])
  1257. button.onHoverChanged = { [weak self, weak button] hovering in
  1258. guard let self, let button else { return }
  1259. let isPremium = self.storeKitCoordinator.hasPremiumAccess
  1260. let baseColor = isPremium
  1261. ? NSColor(calibratedRed: 0.93, green: 0.73, blue: 0.16, alpha: 1.0)
  1262. : self.palette.primaryBlue
  1263. let borderColor = isPremium
  1264. ? NSColor(calibratedRed: 0.78, green: 0.56, blue: 0.10, alpha: 1.0)
  1265. : self.palette.primaryBlueBorder
  1266. let hoverColor: NSColor
  1267. let hoverBorderColor: NSColor
  1268. if isPremium {
  1269. // Darker rich-gold hover for stronger premium feedback.
  1270. hoverColor = NSColor(calibratedRed: 0.84, green: 0.62, blue: 0.11, alpha: 1.0)
  1271. hoverBorderColor = NSColor(calibratedRed: 0.67, green: 0.46, blue: 0.07, alpha: 1.0)
  1272. } else {
  1273. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  1274. hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  1275. hoverBorderColor = borderColor
  1276. }
  1277. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  1278. button.layer?.shadowColor = NSColor.black.cgColor
  1279. button.layer?.shadowOpacity = hovering ? (isPremium ? 0.30 : 0.20) : 0.14
  1280. button.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1281. button.layer?.shadowRadius = hovering ? (isPremium ? 8 : 6) : 4
  1282. self.styleSurface(button, borderColor: (hovering ? hoverBorderColor : borderColor), borderWidth: hovering ? 1.5 : 1, shadow: false)
  1283. }
  1284. button.onHoverChanged?(false)
  1285. sidebarPremiumTitleLabel = title
  1286. sidebarPremiumIconView = icon
  1287. sidebarPremiumButtonView = button
  1288. refreshSidebarPremiumButton()
  1289. let click = NSClickGestureRecognizer(target: self, action: #selector(premiumButtonClicked(_:)))
  1290. button.addGestureRecognizer(click)
  1291. return button
  1292. }
  1293. func makeMainPanel() -> NSView {
  1294. let panel = NSView()
  1295. panel.translatesAutoresizingMaskIntoConstraints = false
  1296. panel.wantsLayer = true
  1297. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  1298. let authBar = scheduleTopAuthRow()
  1299. authBar.translatesAutoresizingMaskIntoConstraints = false
  1300. panel.addSubview(authBar)
  1301. let host = NSView()
  1302. host.translatesAutoresizingMaskIntoConstraints = false
  1303. panel.addSubview(host)
  1304. NSLayoutConstraint.activate([
  1305. authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1306. authBar.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1307. authBar.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  1308. host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1309. host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1310. host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20),
  1311. host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
  1312. ])
  1313. mainContentHost = host
  1314. if googleOAuth.loadTokens() != nil, let profile = scheduleCurrentProfile {
  1315. applyGoogleProfile(profile)
  1316. }
  1317. showSidebarPage(.joinMeetings)
  1318. return panel
  1319. }
  1320. func makeJoinMeetingsContent() -> NSView {
  1321. let panel = NSView()
  1322. panel.translatesAutoresizingMaskIntoConstraints = false
  1323. let contentStack = NSStackView()
  1324. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1325. contentStack.orientation = .vertical
  1326. contentStack.spacing = 14
  1327. contentStack.alignment = .leading
  1328. let joinActions = meetJoinActionsRow()
  1329. contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
  1330. contentStack.addArrangedSubview(meetJoinSectionRow())
  1331. contentStack.addArrangedSubview(joinActions)
  1332. contentStack.setCustomSpacing(26, after: joinActions)
  1333. contentStack.addArrangedSubview(scheduleHeader())
  1334. let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
  1335. scheduleDateHeadingLabel = dateHeading
  1336. contentStack.addArrangedSubview(dateHeading)
  1337. let cardsRow = scheduleCardsRow(meetings: [])
  1338. contentStack.addArrangedSubview(cardsRow)
  1339. panel.addSubview(contentStack)
  1340. NSLayoutConstraint.activate([
  1341. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1342. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1343. contentStack.topAnchor.constraint(equalTo: panel.topAnchor)
  1344. ])
  1345. Task { [weak self] in
  1346. await self?.loadSchedule()
  1347. }
  1348. return panel
  1349. }
  1350. func meetJoinSectionRow() -> NSView {
  1351. let row = NSStackView()
  1352. row.translatesAutoresizingMaskIntoConstraints = false
  1353. row.orientation = .horizontal
  1354. row.spacing = 12
  1355. row.alignment = .top
  1356. row.distribution = .fillEqually
  1357. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  1358. row.heightAnchor.constraint(equalToConstant: 140).isActive = true
  1359. let instant = HoverSurfaceView()
  1360. instant.translatesAutoresizingMaskIntoConstraints = false
  1361. instant.wantsLayer = true
  1362. instant.layer?.cornerRadius = 14
  1363. instant.layer?.backgroundColor = palette.sectionCard.cgColor
  1364. styleSurface(instant, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1365. let iconWrap = roundedContainer(cornerRadius: 12, color: NSColor.clear)
  1366. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  1367. iconWrap.widthAnchor.constraint(equalToConstant: 58).isActive = true
  1368. iconWrap.heightAnchor.constraint(equalToConstant: 58).isActive = true
  1369. iconWrap.layer?.borderWidth = 0
  1370. let meetLogoImage = NSImage(named: "MeetLogo") ?? NSImage()
  1371. meetLogoImage.isTemplate = false
  1372. let meetLogo = NSImageView(image: meetLogoImage)
  1373. meetLogo.translatesAutoresizingMaskIntoConstraints = false
  1374. meetLogo.imageScaling = .scaleProportionallyDown
  1375. meetLogo.contentTintColor = nil
  1376. iconWrap.addSubview(meetLogo)
  1377. let instantTitle = textLabel("New Instant Meet", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  1378. let instantSub = textLabel("Start instant Meet in more section with\nGoogle Meet meet.", font: NSFont.systemFont(ofSize: 16 / 2, weight: .medium), color: palette.textSecondary)
  1379. instantSub.maximumNumberOfLines = 2
  1380. instant.addSubview(iconWrap)
  1381. instant.addSubview(instantTitle)
  1382. instant.addSubview(instantSub)
  1383. let codeCard = HoverSurfaceView()
  1384. codeCard.translatesAutoresizingMaskIntoConstraints = false
  1385. codeCard.wantsLayer = true
  1386. codeCard.layer?.cornerRadius = 14
  1387. codeCard.layer?.backgroundColor = palette.sectionCard.cgColor
  1388. styleSurface(codeCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1389. let codeTitle = textLabel("Join with Link", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  1390. let codeInputShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  1391. codeInputShell.translatesAutoresizingMaskIntoConstraints = false
  1392. codeInputShell.heightAnchor.constraint(equalToConstant: 52).isActive = true
  1393. styleSurface(codeInputShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1394. let codeField = NSTextField(string: "")
  1395. codeField.translatesAutoresizingMaskIntoConstraints = false
  1396. codeField.isEditable = true
  1397. codeField.isBordered = false
  1398. codeField.drawsBackground = false
  1399. codeField.focusRingType = .none
  1400. codeField.font = NSFont.systemFont(ofSize: 36 / 2, weight: .regular)
  1401. codeField.textColor = palette.textPrimary
  1402. codeField.placeholderString = "Code or meet.google.com/…"
  1403. codeInputShell.addSubview(codeField)
  1404. meetLinkField = codeField
  1405. codeCard.addSubview(codeTitle)
  1406. codeCard.addSubview(codeInputShell)
  1407. NSLayoutConstraint.activate([
  1408. meetLogo.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  1409. meetLogo.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor),
  1410. meetLogo.widthAnchor.constraint(equalToConstant: 46),
  1411. meetLogo.heightAnchor.constraint(equalToConstant: 46),
  1412. iconWrap.leadingAnchor.constraint(equalTo: instant.leadingAnchor, constant: 18),
  1413. iconWrap.topAnchor.constraint(equalTo: instant.topAnchor, constant: 22),
  1414. instantTitle.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 14),
  1415. instantTitle.topAnchor.constraint(equalTo: instant.topAnchor, constant: 24),
  1416. instantSub.leadingAnchor.constraint(equalTo: instantTitle.leadingAnchor),
  1417. instantSub.topAnchor.constraint(equalTo: instantTitle.bottomAnchor, constant: 6),
  1418. instantSub.trailingAnchor.constraint(lessThanOrEqualTo: instant.trailingAnchor, constant: -16),
  1419. codeTitle.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  1420. codeTitle.topAnchor.constraint(equalTo: codeCard.topAnchor, constant: 22),
  1421. codeInputShell.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  1422. codeInputShell.trailingAnchor.constraint(equalTo: codeCard.trailingAnchor, constant: -18),
  1423. codeInputShell.topAnchor.constraint(equalTo: codeTitle.bottomAnchor, constant: 12),
  1424. codeField.leadingAnchor.constraint(equalTo: codeInputShell.leadingAnchor, constant: 14),
  1425. codeField.trailingAnchor.constraint(equalTo: codeInputShell.trailingAnchor, constant: -14),
  1426. codeField.centerYAnchor.constraint(equalTo: codeInputShell.centerYAnchor)
  1427. ])
  1428. let baseColor = palette.sectionCard
  1429. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1430. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  1431. instant.onHoverChanged = { [weak self] hovering in
  1432. guard let self else { return }
  1433. if self.storeKitCoordinator.hasPremiumAccess {
  1434. instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  1435. } else {
  1436. let disabledColor = self.palette.sectionCard.blended(withFraction: 0.22, of: self.palette.pageBackground) ?? self.palette.sectionCard
  1437. instant.layer?.backgroundColor = disabledColor.cgColor
  1438. }
  1439. }
  1440. codeCard.onHoverChanged = { [weak self] hovering in
  1441. guard let self else { return }
  1442. if self.storeKitCoordinator.hasPremiumAccess {
  1443. codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  1444. } else {
  1445. let disabledColor = self.palette.sectionCard.blended(withFraction: 0.22, of: self.palette.pageBackground) ?? self.palette.sectionCard
  1446. codeCard.layer?.backgroundColor = disabledColor.cgColor
  1447. }
  1448. }
  1449. instant.onHoverChanged?(false)
  1450. codeCard.onHoverChanged?(false)
  1451. let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
  1452. instant.addGestureRecognizer(instantClick)
  1453. let joinWithLinkClick = NSClickGestureRecognizer(target: self, action: #selector(joinWithLinkCardClicked(_:)))
  1454. codeCard.addGestureRecognizer(joinWithLinkClick)
  1455. instantMeetCardView = instant
  1456. instantMeetTitleLabel = instantTitle
  1457. instantMeetSubtitleLabel = instantSub
  1458. joinWithLinkCardView = codeCard
  1459. joinWithLinkTitleLabel = codeTitle
  1460. refreshInstantMeetPremiumState()
  1461. row.addArrangedSubview(instant)
  1462. row.addArrangedSubview(codeCard)
  1463. return row
  1464. }
  1465. func meetJoinActionsRow() -> NSView {
  1466. let row = NSStackView()
  1467. row.translatesAutoresizingMaskIntoConstraints = false
  1468. row.orientation = .horizontal
  1469. row.spacing = 12
  1470. row.alignment = .centerY
  1471. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  1472. let spacer = NSView()
  1473. spacer.translatesAutoresizingMaskIntoConstraints = false
  1474. row.addArrangedSubview(spacer)
  1475. row.addArrangedSubview(meetActionButton(
  1476. title: "Cancel",
  1477. color: palette.cancelButton,
  1478. textColor: palette.textSecondary,
  1479. width: 110,
  1480. action: #selector(cancelMeetJoinClicked(_:))
  1481. ))
  1482. let joinButton = meetActionButton(
  1483. title: "Join",
  1484. color: palette.primaryBlue,
  1485. textColor: .white,
  1486. width: 116,
  1487. action: #selector(joinMeetClicked(_:))
  1488. )
  1489. joinMeetPrimaryButton = joinButton
  1490. row.addArrangedSubview(joinButton)
  1491. refreshInstantMeetPremiumState()
  1492. return row
  1493. }
  1494. func meetActionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat, action: Selector) -> NSButton {
  1495. let button = HoverButton(title: title, target: self, action: action)
  1496. button.translatesAutoresizingMaskIntoConstraints = false
  1497. button.isBordered = false
  1498. button.bezelStyle = .regularSquare
  1499. button.wantsLayer = true
  1500. button.layer?.cornerRadius = 9
  1501. let baseBackground = color
  1502. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1503. let hoverBackground = baseBackground.blended(withFraction: 0.10, of: hoverBlend) ?? baseBackground
  1504. let baseBorder = (title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder)
  1505. let hoverBorder = baseBorder.blended(withFraction: 0.18, of: hoverBlend) ?? baseBorder
  1506. button.layer?.backgroundColor = baseBackground.cgColor
  1507. button.layer?.borderColor = baseBorder.cgColor
  1508. button.layer?.borderWidth = 1
  1509. button.font = typography.buttonText
  1510. button.contentTintColor = textColor
  1511. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  1512. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  1513. button.onHoverChanged = { [weak self, weak button] hovering in
  1514. guard let self, let button else { return }
  1515. button.layer?.backgroundColor = (hovering ? hoverBackground : baseBackground).cgColor
  1516. button.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  1517. if title == "Cancel" {
  1518. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : textColor
  1519. }
  1520. }
  1521. button.onHoverChanged?(false)
  1522. return button
  1523. }
  1524. func makePaywallContent() -> NSView {
  1525. paywallPlanViews.removeAll()
  1526. premiumPlanByView.removeAll()
  1527. let panel = NSView()
  1528. panel.translatesAutoresizingMaskIntoConstraints = false
  1529. panel.wantsLayer = true
  1530. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  1531. let contentStack = NSStackView()
  1532. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1533. contentStack.orientation = .vertical
  1534. contentStack.spacing = 12
  1535. contentStack.alignment = .leading
  1536. panel.addSubview(contentStack)
  1537. let topRow = NSStackView()
  1538. topRow.translatesAutoresizingMaskIntoConstraints = false
  1539. topRow.orientation = .horizontal
  1540. topRow.alignment = .centerY
  1541. topRow.distribution = .fill
  1542. topRow.spacing = 10
  1543. topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary))
  1544. let topSpacer = NSView()
  1545. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  1546. topRow.addArrangedSubview(topSpacer)
  1547. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  1548. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1549. closeButton.isBordered = false
  1550. closeButton.bezelStyle = .regularSquare
  1551. closeButton.wantsLayer = true
  1552. closeButton.layer?.cornerRadius = 14
  1553. closeButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1554. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1555. closeButton.layer?.borderWidth = 1
  1556. closeButton.font = typography.iconButton
  1557. closeButton.contentTintColor = palette.textSecondary
  1558. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1559. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1560. closeButton.onHoverChanged = { [weak closeButton, weak self] hovering in
  1561. guard let closeButton, let self else { return }
  1562. let base = self.palette.inputBackground
  1563. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  1564. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  1565. closeButton.layer?.backgroundColor = (hovering ? hover : base).cgColor
  1566. closeButton.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  1567. }
  1568. topRow.addArrangedSubview(closeButton)
  1569. topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1570. contentStack.addArrangedSubview(topRow)
  1571. contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textSecondary))
  1572. let benefits = paywallBenefitsSection()
  1573. contentStack.addArrangedSubview(benefits)
  1574. contentStack.setCustomSpacing(18, after: benefits)
  1575. let weeklyCard = paywallPlanCard(
  1576. title: "Weekly",
  1577. price: "PKR 1,100.00",
  1578. badge: "Basic Deal",
  1579. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1580. subtitle: nil,
  1581. plan: .weekly,
  1582. strikePrice: nil
  1583. )
  1584. contentStack.addArrangedSubview(weeklyCard)
  1585. let monthlyCard = paywallPlanCard(
  1586. title: "Monthly",
  1587. price: "PKR 2,500.00",
  1588. badge: "Free Trial",
  1589. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  1590. subtitle: "625.00/week",
  1591. plan: .monthly,
  1592. strikePrice: nil
  1593. )
  1594. contentStack.addArrangedSubview(monthlyCard)
  1595. let yearlyCard = paywallPlanCard(
  1596. title: "Yearly",
  1597. price: "PKR 9,900.00",
  1598. badge: "Best Deal",
  1599. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1600. subtitle: "190.38/week",
  1601. plan: .yearly,
  1602. strikePrice: nil
  1603. )
  1604. contentStack.addArrangedSubview(yearlyCard)
  1605. let lifetimeCard = paywallPlanCard(
  1606. title: "Lifetime",
  1607. price: "PKR 14,900.00",
  1608. badge: "Save 50%",
  1609. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1610. subtitle: nil,
  1611. plan: .lifetime,
  1612. strikePrice: "PKR 29,800.00"
  1613. )
  1614. contentStack.addArrangedSubview(lifetimeCard)
  1615. updatePaywallPlanSelection()
  1616. contentStack.setCustomSpacing(20, after: lifetimeCard)
  1617. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
  1618. offer.alignment = .center
  1619. paywallOfferLabel = offer
  1620. let offerWrap = NSView()
  1621. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  1622. offerWrap.addSubview(offer)
  1623. NSLayoutConstraint.activate([
  1624. offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  1625. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  1626. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  1627. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  1628. ])
  1629. contentStack.addArrangedSubview(offerWrap)
  1630. contentStack.setCustomSpacing(18, after: offerWrap)
  1631. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  1632. continueButton.translatesAutoresizingMaskIntoConstraints = false
  1633. continueButton.isBordered = false
  1634. continueButton.bezelStyle = .regularSquare
  1635. continueButton.wantsLayer = true
  1636. continueButton.layer?.cornerRadius = 14
  1637. continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
  1638. continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
  1639. continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1640. styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
  1641. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  1642. continueButton.addSubview(continueLabel)
  1643. NSLayoutConstraint.activate([
  1644. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  1645. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  1646. ])
  1647. let baseBlue = palette.primaryBlue
  1648. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1649. let hoverBlue = baseBlue.blended(withFraction: 0.10, of: hoverBlend) ?? baseBlue
  1650. continueButton.onHoverChanged = { hovering in
  1651. continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
  1652. }
  1653. continueButton.onHoverChanged?(false)
  1654. paywallContinueButton = continueButton
  1655. paywallContinueLabel = continueLabel
  1656. contentStack.addArrangedSubview(continueButton)
  1657. contentStack.setCustomSpacing(16, after: continueButton)
  1658. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
  1659. secure.alignment = .center
  1660. let secureWrap = NSView()
  1661. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  1662. secureWrap.addSubview(secure)
  1663. NSLayoutConstraint.activate([
  1664. secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  1665. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  1666. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  1667. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  1668. ])
  1669. contentStack.addArrangedSubview(secureWrap)
  1670. contentStack.setCustomSpacing(16, after: secureWrap)
  1671. let footer = paywallFooterLinks()
  1672. contentStack.addArrangedSubview(footer)
  1673. NSLayoutConstraint.activate([
  1674. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  1675. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  1676. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
  1677. contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
  1678. ])
  1679. refreshPaywallStoreUI()
  1680. return panel
  1681. }
  1682. func paywallPlanCard(
  1683. title: String,
  1684. price: String,
  1685. badge: String,
  1686. badgeColor: NSColor,
  1687. subtitle: String?,
  1688. plan: PremiumPlan,
  1689. strikePrice: String?
  1690. ) -> NSView {
  1691. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  1692. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1693. wrapper.isBordered = false
  1694. wrapper.bezelStyle = .regularSquare
  1695. wrapper.wantsLayer = true
  1696. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  1697. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1698. wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
  1699. wrapper.tag = plan.rawValue
  1700. let card = HoverTrackingView()
  1701. card.translatesAutoresizingMaskIntoConstraints = false
  1702. card.wantsLayer = true
  1703. card.layer?.cornerRadius = 16
  1704. card.layer?.backgroundColor = palette.sectionCard.cgColor
  1705. card.heightAnchor.constraint(equalToConstant: 82).isActive = true
  1706. wrapper.addSubview(card)
  1707. NSLayoutConstraint.activate([
  1708. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1709. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1710. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
  1711. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  1712. ])
  1713. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1714. let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
  1715. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  1716. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  1717. badgeWrap.wantsLayer = true
  1718. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  1719. badgeWrap.layer?.borderWidth = 1
  1720. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  1721. badgeWrap.layer?.shadowOpacity = 0.20
  1722. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1723. badgeWrap.layer?.shadowRadius = 3
  1724. badgeWrap.addSubview(badgeLabel)
  1725. NSLayoutConstraint.activate([
  1726. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  1727. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  1728. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  1729. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  1730. ])
  1731. wrapper.addSubview(badgeWrap)
  1732. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue)
  1733. card.addSubview(titleLabel)
  1734. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
  1735. card.addSubview(priceLabel)
  1736. paywallPriceLabels[plan] = priceLabel
  1737. NSLayoutConstraint.activate([
  1738. badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  1739. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  1740. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  1741. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
  1742. priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  1743. priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
  1744. ])
  1745. if let subtitle {
  1746. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
  1747. card.addSubview(sub)
  1748. paywallSubtitleLabels[plan] = sub
  1749. NSLayoutConstraint.activate([
  1750. sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  1751. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
  1752. ])
  1753. }
  1754. if let strikePrice {
  1755. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  1756. card.addSubview(strike)
  1757. NSLayoutConstraint.activate([
  1758. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  1759. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  1760. ])
  1761. }
  1762. paywallPlanViews[plan] = card
  1763. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  1764. guard let self, let card else { return }
  1765. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  1766. }
  1767. wrapper.onHoverChanged?(false)
  1768. return wrapper
  1769. }
  1770. func paywallFooterLinks() -> NSView {
  1771. let wrap = NSView()
  1772. wrap.translatesAutoresizingMaskIntoConstraints = false
  1773. wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true
  1774. wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1775. let row = NSStackView()
  1776. row.translatesAutoresizingMaskIntoConstraints = false
  1777. row.orientation = .horizontal
  1778. row.distribution = .fillEqually
  1779. row.alignment = .centerY
  1780. row.spacing = 0
  1781. wrap.addSubview(row)
  1782. row.addArrangedSubview(footerLink("Privacy Policy"))
  1783. row.addArrangedSubview(footerLink("Support"))
  1784. row.addArrangedSubview(footerLink("Terms of Services"))
  1785. NSLayoutConstraint.activate([
  1786. row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
  1787. row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
  1788. row.topAnchor.constraint(equalTo: wrap.topAnchor),
  1789. row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
  1790. ])
  1791. return wrap
  1792. }
  1793. func footerLink(_ title: String) -> NSView {
  1794. let container = HoverTrackingView()
  1795. container.translatesAutoresizingMaskIntoConstraints = false
  1796. let label = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
  1797. label.alignment = .center
  1798. container.addSubview(label)
  1799. NSLayoutConstraint.activate([
  1800. label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  1801. label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  1802. ])
  1803. let click = NSClickGestureRecognizer(target: self, action: #selector(paywallFooterLinkClicked(_:)))
  1804. container.addGestureRecognizer(click)
  1805. container.onHoverChanged = { hovering in
  1806. label.textColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  1807. }
  1808. container.onHoverChanged?(false)
  1809. return container
  1810. }
  1811. func paywallBenefitsSection() -> NSView {
  1812. let stack = NSStackView()
  1813. stack.translatesAutoresizingMaskIntoConstraints = false
  1814. stack.orientation = .vertical
  1815. stack.spacing = 8
  1816. stack.alignment = .leading
  1817. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1818. let rowOne = NSStackView()
  1819. rowOne.translatesAutoresizingMaskIntoConstraints = false
  1820. rowOne.orientation = .horizontal
  1821. rowOne.spacing = 10
  1822. rowOne.distribution = .fillEqually
  1823. rowOne.alignment = .centerY
  1824. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  1825. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  1826. let rowTwo = NSStackView()
  1827. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  1828. rowTwo.orientation = .horizontal
  1829. rowTwo.spacing = 10
  1830. rowTwo.distribution = .fillEqually
  1831. rowTwo.alignment = .centerY
  1832. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  1833. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  1834. stack.addArrangedSubview(rowOne)
  1835. stack.addArrangedSubview(rowTwo)
  1836. return stack
  1837. }
  1838. func paywallBenefitItem(icon: String, text: String) -> NSView {
  1839. let card = HoverTrackingView()
  1840. card.translatesAutoresizingMaskIntoConstraints = false
  1841. card.wantsLayer = true
  1842. card.layer?.cornerRadius = 10
  1843. card.layer?.backgroundColor = palette.inputBackground.cgColor
  1844. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  1845. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1846. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  1847. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  1848. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  1849. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  1850. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1851. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue)
  1852. iconWrap.addSubview(iconLabel)
  1853. NSLayoutConstraint.activate([
  1854. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  1855. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  1856. ])
  1857. let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary)
  1858. card.addSubview(iconWrap)
  1859. card.addSubview(title)
  1860. NSLayoutConstraint.activate([
  1861. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  1862. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  1863. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  1864. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  1865. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  1866. ])
  1867. let base = palette.inputBackground
  1868. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1869. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  1870. let hoverBorder = palette.primaryBlueBorder.withAlphaComponent(0.55)
  1871. card.onHoverChanged = { [weak card, weak iconWrap] hovering in
  1872. guard let card else { return }
  1873. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  1874. card.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  1875. iconWrap?.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  1876. }
  1877. card.onHoverChanged?(false)
  1878. return card
  1879. }
  1880. func zoomJoinModeTabs() -> NSView {
  1881. let row = NSStackView()
  1882. row.translatesAutoresizingMaskIntoConstraints = false
  1883. row.orientation = .horizontal
  1884. row.alignment = .centerY
  1885. row.spacing = 28
  1886. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  1887. let idTab = joinModeTab("Join with ID", mode: .id)
  1888. let urlTab = joinModeTab("Join with URL", mode: .url)
  1889. row.addArrangedSubview(idTab)
  1890. row.addArrangedSubview(urlTab)
  1891. let spacer = NSView()
  1892. spacer.translatesAutoresizingMaskIntoConstraints = false
  1893. row.addArrangedSubview(spacer)
  1894. zoomJoinModeViews[.id] = idTab
  1895. zoomJoinModeViews[.url] = urlTab
  1896. updateZoomJoinModeAppearance()
  1897. return row
  1898. }
  1899. func joinModeTab(_ title: String, mode: ZoomJoinMode) -> NSView {
  1900. let tab = HoverTrackingView()
  1901. tab.translatesAutoresizingMaskIntoConstraints = false
  1902. tab.wantsLayer = true
  1903. tab.layer?.cornerRadius = 6
  1904. tab.layer?.backgroundColor = NSColor.clear.cgColor
  1905. tab.heightAnchor.constraint(equalToConstant: 30).isActive = true
  1906. zoomJoinModeByView[ObjectIdentifier(tab)] = mode
  1907. let label = textLabel(title, font: NSFont.systemFont(ofSize: 33 / 2, weight: .medium), color: palette.textPrimary)
  1908. tab.addSubview(label)
  1909. NSLayoutConstraint.activate([
  1910. label.leadingAnchor.constraint(equalTo: tab.leadingAnchor, constant: 4),
  1911. label.trailingAnchor.constraint(equalTo: tab.trailingAnchor, constant: -4),
  1912. label.topAnchor.constraint(equalTo: tab.topAnchor, constant: 4),
  1913. label.bottomAnchor.constraint(equalTo: tab.bottomAnchor, constant: -6)
  1914. ])
  1915. let click = NSClickGestureRecognizer(target: self, action: #selector(zoomJoinModeClicked(_:)))
  1916. tab.addGestureRecognizer(click)
  1917. return tab
  1918. }
  1919. func updateZoomJoinModeAppearance() {
  1920. for (mode, tab) in zoomJoinModeViews {
  1921. let selected = (mode == selectedZoomJoinMode)
  1922. let textColor = selected ? palette.textPrimary : palette.textSecondary
  1923. let label = tab.subviews.first { $0 is NSTextField } as? NSTextField
  1924. label?.textColor = textColor
  1925. // Keep the active tab visually underlined like the reference.
  1926. if selected {
  1927. if tab.subviews.contains(where: { $0.identifier?.rawValue == "modeUnderline" }) == false {
  1928. let underline = NSView()
  1929. underline.identifier = NSUserInterfaceItemIdentifier("modeUnderline")
  1930. underline.translatesAutoresizingMaskIntoConstraints = false
  1931. underline.wantsLayer = true
  1932. underline.layer?.backgroundColor = palette.primaryBlue.cgColor
  1933. tab.addSubview(underline)
  1934. NSLayoutConstraint.activate([
  1935. underline.leadingAnchor.constraint(equalTo: tab.leadingAnchor),
  1936. underline.trailingAnchor.constraint(equalTo: tab.trailingAnchor),
  1937. underline.bottomAnchor.constraint(equalTo: tab.bottomAnchor),
  1938. underline.heightAnchor.constraint(equalToConstant: 2)
  1939. ])
  1940. }
  1941. } else {
  1942. tab.subviews
  1943. .filter { $0.identifier?.rawValue == "modeUnderline" }
  1944. .forEach { $0.removeFromSuperview() }
  1945. }
  1946. }
  1947. }
  1948. func joinWithIDHeading() -> NSView {
  1949. let container = NSView()
  1950. container.translatesAutoresizingMaskIntoConstraints = false
  1951. let title = textLabel("Join with ID", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1952. title.alignment = .left
  1953. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  1954. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  1955. let bar = NSView()
  1956. bar.translatesAutoresizingMaskIntoConstraints = false
  1957. bar.wantsLayer = true
  1958. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  1959. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  1960. container.addSubview(title)
  1961. container.addSubview(bar)
  1962. NSLayoutConstraint.activate([
  1963. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1964. title.topAnchor.constraint(equalTo: container.topAnchor),
  1965. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1966. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  1967. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  1968. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1969. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  1970. ])
  1971. return container
  1972. }
  1973. func zoomMeetingIDSection() -> NSView {
  1974. let wrapper = NSView()
  1975. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1976. let fieldsRow = NSStackView()
  1977. fieldsRow.translatesAutoresizingMaskIntoConstraints = false
  1978. fieldsRow.orientation = .horizontal
  1979. fieldsRow.alignment = .top
  1980. fieldsRow.distribution = .fillEqually
  1981. fieldsRow.spacing = 12
  1982. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting ID", placeholder: "Enter meeting ID..."))
  1983. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting Passcode", placeholder: "Enter meeting passcode..."))
  1984. let actions = NSStackView()
  1985. actions.orientation = .horizontal
  1986. actions.spacing = 10
  1987. actions.translatesAutoresizingMaskIntoConstraints = false
  1988. actions.alignment = .centerY
  1989. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  1990. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  1991. wrapper.addSubview(fieldsRow)
  1992. wrapper.addSubview(actions)
  1993. NSLayoutConstraint.activate([
  1994. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  1995. fieldsRow.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1996. fieldsRow.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1997. fieldsRow.topAnchor.constraint(equalTo: wrapper.topAnchor),
  1998. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1999. actions.topAnchor.constraint(equalTo: fieldsRow.bottomAnchor, constant: 14),
  2000. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  2001. ])
  2002. return wrapper
  2003. }
  2004. func zoomInputField(title: String, placeholder: String) -> NSView {
  2005. let wrapper = NSView()
  2006. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2007. let heading = textLabel(title, font: typography.fieldLabel, color: palette.textPrimary)
  2008. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  2009. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  2010. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  2011. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2012. let field = NSTextField(string: "")
  2013. field.translatesAutoresizingMaskIntoConstraints = false
  2014. field.isEditable = true
  2015. field.isSelectable = true
  2016. field.isBordered = false
  2017. field.drawsBackground = false
  2018. field.placeholderString = placeholder
  2019. field.font = typography.inputPlaceholder
  2020. field.textColor = palette.textPrimary
  2021. field.focusRingType = .none
  2022. textFieldContainer.addSubview(field)
  2023. wrapper.addSubview(heading)
  2024. wrapper.addSubview(textFieldContainer)
  2025. NSLayoutConstraint.activate([
  2026. heading.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2027. heading.topAnchor.constraint(equalTo: wrapper.topAnchor),
  2028. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2029. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2030. textFieldContainer.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 10),
  2031. textFieldContainer.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
  2032. field.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  2033. field.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  2034. field.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor)
  2035. ])
  2036. return wrapper
  2037. }
  2038. func joinWithURLHeading() -> NSView {
  2039. let container = NSView()
  2040. container.translatesAutoresizingMaskIntoConstraints = false
  2041. let title = textLabel("Join with URL", font: typography.joinWithURLTitle, color: palette.textPrimary)
  2042. title.alignment = .left
  2043. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  2044. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  2045. let bar = NSView()
  2046. bar.translatesAutoresizingMaskIntoConstraints = false
  2047. bar.wantsLayer = true
  2048. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  2049. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  2050. container.addSubview(title)
  2051. container.addSubview(bar)
  2052. NSLayoutConstraint.activate([
  2053. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  2054. title.topAnchor.constraint(equalTo: container.topAnchor),
  2055. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2056. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  2057. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  2058. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  2059. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  2060. ])
  2061. return container
  2062. }
  2063. func meetingUrlSection() -> NSView {
  2064. let wrapper = NSView()
  2065. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2066. let title = textLabel("Meeting URL", font: typography.fieldLabel, color: palette.textSecondary)
  2067. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  2068. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  2069. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  2070. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2071. let urlField = NSTextField(string: "")
  2072. urlField.translatesAutoresizingMaskIntoConstraints = false
  2073. urlField.isEditable = true
  2074. urlField.isSelectable = true
  2075. urlField.isBordered = false
  2076. urlField.drawsBackground = false
  2077. urlField.placeholderString = "Enter meeting URL..."
  2078. urlField.font = typography.inputPlaceholder
  2079. urlField.textColor = palette.textPrimary
  2080. urlField.focusRingType = .none
  2081. textFieldContainer.addSubview(urlField)
  2082. let actions = NSStackView()
  2083. actions.orientation = .horizontal
  2084. actions.spacing = 10
  2085. actions.translatesAutoresizingMaskIntoConstraints = false
  2086. actions.alignment = .centerY
  2087. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  2088. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  2089. wrapper.addSubview(title)
  2090. wrapper.addSubview(textFieldContainer)
  2091. wrapper.addSubview(actions)
  2092. NSLayoutConstraint.activate([
  2093. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  2094. title.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2095. title.topAnchor.constraint(equalTo: wrapper.topAnchor),
  2096. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2097. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2098. textFieldContainer.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10),
  2099. urlField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  2100. urlField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  2101. urlField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor),
  2102. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2103. actions.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 14),
  2104. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  2105. ])
  2106. return wrapper
  2107. }
  2108. func scheduleHeader() -> NSView {
  2109. let row = NSStackView()
  2110. row.translatesAutoresizingMaskIntoConstraints = false
  2111. row.orientation = .horizontal
  2112. row.alignment = .centerY
  2113. row.distribution = .fill
  2114. row.spacing = 12
  2115. row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
  2116. let spacer = NSView()
  2117. spacer.translatesAutoresizingMaskIntoConstraints = false
  2118. row.addArrangedSubview(spacer)
  2119. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2120. row.addArrangedSubview(makeScheduleRefreshButton())
  2121. row.addArrangedSubview(makeScheduleFilterDropdown())
  2122. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  2123. return row
  2124. }
  2125. private func scheduleTopAuthRow() -> NSView {
  2126. let row = NSStackView()
  2127. row.translatesAutoresizingMaskIntoConstraints = false
  2128. row.orientation = .horizontal
  2129. row.alignment = .centerY
  2130. row.spacing = 10
  2131. let spacer = NSView()
  2132. spacer.translatesAutoresizingMaskIntoConstraints = false
  2133. row.addArrangedSubview(spacer)
  2134. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2135. let host = GoogleProfileAuthHostView()
  2136. host.translatesAutoresizingMaskIntoConstraints = false
  2137. let authButton = makeGoogleAuthButton()
  2138. host.authButton = authButton
  2139. scheduleGoogleAuthHostView = host
  2140. scheduleGoogleAuthButton = authButton
  2141. host.addSubview(authButton)
  2142. NSLayoutConstraint.activate([
  2143. authButton.centerXAnchor.constraint(equalTo: host.centerXAnchor),
  2144. authButton.centerYAnchor.constraint(equalTo: host.centerYAnchor)
  2145. ])
  2146. let hostPadW = host.widthAnchor.constraint(equalTo: authButton.widthAnchor, constant: 0)
  2147. let hostPadH = host.heightAnchor.constraint(equalTo: authButton.heightAnchor, constant: 0)
  2148. hostPadW.isActive = true
  2149. hostPadH.isActive = true
  2150. scheduleGoogleAuthHostPadWidthConstraint = hostPadW
  2151. scheduleGoogleAuthHostPadHeightConstraint = hostPadH
  2152. updateGoogleAuthButtonTitle()
  2153. row.addArrangedSubview(host)
  2154. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  2155. return row
  2156. }
  2157. private func makeScheduleFilterDropdown() -> NSPopUpButton {
  2158. let button = NSPopUpButton(frame: .zero, pullsDown: false)
  2159. button.translatesAutoresizingMaskIntoConstraints = false
  2160. button.autoenablesItems = false
  2161. button.wantsLayer = true
  2162. button.layer?.cornerRadius = 8
  2163. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2164. button.layer?.borderColor = palette.inputBorder.cgColor
  2165. button.layer?.borderWidth = 1
  2166. button.font = typography.filterText
  2167. button.contentTintColor = palette.textSecondary
  2168. button.target = self
  2169. button.action = #selector(scheduleFilterDropdownChanged(_:))
  2170. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  2171. button.widthAnchor.constraint(equalToConstant: 156).isActive = true
  2172. button.removeAllItems()
  2173. button.addItems(withTitles: ["All", "Today", "This week"])
  2174. button.selectItem(at: scheduleFilter.rawValue)
  2175. if let menu = button.menu {
  2176. for (index, item) in menu.items.enumerated() {
  2177. item.tag = index
  2178. }
  2179. }
  2180. scheduleFilterDropdown = button
  2181. return button
  2182. }
  2183. private func makeSchedulePillButton(title: String) -> NSButton {
  2184. let button = NSButton(title: title, target: nil, action: nil)
  2185. button.translatesAutoresizingMaskIntoConstraints = false
  2186. button.isBordered = false
  2187. button.bezelStyle = .regularSquare
  2188. button.wantsLayer = true
  2189. button.layer?.cornerRadius = 8
  2190. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2191. button.layer?.borderColor = palette.inputBorder.cgColor
  2192. button.layer?.borderWidth = 1
  2193. button.font = typography.filterText
  2194. button.contentTintColor = palette.textSecondary
  2195. button.setButtonType(.momentaryChange)
  2196. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  2197. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
  2198. return button
  2199. }
  2200. private func makeGoogleAuthButton() -> NSButton {
  2201. let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
  2202. button.translatesAutoresizingMaskIntoConstraints = false
  2203. button.isBordered = false
  2204. button.bezelStyle = .regularSquare
  2205. button.wantsLayer = true
  2206. button.layer?.cornerRadius = 21
  2207. button.layer?.borderWidth = 1
  2208. button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
  2209. button.imagePosition = .imageLeading
  2210. button.alignment = .center
  2211. button.imageHugsTitle = true
  2212. button.lineBreakMode = .byTruncatingTail
  2213. button.contentTintColor = palette.textPrimary
  2214. button.imageScaling = .scaleNone
  2215. button.layer?.masksToBounds = true
  2216. let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42)
  2217. heightConstraint.isActive = true
  2218. scheduleGoogleAuthButtonHeightConstraint = heightConstraint
  2219. let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
  2220. widthConstraint.isActive = true
  2221. scheduleGoogleAuthButtonWidthConstraint = widthConstraint
  2222. button.onHoverChanged = { [weak self] hovering in
  2223. self?.scheduleGoogleAuthHovering = hovering
  2224. self?.scheduleGoogleAuthHostView?.setProfileHoverActive(hovering)
  2225. self?.applyGoogleAuthButtonSurface()
  2226. }
  2227. button.onHoverChanged?(false)
  2228. return button
  2229. }
  2230. private func makeScheduleRefreshButton() -> NSButton {
  2231. let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
  2232. button.translatesAutoresizingMaskIntoConstraints = false
  2233. button.isBordered = false
  2234. button.bezelStyle = .regularSquare
  2235. button.wantsLayer = true
  2236. button.layer?.cornerRadius = 21
  2237. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2238. button.layer?.borderColor = palette.inputBorder.cgColor
  2239. button.layer?.borderWidth = 1
  2240. button.setButtonType(.momentaryChange)
  2241. button.contentTintColor = palette.textSecondary
  2242. button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
  2243. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  2244. button.imagePosition = .imageOnly
  2245. button.imageScaling = .scaleProportionallyDown
  2246. button.focusRingType = .none
  2247. button.heightAnchor.constraint(equalToConstant: 42).isActive = true
  2248. button.widthAnchor.constraint(equalToConstant: 42).isActive = true
  2249. return button
  2250. }
  2251. func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
  2252. let cardWidth: CGFloat = 240
  2253. let cardsPerViewport: CGFloat = 3
  2254. let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
  2255. let wrapper = NSStackView()
  2256. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2257. wrapper.orientation = .horizontal
  2258. wrapper.alignment = .centerY
  2259. wrapper.spacing = 10
  2260. let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
  2261. scheduleScrollLeftButton = leftButton
  2262. wrapper.addArrangedSubview(leftButton)
  2263. let scroll = NSScrollView()
  2264. scheduleCardsScrollView = scroll
  2265. scroll.translatesAutoresizingMaskIntoConstraints = false
  2266. scroll.drawsBackground = false
  2267. scroll.hasHorizontalScroller = false
  2268. scroll.hasVerticalScroller = false
  2269. scroll.horizontalScrollElasticity = .allowed
  2270. scroll.verticalScrollElasticity = .none
  2271. scroll.autohidesScrollers = false
  2272. scroll.borderType = .noBorder
  2273. scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
  2274. scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
  2275. let row = NSStackView()
  2276. row.translatesAutoresizingMaskIntoConstraints = false
  2277. row.orientation = .horizontal
  2278. row.spacing = 12
  2279. row.alignment = .top
  2280. row.distribution = .gravityAreas
  2281. row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  2282. row.heightAnchor.constraint(equalToConstant: 150).isActive = true
  2283. scheduleCardsStack = row
  2284. scroll.documentView = row
  2285. scroll.contentView.postsBoundsChangedNotifications = true
  2286. // Ensure the stack view determines content size for horizontal scrolling.
  2287. NSLayoutConstraint.activate([
  2288. row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  2289. row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
  2290. row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  2291. row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
  2292. row.heightAnchor.constraint(equalToConstant: 150)
  2293. ])
  2294. renderScheduleCards(into: row, meetings: meetings)
  2295. wrapper.addArrangedSubview(scroll)
  2296. let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
  2297. scheduleScrollRightButton = rightButton
  2298. wrapper.addArrangedSubview(rightButton)
  2299. scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2300. scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2301. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  2302. return wrapper
  2303. }
  2304. func scheduleCard(meeting: ScheduledMeeting) -> NSView {
  2305. let cardWidth: CGFloat = 240
  2306. let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
  2307. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  2308. card.translatesAutoresizingMaskIntoConstraints = false
  2309. card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  2310. card.heightAnchor.constraint(equalToConstant: 150).isActive = true
  2311. card.setContentHuggingPriority(.required, for: .horizontal)
  2312. card.setContentCompressionResistancePriority(.required, for: .horizontal)
  2313. let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
  2314. icon.translatesAutoresizingMaskIntoConstraints = false
  2315. icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
  2316. icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
  2317. let iconView = NSImageView()
  2318. iconView.translatesAutoresizingMaskIntoConstraints = false
  2319. iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
  2320. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  2321. iconView.contentTintColor = .white
  2322. icon.addSubview(iconView)
  2323. NSLayoutConstraint.activate([
  2324. iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
  2325. iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
  2326. ])
  2327. let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
  2328. title.lineBreakMode = .byTruncatingTail
  2329. title.maximumNumberOfLines = 1
  2330. title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2331. let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
  2332. let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
  2333. let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
  2334. let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
  2335. dayChip.translatesAutoresizingMaskIntoConstraints = false
  2336. dayChip.layer?.borderWidth = 1
  2337. dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
  2338. let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
  2339. dayText.translatesAutoresizingMaskIntoConstraints = false
  2340. dayChip.addSubview(dayText)
  2341. NSLayoutConstraint.activate([
  2342. dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
  2343. dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
  2344. dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
  2345. dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
  2346. ])
  2347. card.addSubview(icon)
  2348. card.addSubview(dayChip)
  2349. card.addSubview(title)
  2350. card.addSubview(subtitle)
  2351. card.addSubview(time)
  2352. card.addSubview(duration)
  2353. NSLayoutConstraint.activate([
  2354. icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  2355. icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
  2356. dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
  2357. dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  2358. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
  2359. title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  2360. title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
  2361. title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
  2362. subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  2363. subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
  2364. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  2365. time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  2366. time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
  2367. time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  2368. duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  2369. duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
  2370. duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
  2371. ])
  2372. let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
  2373. hit.translatesAutoresizingMaskIntoConstraints = false
  2374. hit.isBordered = false
  2375. hit.bezelStyle = .regularSquare
  2376. hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
  2377. hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  2378. hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
  2379. hit.setContentHuggingPriority(.required, for: .horizontal)
  2380. hit.setContentCompressionResistancePriority(.required, for: .horizontal)
  2381. hit.addSubview(card)
  2382. NSLayoutConstraint.activate([
  2383. card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
  2384. card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
  2385. card.topAnchor.constraint(equalTo: hit.topAnchor),
  2386. card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
  2387. ])
  2388. hit.onHoverChanged = { [weak self] hovering in
  2389. guard let self else { return }
  2390. let base = self.palette.sectionCard
  2391. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  2392. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  2393. if self.storeKitCoordinator.hasPremiumAccess {
  2394. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  2395. } else {
  2396. card.layer?.backgroundColor = base.cgColor
  2397. }
  2398. }
  2399. hit.onHoverChanged?(false)
  2400. if !storeKitCoordinator.hasPremiumAccess {
  2401. let lockOverlay = NSVisualEffectView()
  2402. lockOverlay.translatesAutoresizingMaskIntoConstraints = false
  2403. lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
  2404. lockOverlay.blendingMode = .withinWindow
  2405. lockOverlay.state = .active
  2406. lockOverlay.wantsLayer = true
  2407. lockOverlay.layer?.cornerRadius = 12
  2408. lockOverlay.layer?.masksToBounds = true
  2409. lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.28 : 0.12).cgColor
  2410. let lockLabel = textLabel("Get Premium to see events", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .white)
  2411. lockLabel.alignment = .center
  2412. card.addSubview(lockOverlay)
  2413. lockOverlay.addSubview(lockLabel)
  2414. NSLayoutConstraint.activate([
  2415. lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor),
  2416. lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor),
  2417. lockOverlay.topAnchor.constraint(equalTo: card.topAnchor),
  2418. lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor),
  2419. lockLabel.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
  2420. lockLabel.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
  2421. lockLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 10),
  2422. lockLabel.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -10)
  2423. ])
  2424. hit.toolTip = "Premium required. Click to open paywall."
  2425. }
  2426. return hit
  2427. }
  2428. private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
  2429. let button = NSButton(title: "", target: self, action: action)
  2430. button.translatesAutoresizingMaskIntoConstraints = false
  2431. button.isBordered = false
  2432. button.bezelStyle = .regularSquare
  2433. button.wantsLayer = true
  2434. button.layer?.cornerRadius = 16
  2435. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2436. button.layer?.borderColor = palette.inputBorder.cgColor
  2437. button.layer?.borderWidth = 1
  2438. button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
  2439. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  2440. button.imagePosition = .imageOnly
  2441. button.imageScaling = .scaleProportionallyDown
  2442. button.contentTintColor = palette.textSecondary
  2443. button.focusRingType = .none
  2444. button.heightAnchor.constraint(equalToConstant: 32).isActive = true
  2445. button.widthAnchor.constraint(equalToConstant: 32).isActive = true
  2446. return button
  2447. }
  2448. }
  2449. private extension PremiumPlan {
  2450. var displayName: String {
  2451. switch self {
  2452. case .weekly: return "Weekly"
  2453. case .monthly: return "Monthly"
  2454. case .yearly: return "Yearly"
  2455. case .lifetime: return "Lifetime"
  2456. }
  2457. }
  2458. }
  2459. extension ViewController: NSTextFieldDelegate {
  2460. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  2461. if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  2462. browseOpenAddressClicked(nil)
  2463. return true
  2464. }
  2465. return false
  2466. }
  2467. }
  2468. extension ViewController: NSWindowDelegate {
  2469. func windowWillClose(_ notification: Notification) {
  2470. guard let closingWindow = notification.object as? NSWindow else { return }
  2471. if closingWindow === paywallWindow {
  2472. paywallWindow = nil
  2473. paywallUpgradeFlowEnabled = false
  2474. }
  2475. }
  2476. }
  2477. /// Wraps the Google auth control and draws a circular accent ring with a light pulse while the signed-in avatar is hovered.
  2478. private final class GoogleProfileAuthHostView: NSView {
  2479. weak var authButton: NSButton? {
  2480. didSet { needsLayout = true }
  2481. }
  2482. private let ringLayer = CAShapeLayer()
  2483. private var avatarRingMode = false
  2484. private static let ringLineWidth: CGFloat = 2.25
  2485. override init(frame frameRect: NSRect) {
  2486. super.init(frame: frameRect)
  2487. wantsLayer = true
  2488. layer?.masksToBounds = false
  2489. ringLayer.fillColor = nil
  2490. ringLayer.strokeColor = NSColor.clear.cgColor
  2491. ringLayer.lineWidth = Self.ringLineWidth
  2492. ringLayer.lineCap = .round
  2493. ringLayer.opacity = 0
  2494. ringLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
  2495. layer?.insertSublayer(ringLayer, at: 0)
  2496. }
  2497. @available(*, unavailable)
  2498. required init?(coder: NSCoder) {
  2499. nil
  2500. }
  2501. func setAvatarRingMode(_ enabled: Bool) {
  2502. avatarRingMode = enabled
  2503. if enabled == false {
  2504. ringLayer.removeAllAnimations()
  2505. ringLayer.opacity = 0
  2506. ringLayer.lineWidth = Self.ringLineWidth
  2507. }
  2508. needsLayout = true
  2509. }
  2510. func updateRingAppearance(isDark: Bool, accent: NSColor) {
  2511. let stroke = isDark
  2512. ? accent.blended(withFraction: 0.22, of: NSColor.white) ?? accent
  2513. : accent
  2514. CATransaction.begin()
  2515. CATransaction.setDisableActions(true)
  2516. ringLayer.strokeColor = stroke.withAlphaComponent(0.95).cgColor
  2517. CATransaction.commit()
  2518. }
  2519. func setProfileHoverActive(_ active: Bool) {
  2520. guard avatarRingMode else { return }
  2521. ringLayer.removeAnimation(forKey: "pulse")
  2522. if active {
  2523. layoutRingPathIfNeeded()
  2524. CATransaction.begin()
  2525. CATransaction.setAnimationDuration(0.22)
  2526. ringLayer.opacity = 1
  2527. CATransaction.commit()
  2528. let pulse = CABasicAnimation(keyPath: "lineWidth")
  2529. pulse.fromValue = Self.ringLineWidth * 0.88
  2530. pulse.toValue = Self.ringLineWidth * 1.45
  2531. pulse.duration = 0.72
  2532. pulse.autoreverses = true
  2533. pulse.repeatCount = .infinity
  2534. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  2535. ringLayer.add(pulse, forKey: "pulse")
  2536. } else {
  2537. CATransaction.begin()
  2538. CATransaction.setAnimationDuration(0.18)
  2539. ringLayer.opacity = 0
  2540. CATransaction.commit()
  2541. ringLayer.lineWidth = Self.ringLineWidth
  2542. }
  2543. }
  2544. private func layoutRingPathIfNeeded() {
  2545. guard avatarRingMode, let btn = authButton else { return }
  2546. let f = btn.frame
  2547. guard f.width > 1, f.height > 1 else { return }
  2548. let center = CGPoint(x: f.midX, y: f.midY)
  2549. let avatarR = min(f.width, f.height) / 2
  2550. let gap: CGFloat = 3.5
  2551. let ringRadius = avatarR + gap
  2552. let d = ringRadius * 2
  2553. CATransaction.begin()
  2554. CATransaction.setDisableActions(true)
  2555. ringLayer.bounds = CGRect(x: 0, y: 0, width: d, height: d)
  2556. ringLayer.position = center
  2557. ringLayer.path = CGPath(ellipseIn: CGRect(origin: .zero, size: CGSize(width: d, height: d)), transform: nil)
  2558. CATransaction.commit()
  2559. }
  2560. override func layout() {
  2561. super.layout()
  2562. layoutRingPathIfNeeded()
  2563. }
  2564. }
  2565. /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
  2566. private class RowHitTestView: NSView {
  2567. override func hitTest(_ point: NSPoint) -> NSView? {
  2568. return bounds.contains(point) ? self : nil
  2569. }
  2570. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  2571. true
  2572. }
  2573. }
  2574. private final class HoverTrackingView: RowHitTestView {
  2575. var onHoverChanged: ((Bool) -> Void)?
  2576. var onClick: (() -> Void)?
  2577. var showsHandCursor = true
  2578. private var trackingAreaRef: NSTrackingArea?
  2579. private var isHovering = false {
  2580. didSet {
  2581. guard isHovering != oldValue else { return }
  2582. onHoverChanged?(isHovering)
  2583. }
  2584. }
  2585. override func updateTrackingAreas() {
  2586. super.updateTrackingAreas()
  2587. if let trackingAreaRef {
  2588. removeTrackingArea(trackingAreaRef)
  2589. }
  2590. let options: NSTrackingArea.Options = [
  2591. .activeInKeyWindow,
  2592. .inVisibleRect,
  2593. .mouseEnteredAndExited
  2594. ]
  2595. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  2596. addTrackingArea(area)
  2597. trackingAreaRef = area
  2598. }
  2599. override func mouseEntered(with event: NSEvent) {
  2600. super.mouseEntered(with: event)
  2601. isHovering = true
  2602. }
  2603. override func mouseExited(with event: NSEvent) {
  2604. super.mouseExited(with: event)
  2605. isHovering = false
  2606. }
  2607. override func resetCursorRects() {
  2608. super.resetCursorRects()
  2609. guard showsHandCursor else { return }
  2610. addCursorRect(bounds, cursor: .pointingHand)
  2611. }
  2612. override func mouseUp(with event: NSEvent) {
  2613. super.mouseUp(with: event)
  2614. guard event.type == .leftMouseUp else { return }
  2615. onClick?()
  2616. }
  2617. }
  2618. /// Hover tracking without overriding hit-testing; keeps controls like text fields interactive.
  2619. private final class HoverSurfaceView: NSView {
  2620. var onHoverChanged: ((Bool) -> Void)?
  2621. private var trackingAreaRef: NSTrackingArea?
  2622. private var isHovering = false {
  2623. didSet {
  2624. guard isHovering != oldValue else { return }
  2625. onHoverChanged?(isHovering)
  2626. }
  2627. }
  2628. override func updateTrackingAreas() {
  2629. super.updateTrackingAreas()
  2630. if let trackingAreaRef {
  2631. removeTrackingArea(trackingAreaRef)
  2632. }
  2633. let options: NSTrackingArea.Options = [
  2634. .activeInKeyWindow,
  2635. .inVisibleRect,
  2636. .mouseEnteredAndExited
  2637. ]
  2638. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  2639. addTrackingArea(area)
  2640. trackingAreaRef = area
  2641. }
  2642. override func mouseEntered(with event: NSEvent) {
  2643. super.mouseEntered(with: event)
  2644. isHovering = true
  2645. }
  2646. override func mouseExited(with event: NSEvent) {
  2647. super.mouseExited(with: event)
  2648. isHovering = false
  2649. }
  2650. }
  2651. private final class HoverButton: NSButton {
  2652. var onHoverChanged: ((Bool) -> Void)?
  2653. var showsHandCursor = true
  2654. private var trackingAreaRef: NSTrackingArea?
  2655. private var isHovering = false {
  2656. didSet {
  2657. guard isHovering != oldValue else { return }
  2658. onHoverChanged?(isHovering)
  2659. }
  2660. }
  2661. override func updateTrackingAreas() {
  2662. super.updateTrackingAreas()
  2663. if let trackingAreaRef {
  2664. removeTrackingArea(trackingAreaRef)
  2665. }
  2666. let options: NSTrackingArea.Options = [
  2667. .activeInKeyWindow,
  2668. .inVisibleRect,
  2669. .mouseEnteredAndExited
  2670. ]
  2671. let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  2672. addTrackingArea(tracking)
  2673. trackingAreaRef = tracking
  2674. }
  2675. override func mouseEntered(with event: NSEvent) {
  2676. super.mouseEntered(with: event)
  2677. if showsHandCursor {
  2678. NSCursor.pointingHand.set()
  2679. }
  2680. isHovering = true
  2681. }
  2682. override func mouseExited(with event: NSEvent) {
  2683. super.mouseExited(with: event)
  2684. isHovering = false
  2685. }
  2686. }
  2687. private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  2688. let size = NSSize(width: diameter, height: diameter)
  2689. let result = NSImage(size: size)
  2690. result.lockFocus()
  2691. if let ctx = NSGraphicsContext.current {
  2692. ctx.imageInterpolation = .high
  2693. }
  2694. let rect = NSRect(origin: .zero, size: size)
  2695. let path = NSBezierPath(ovalIn: rect)
  2696. path.addClip()
  2697. let src = image.size.width > 0 && image.size.height > 0
  2698. ? NSRect(origin: .zero, size: image.size)
  2699. : NSRect(origin: .zero, size: size)
  2700. image.draw(in: rect, from: src, operation: .copy, fraction: 1.0)
  2701. result.unlockFocus()
  2702. result.isTemplate = false
  2703. return result
  2704. }
  2705. private final class GoogleAccountMenuViewController: NSViewController {
  2706. private let palette: Palette
  2707. private let darkModeEnabled: Bool
  2708. private let displayName: String
  2709. private let email: String
  2710. private let avatar: NSImage?
  2711. private let onSignOut: () -> Void
  2712. init(
  2713. palette: Palette,
  2714. darkModeEnabled: Bool,
  2715. displayName: String,
  2716. email: String,
  2717. avatar: NSImage?,
  2718. onSignOut: @escaping () -> Void
  2719. ) {
  2720. self.palette = palette
  2721. self.darkModeEnabled = darkModeEnabled
  2722. self.displayName = displayName
  2723. self.email = email
  2724. self.avatar = avatar
  2725. self.onSignOut = onSignOut
  2726. super.init(nibName: nil, bundle: nil)
  2727. view = makeContentView()
  2728. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  2729. preferredContentSize = NSSize(width: 300, height: 158)
  2730. }
  2731. @available(*, unavailable)
  2732. required init?(coder: NSCoder) {
  2733. nil
  2734. }
  2735. private func makeContentView() -> NSView {
  2736. let root = NSView()
  2737. root.translatesAutoresizingMaskIntoConstraints = false
  2738. let card = NSView()
  2739. card.translatesAutoresizingMaskIntoConstraints = false
  2740. card.wantsLayer = true
  2741. card.layer?.cornerRadius = 14
  2742. card.layer?.backgroundColor = palette.sectionCard.cgColor
  2743. card.layer?.borderColor = palette.inputBorder.cgColor
  2744. card.layer?.borderWidth = 1
  2745. card.layer?.shadowColor = NSColor.black.cgColor
  2746. card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2
  2747. card.layer?.shadowOffset = CGSize(width: 0, height: 6)
  2748. card.layer?.shadowRadius = 18
  2749. card.layer?.masksToBounds = false
  2750. root.addSubview(card)
  2751. NSLayoutConstraint.activate([
  2752. card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  2753. card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  2754. card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  2755. card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  2756. root.widthAnchor.constraint(equalToConstant: 300)
  2757. ])
  2758. let inner = NSStackView()
  2759. inner.translatesAutoresizingMaskIntoConstraints = false
  2760. inner.orientation = .vertical
  2761. inner.spacing = 0
  2762. inner.alignment = .leading
  2763. card.addSubview(inner)
  2764. NSLayoutConstraint.activate([
  2765. inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  2766. inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  2767. inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18),
  2768. inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
  2769. ])
  2770. let headerRow = NSView()
  2771. headerRow.translatesAutoresizingMaskIntoConstraints = false
  2772. let avatarBox = NSView()
  2773. avatarBox.translatesAutoresizingMaskIntoConstraints = false
  2774. avatarBox.wantsLayer = true
  2775. avatarBox.layer?.cornerRadius = 24
  2776. avatarBox.layer?.masksToBounds = true
  2777. avatarBox.layer?.borderColor = palette.inputBorder.cgColor
  2778. avatarBox.layer?.borderWidth = 1
  2779. let avatarView = NSImageView()
  2780. avatarView.translatesAutoresizingMaskIntoConstraints = false
  2781. avatarView.imageScaling = .scaleAxesIndependently
  2782. avatarView.image = resolvedAvatarImage()
  2783. avatarBox.addSubview(avatarView)
  2784. NSLayoutConstraint.activate([
  2785. avatarBox.widthAnchor.constraint(equalToConstant: 48),
  2786. avatarBox.heightAnchor.constraint(equalToConstant: 48),
  2787. avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor),
  2788. avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor),
  2789. avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor),
  2790. avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor)
  2791. ])
  2792. let textColumn = NSStackView()
  2793. textColumn.translatesAutoresizingMaskIntoConstraints = false
  2794. textColumn.orientation = .vertical
  2795. textColumn.spacing = 3
  2796. textColumn.alignment = .leading
  2797. let nameField = NSTextField(labelWithString: displayName)
  2798. nameField.translatesAutoresizingMaskIntoConstraints = false
  2799. nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  2800. nameField.textColor = palette.textPrimary
  2801. nameField.lineBreakMode = .byTruncatingTail
  2802. nameField.maximumNumberOfLines = 1
  2803. nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2804. let emailField = NSTextField(labelWithString: email)
  2805. emailField.translatesAutoresizingMaskIntoConstraints = false
  2806. emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular)
  2807. emailField.textColor = palette.textTertiary
  2808. emailField.lineBreakMode = .byTruncatingTail
  2809. emailField.maximumNumberOfLines = 1
  2810. emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2811. textColumn.addArrangedSubview(nameField)
  2812. textColumn.addArrangedSubview(emailField)
  2813. headerRow.addSubview(avatarBox)
  2814. headerRow.addSubview(textColumn)
  2815. NSLayoutConstraint.activate([
  2816. avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor),
  2817. avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor),
  2818. avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor),
  2819. textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14),
  2820. textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor),
  2821. textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor),
  2822. textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor),
  2823. textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor)
  2824. ])
  2825. inner.addArrangedSubview(headerRow)
  2826. headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  2827. inner.setCustomSpacing(14, after: headerRow)
  2828. let separator = NSView()
  2829. separator.translatesAutoresizingMaskIntoConstraints = false
  2830. separator.wantsLayer = true
  2831. separator.layer?.backgroundColor = palette.separator.cgColor
  2832. separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
  2833. inner.addArrangedSubview(separator)
  2834. separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  2835. inner.setCustomSpacing(6, after: separator)
  2836. let signOutRow = HoverTrackingView()
  2837. signOutRow.translatesAutoresizingMaskIntoConstraints = false
  2838. signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true
  2839. signOutRow.wantsLayer = true
  2840. signOutRow.layer?.cornerRadius = 10
  2841. let signOutIcon = NSImageView()
  2842. signOutIcon.translatesAutoresizingMaskIntoConstraints = false
  2843. signOutIcon.imageScaling = .scaleProportionallyDown
  2844. if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) {
  2845. signOutIcon.image = sym
  2846. signOutIcon.contentTintColor = palette.textSecondary
  2847. signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  2848. }
  2849. let signOutLabel = NSTextField(labelWithString: "Log out")
  2850. signOutLabel.translatesAutoresizingMaskIntoConstraints = false
  2851. signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
  2852. signOutLabel.textColor = palette.textPrimary
  2853. signOutRow.addSubview(signOutIcon)
  2854. signOutRow.addSubview(signOutLabel)
  2855. NSLayoutConstraint.activate([
  2856. signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10),
  2857. signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  2858. signOutIcon.widthAnchor.constraint(equalToConstant: 20),
  2859. signOutIcon.heightAnchor.constraint(equalToConstant: 20),
  2860. signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10),
  2861. signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  2862. signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10)
  2863. ])
  2864. let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked))
  2865. signOutRow.addGestureRecognizer(signOutClick)
  2866. signOutRow.onHoverChanged = { [weak self] hovering in
  2867. guard let self else { return }
  2868. signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  2869. }
  2870. signOutRow.onHoverChanged?(false)
  2871. inner.addArrangedSubview(signOutRow)
  2872. signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  2873. return root
  2874. }
  2875. private func resolvedAvatarImage() -> NSImage {
  2876. if let a = avatar {
  2877. return circularNSImage(a, diameter: 48)
  2878. }
  2879. return initialLetterAvatar()
  2880. }
  2881. private func initialLetterAvatar() -> NSImage {
  2882. let d: CGFloat = 48
  2883. let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?"
  2884. let img = NSImage(size: NSSize(width: d, height: d))
  2885. img.lockFocus()
  2886. palette.primaryBlue.setFill()
  2887. NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
  2888. let attrs: [NSAttributedString.Key: Any] = [
  2889. .font: NSFont.systemFont(ofSize: 20, weight: .semibold),
  2890. .foregroundColor: NSColor.white
  2891. ]
  2892. let sz = (letter as NSString).size(withAttributes: attrs)
  2893. let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2)
  2894. (letter as NSString).draw(at: origin, withAttributes: attrs)
  2895. img.unlockFocus()
  2896. img.isTemplate = false
  2897. return img
  2898. }
  2899. @objc private func signOutClicked() {
  2900. onSignOut()
  2901. }
  2902. }
  2903. private final class SettingsMenuViewController: NSViewController {
  2904. private let palette: Palette
  2905. private let typography: Typography
  2906. private let onToggleDarkMode: (Bool) -> Void
  2907. private let onAction: (SettingsAction) -> Void
  2908. private var darkToggle: NSSwitch?
  2909. init(
  2910. palette: Palette,
  2911. typography: Typography,
  2912. darkModeEnabled: Bool,
  2913. showUpgradeInSettings: Bool,
  2914. onToggleDarkMode: @escaping (Bool) -> Void,
  2915. onAction: @escaping (SettingsAction) -> Void
  2916. ) {
  2917. self.palette = palette
  2918. self.typography = typography
  2919. self.onToggleDarkMode = onToggleDarkMode
  2920. self.onAction = onAction
  2921. super.init(nibName: nil, bundle: nil)
  2922. self.view = makeView(darkModeEnabled: darkModeEnabled, showUpgradeInSettings: showUpgradeInSettings)
  2923. }
  2924. @available(*, unavailable)
  2925. required init?(coder: NSCoder) {
  2926. nil
  2927. }
  2928. func setDarkModeEnabled(_ enabled: Bool) {
  2929. darkToggle?.state = enabled ? .on : .off
  2930. }
  2931. private func makeView(darkModeEnabled: Bool, showUpgradeInSettings: Bool) -> NSView {
  2932. let root = NSView()
  2933. root.translatesAutoresizingMaskIntoConstraints = false
  2934. let card = roundedCard()
  2935. root.addSubview(card)
  2936. NSLayoutConstraint.activate([
  2937. card.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  2938. card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  2939. card.topAnchor.constraint(equalTo: root.topAnchor),
  2940. card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  2941. root.widthAnchor.constraint(equalToConstant: 260)
  2942. ])
  2943. let stack = NSStackView()
  2944. stack.translatesAutoresizingMaskIntoConstraints = false
  2945. stack.orientation = .vertical
  2946. stack.spacing = 6
  2947. stack.alignment = .leading
  2948. card.addSubview(stack)
  2949. NSLayoutConstraint.activate([
  2950. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  2951. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  2952. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  2953. stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14)
  2954. ])
  2955. stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
  2956. stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
  2957. stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
  2958. stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
  2959. if showUpgradeInSettings {
  2960. stack.addArrangedSubview(settingsActionRow(icon: "⬆︎", title: "Upgrade", action: .upgrade))
  2961. }
  2962. for v in stack.arrangedSubviews {
  2963. v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2964. }
  2965. return root
  2966. }
  2967. private func roundedCard() -> NSView {
  2968. let view = NSView()
  2969. view.translatesAutoresizingMaskIntoConstraints = false
  2970. view.wantsLayer = true
  2971. view.layer?.cornerRadius = 12
  2972. view.layer?.backgroundColor = palette.sectionCard.cgColor
  2973. view.layer?.borderColor = palette.inputBorder.cgColor
  2974. view.layer?.borderWidth = 1
  2975. view.layer?.shadowColor = NSColor.black.cgColor
  2976. view.layer?.shadowOpacity = 0.28
  2977. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2978. view.layer?.shadowRadius = 10
  2979. return view
  2980. }
  2981. private func settingsDarkModeRow(enabled: Bool) -> NSView {
  2982. let row = NSView()
  2983. row.translatesAutoresizingMaskIntoConstraints = false
  2984. row.heightAnchor.constraint(equalToConstant: 44).isActive = true
  2985. row.wantsLayer = true
  2986. row.layer?.cornerRadius = 10
  2987. let icon = NSTextField(labelWithString: "◐")
  2988. icon.translatesAutoresizingMaskIntoConstraints = false
  2989. icon.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  2990. icon.textColor = palette.textPrimary
  2991. let title = NSTextField(labelWithString: "Dark Mode")
  2992. title.translatesAutoresizingMaskIntoConstraints = false
  2993. title.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  2994. title.textColor = palette.textPrimary
  2995. let toggle = NSSwitch()
  2996. toggle.translatesAutoresizingMaskIntoConstraints = false
  2997. toggle.state = enabled ? .on : .off
  2998. toggle.target = self
  2999. toggle.action = #selector(darkModeToggled(_:))
  3000. darkToggle = toggle
  3001. row.addSubview(icon)
  3002. row.addSubview(title)
  3003. row.addSubview(toggle)
  3004. row.layer?.backgroundColor = NSColor.clear.cgColor
  3005. NSLayoutConstraint.activate([
  3006. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  3007. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3008. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  3009. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3010. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
  3011. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  3012. ])
  3013. return row
  3014. }
  3015. private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView {
  3016. let row = HoverTrackingView()
  3017. row.translatesAutoresizingMaskIntoConstraints = false
  3018. row.heightAnchor.constraint(equalToConstant: 42).isActive = true
  3019. let iconLabel = NSTextField(labelWithString: icon)
  3020. iconLabel.translatesAutoresizingMaskIntoConstraints = false
  3021. iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  3022. iconLabel.textColor = palette.textPrimary
  3023. let titleLabel = NSTextField(labelWithString: title)
  3024. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  3025. titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  3026. titleLabel.textColor = palette.textPrimary
  3027. row.addSubview(iconLabel)
  3028. row.addSubview(titleLabel)
  3029. NSLayoutConstraint.activate([
  3030. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  3031. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3032. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  3033. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  3034. ])
  3035. let click = NSClickGestureRecognizer(target: self, action: #selector(settingsActionClicked(_:)))
  3036. row.addGestureRecognizer(click)
  3037. row.identifier = NSUserInterfaceItemIdentifier(rawValue: "\(action.rawValue)")
  3038. row.onHoverChanged = { hovering in
  3039. row.wantsLayer = true
  3040. row.layer?.cornerRadius = 10
  3041. row.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  3042. }
  3043. row.onHoverChanged?(false)
  3044. return row
  3045. }
  3046. @objc private func darkModeToggled(_ sender: NSSwitch) {
  3047. onToggleDarkMode(sender.state == .on)
  3048. }
  3049. @objc private func settingsActionClicked(_ sender: NSClickGestureRecognizer) {
  3050. guard let view = sender.view,
  3051. let raw = Int(view.identifier?.rawValue ?? ""),
  3052. let action = SettingsAction(rawValue: raw) else { return }
  3053. onAction(action)
  3054. }
  3055. }
  3056. private extension ViewController {
  3057. func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  3058. let view = NSView()
  3059. view.wantsLayer = true
  3060. view.layer?.backgroundColor = color.cgColor
  3061. view.layer?.cornerRadius = cornerRadius
  3062. return view
  3063. }
  3064. func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  3065. view.layer?.borderColor = borderColor.cgColor
  3066. view.layer?.borderWidth = borderWidth
  3067. if shadow {
  3068. view.layer?.shadowColor = NSColor.black.cgColor
  3069. view.layer?.shadowOpacity = 0.18
  3070. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  3071. view.layer?.shadowRadius = 5
  3072. }
  3073. }
  3074. func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  3075. let label = NSTextField(labelWithString: text)
  3076. label.translatesAutoresizingMaskIntoConstraints = false
  3077. label.textColor = color
  3078. label.font = font
  3079. return label
  3080. }
  3081. func iconLabel(_ text: String, size: CGFloat) -> NSTextField {
  3082. let label = NSTextField(labelWithString: text)
  3083. label.translatesAutoresizingMaskIntoConstraints = false
  3084. label.font = NSFont.systemFont(ofSize: size)
  3085. return label
  3086. }
  3087. func sidebarSectionTitle(_ text: String) -> NSTextField {
  3088. let field = textLabel(text, font: typography.sidebarSection, color: palette.textMuted)
  3089. field.alignment = .left
  3090. return field
  3091. }
  3092. func sidebarItem(_ text: String, icon: String, page: SidebarPage, logoImageName: String? = nil, logoIconWidth: CGFloat = 18, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true, showsDisclosure: Bool = false) -> NSView {
  3093. let item = HoverButton(title: "", target: self, action: #selector(sidebarButtonClicked(_:)))
  3094. item.tag = page.rawValue
  3095. item.isBordered = false
  3096. item.wantsLayer = true
  3097. item.layer?.cornerRadius = 10
  3098. item.layer?.backgroundColor = NSColor.clear.cgColor
  3099. item.translatesAutoresizingMaskIntoConstraints = false
  3100. item.heightAnchor.constraint(equalToConstant: 36).isActive = true
  3101. item.layer?.borderWidth = 0
  3102. sidebarPageByView[ObjectIdentifier(item)] = page
  3103. let leadingView: NSView
  3104. if let name = logoImageName, let logo = NSImage(named: name) {
  3105. logo.isTemplate = true
  3106. let imageView = NSImageView(image: logo)
  3107. imageView.translatesAutoresizingMaskIntoConstraints = false
  3108. imageView.imageScaling = .scaleProportionallyDown
  3109. imageView.imageAlignment = .alignCenter
  3110. imageView.isEditable = false
  3111. leadingView = imageView
  3112. } else {
  3113. leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary)
  3114. }
  3115. let titleLabel = textLabel(text, font: typography.sidebarItem, color: palette.textSecondary)
  3116. titleLabel.alignment = .left
  3117. item.addSubview(leadingView)
  3118. item.addSubview(titleLabel)
  3119. var constraints: [NSLayoutConstraint] = [
  3120. leadingView.leadingAnchor.constraint(equalTo: item.leadingAnchor, constant: 12),
  3121. leadingView.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  3122. titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8),
  3123. titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor)
  3124. ]
  3125. if showsDisclosure {
  3126. let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: palette.textSecondary)
  3127. chevron.translatesAutoresizingMaskIntoConstraints = false
  3128. chevron.alignment = .right
  3129. item.addSubview(chevron)
  3130. constraints.append(contentsOf: [
  3131. chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10),
  3132. chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  3133. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8)
  3134. ])
  3135. } else {
  3136. constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10))
  3137. }
  3138. if logoImageName != nil {
  3139. let h = logoIconWidth * logoHeightMultiplier
  3140. constraints.append(contentsOf: [
  3141. leadingView.widthAnchor.constraint(equalToConstant: logoIconWidth),
  3142. leadingView.heightAnchor.constraint(equalToConstant: h)
  3143. ])
  3144. }
  3145. NSLayoutConstraint.activate(constraints)
  3146. applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate)
  3147. item.onHoverChanged = { [weak self, weak item] hovering in
  3148. guard let self, let item else { return }
  3149. self.applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate, hovering: hovering)
  3150. }
  3151. return item
  3152. }
  3153. func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool, hovering: Bool = false) {
  3154. let selected = (page == selectedSidebarPage)
  3155. let hoverColor = darkModeEnabled ? NSColor(calibratedWhite: 1, alpha: 0.07) : NSColor(calibratedWhite: 0, alpha: 0.08)
  3156. item.layer?.backgroundColor = (selected ? palette.primaryBlue : (hovering ? hoverColor : NSColor.clear)).cgColor
  3157. let tint = selected ? NSColor.white : palette.textSecondary
  3158. let sidebarIconTint = darkModeEnabled ? tint : NSColor.black
  3159. guard item.subviews.count >= 2 else { return }
  3160. let leading = item.subviews[0]
  3161. let title = item.subviews.first { $0 is NSTextField } as? NSTextField
  3162. title?.textColor = tint
  3163. // Optional disclosure chevron (if present) is the last text field.
  3164. if let chevron = item.subviews.last as? NSTextField, chevron !== title {
  3165. chevron.textColor = sidebarIconTint
  3166. }
  3167. if let imageView = leading as? NSImageView {
  3168. if logoTemplate {
  3169. imageView.contentTintColor = sidebarIconTint
  3170. }
  3171. } else if let iconField = leading as? NSTextField {
  3172. iconField.textColor = sidebarIconTint
  3173. }
  3174. }
  3175. func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView {
  3176. let button = HoverTrackingView()
  3177. button.wantsLayer = true
  3178. button.layer?.cornerRadius = 9
  3179. button.layer?.backgroundColor = color.cgColor
  3180. button.translatesAutoresizingMaskIntoConstraints = false
  3181. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  3182. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  3183. styleSurface(button, borderColor: title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  3184. if title == "Cancel" {
  3185. button.layer?.backgroundColor = palette.cancelButton.cgColor
  3186. }
  3187. let label = textLabel(title, font: typography.buttonText, color: textColor)
  3188. button.addSubview(label)
  3189. NSLayoutConstraint.activate([
  3190. label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  3191. label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  3192. ])
  3193. let baseColor = (title == "Cancel") ? palette.cancelButton : color
  3194. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3195. let hoverColor = baseColor.blended(withFraction: 0.12, of: hoverBlend) ?? baseColor
  3196. button.onHoverChanged = { hovering in
  3197. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  3198. }
  3199. button.onHoverChanged?(false)
  3200. return button
  3201. }
  3202. func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
  3203. let button = HoverTrackingView()
  3204. button.wantsLayer = true
  3205. button.layer?.cornerRadius = size / 2
  3206. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3207. button.translatesAutoresizingMaskIntoConstraints = false
  3208. button.widthAnchor.constraint(equalToConstant: size).isActive = true
  3209. button.heightAnchor.constraint(equalToConstant: size).isActive = true
  3210. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3211. let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
  3212. let iconView = NSImageView()
  3213. iconView.translatesAutoresizingMaskIntoConstraints = false
  3214. iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
  3215. iconView.symbolConfiguration = symbolConfig
  3216. iconView.contentTintColor = palette.textSecondary
  3217. button.addSubview(iconView)
  3218. NSLayoutConstraint.activate([
  3219. iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  3220. iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  3221. ])
  3222. let baseColor = palette.inputBackground
  3223. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3224. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  3225. button.onHoverChanged = { hovering in
  3226. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  3227. }
  3228. button.onHoverChanged?(false)
  3229. button.onClick = onClick
  3230. return button
  3231. }
  3232. }
  3233. // MARK: - Schedule actions (OAuth entry)
  3234. private extension ViewController {
  3235. @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
  3236. scheduleReloadClicked()
  3237. }
  3238. @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
  3239. scrollScheduleCards(direction: -1)
  3240. }
  3241. @objc func scheduleScrollRightPressed(_ sender: NSButton) {
  3242. scrollScheduleCards(direction: 1)
  3243. }
  3244. @objc func scheduleCardButtonPressed(_ sender: NSButton) {
  3245. guard storeKitCoordinator.hasPremiumAccess else {
  3246. showPaywall()
  3247. return
  3248. }
  3249. guard let raw = sender.identifier?.rawValue,
  3250. let url = URL(string: raw) else { return }
  3251. openMeetingURL(url)
  3252. }
  3253. @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
  3254. scheduleConnectClicked()
  3255. }
  3256. private func scheduleInitialHeadingText() -> String {
  3257. googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
  3258. }
  3259. @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
  3260. guard let selectedItem = sender.selectedItem,
  3261. let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
  3262. applyScheduleFilter(filter)
  3263. }
  3264. private func applyScheduleFilter(_ filter: ScheduleFilter) {
  3265. scheduleFilter = filter
  3266. scheduleFilterDropdown?.selectItem(at: filter.rawValue)
  3267. Task { [weak self] in
  3268. await self?.loadSchedule()
  3269. }
  3270. }
  3271. private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
  3272. if meeting.isAllDay { return "All day" }
  3273. let f = DateFormatter()
  3274. f.locale = Locale.current
  3275. f.timeZone = TimeZone.current
  3276. f.dateStyle = .none
  3277. f.timeStyle = .short
  3278. return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
  3279. }
  3280. private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
  3281. let f = DateFormatter()
  3282. f.locale = Locale.current
  3283. f.timeZone = TimeZone.current
  3284. f.dateFormat = "EEE, d MMM"
  3285. return f.string(from: meeting.startDate)
  3286. }
  3287. private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
  3288. if meeting.isAllDay { return "Duration: all day" }
  3289. let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
  3290. let totalMinutes = Int(duration / 60)
  3291. let hours = totalMinutes / 60
  3292. let minutes = totalMinutes % 60
  3293. if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
  3294. if hours > 0 { return "Duration: \(hours)h" }
  3295. return "Duration: \(minutes)m"
  3296. }
  3297. private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
  3298. guard let first = meetings.first else {
  3299. return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
  3300. }
  3301. let day = Calendar.current.startOfDay(for: first.startDate)
  3302. let f = DateFormatter()
  3303. f.locale = Locale.current
  3304. f.timeZone = TimeZone.current
  3305. f.dateFormat = "EEEE, d MMM"
  3306. return f.string(from: day)
  3307. }
  3308. private func openMeetingURL(_ url: URL) {
  3309. NSWorkspace.shared.open(url)
  3310. }
  3311. private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
  3312. displayedScheduleMeetings = meetings
  3313. let shouldShowScrollControls = meetings.count > 3
  3314. scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
  3315. scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
  3316. scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
  3317. if let scroll = scheduleCardsScrollView {
  3318. scroll.reflectScrolledClipView(scroll.contentView)
  3319. }
  3320. stack.arrangedSubviews.forEach { v in
  3321. stack.removeArrangedSubview(v)
  3322. v.removeFromSuperview()
  3323. }
  3324. if meetings.isEmpty {
  3325. let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
  3326. empty.translatesAutoresizingMaskIntoConstraints = false
  3327. empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
  3328. empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
  3329. styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3330. let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
  3331. label.translatesAutoresizingMaskIntoConstraints = false
  3332. empty.addSubview(label)
  3333. NSLayoutConstraint.activate([
  3334. label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
  3335. label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
  3336. ])
  3337. stack.addArrangedSubview(empty)
  3338. return
  3339. }
  3340. for meeting in meetings {
  3341. stack.addArrangedSubview(scheduleCard(meeting: meeting))
  3342. }
  3343. }
  3344. private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
  3345. switch scheduleFilter {
  3346. case .all:
  3347. return meetings
  3348. case .today:
  3349. let start = Calendar.current.startOfDay(for: Date())
  3350. let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
  3351. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  3352. case .week:
  3353. let now = Date()
  3354. let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
  3355. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  3356. }
  3357. }
  3358. private func scrollScheduleCards(direction: Int) {
  3359. guard let scroll = scheduleCardsScrollView else { return }
  3360. let contentBounds = scroll.contentView.bounds
  3361. let step = max(220, contentBounds.width * 0.7)
  3362. let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
  3363. let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
  3364. let nextX = min(max(0, proposedX), maxX)
  3365. scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
  3366. scroll.reflectScrolledClipView(scroll.contentView)
  3367. }
  3368. private func loadSchedule() async {
  3369. do {
  3370. if googleOAuth.loadTokens() == nil {
  3371. await MainActor.run {
  3372. updateGoogleAuthButtonTitle()
  3373. applyGoogleProfile(nil)
  3374. scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  3375. if let stack = scheduleCardsStack {
  3376. renderScheduleCards(into: stack, meetings: [])
  3377. }
  3378. }
  3379. return
  3380. }
  3381. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  3382. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  3383. let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
  3384. let filtered = filteredMeetings(meetings)
  3385. await MainActor.run {
  3386. updateGoogleAuthButtonTitle()
  3387. applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  3388. scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
  3389. if let stack = scheduleCardsStack {
  3390. renderScheduleCards(into: stack, meetings: filtered)
  3391. }
  3392. }
  3393. } catch {
  3394. await MainActor.run {
  3395. updateGoogleAuthButtonTitle()
  3396. if googleOAuth.loadTokens() == nil {
  3397. applyGoogleProfile(nil)
  3398. }
  3399. scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
  3400. if let stack = scheduleCardsStack {
  3401. renderScheduleCards(into: stack, meetings: [])
  3402. }
  3403. showSimpleError("Couldn’t load schedule.", error: error)
  3404. }
  3405. }
  3406. }
  3407. func showScheduleHelp() {
  3408. let alert = NSAlert()
  3409. alert.messageText = "Google schedule"
  3410. 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."
  3411. alert.addButton(withTitle: "OK")
  3412. alert.runModal()
  3413. }
  3414. func scheduleReloadClicked() {
  3415. Task { [weak self] in
  3416. guard let self else { return }
  3417. do {
  3418. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  3419. _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  3420. await MainActor.run {
  3421. self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
  3422. self.pageCache[.joinMeetings] = nil
  3423. self.showSidebarPage(.joinMeetings)
  3424. }
  3425. await self.loadSchedule()
  3426. } catch {
  3427. await MainActor.run {
  3428. self.showSimpleError("Couldn’t refresh schedule.", error: error)
  3429. }
  3430. }
  3431. }
  3432. }
  3433. func scheduleConnectClicked() {
  3434. Task { [weak self] in
  3435. guard let self else { return }
  3436. do {
  3437. if self.googleOAuth.loadTokens() != nil {
  3438. await MainActor.run { self.showGoogleAccountMenu() }
  3439. return
  3440. }
  3441. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  3442. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  3443. let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
  3444. await MainActor.run {
  3445. self.updateGoogleAuthButtonTitle()
  3446. self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  3447. self.pageCache[.joinMeetings] = nil
  3448. self.showSidebarPage(.joinMeetings)
  3449. }
  3450. } catch {
  3451. self.showSimpleError("Couldn’t connect Google account.", error: error)
  3452. }
  3453. }
  3454. }
  3455. private func showGoogleAccountMenu() {
  3456. guard let button = scheduleGoogleAuthButton else { return }
  3457. if googleAccountPopover?.isShown == true {
  3458. googleAccountPopover?.performClose(nil)
  3459. googleAccountPopover = nil
  3460. return
  3461. }
  3462. let popover = NSPopover()
  3463. popover.behavior = .transient
  3464. popover.animates = true
  3465. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  3466. let name = scheduleCurrentProfile?.name ?? "Google account"
  3467. let email = scheduleCurrentProfile?.email ?? "Signed in"
  3468. let avatar = scheduleProfileMenuAvatar
  3469. popover.contentViewController = GoogleAccountMenuViewController(
  3470. palette: palette,
  3471. darkModeEnabled: darkModeEnabled,
  3472. displayName: name,
  3473. email: email,
  3474. avatar: avatar,
  3475. onSignOut: { [weak self] in
  3476. self?.googleAccountPopover?.performClose(nil)
  3477. self?.googleAccountPopover = nil
  3478. self?.performGoogleSignOut()
  3479. }
  3480. )
  3481. googleAccountPopover = popover
  3482. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
  3483. }
  3484. private func performGoogleSignOut() {
  3485. do {
  3486. try googleOAuth.signOut()
  3487. applyGoogleProfile(nil)
  3488. scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  3489. if let stack = scheduleCardsStack {
  3490. renderScheduleCards(into: stack, meetings: [])
  3491. }
  3492. } catch {
  3493. showSimpleError("Couldn’t logout Google account.", error: error)
  3494. }
  3495. }
  3496. private func updateGoogleAuthButtonTitle() {
  3497. let signedIn = (googleOAuth.loadTokens() != nil)
  3498. guard let button = scheduleGoogleAuthButton else { return }
  3499. let profileName = scheduleCurrentProfile?.name ?? "Google account"
  3500. let ringHostInset: CGFloat = signedIn ? 14 : 0
  3501. scheduleGoogleAuthHostPadWidthConstraint?.constant = ringHostInset
  3502. scheduleGoogleAuthHostPadHeightConstraint?.constant = ringHostInset
  3503. scheduleGoogleAuthHostView?.setAvatarRingMode(signedIn)
  3504. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: darkModeEnabled, accent: palette.primaryBlue)
  3505. if signedIn == false {
  3506. scheduleGoogleAuthHostView?.setProfileHoverActive(false)
  3507. }
  3508. if signedIn {
  3509. button.setAccessibilityLabel("\(profileName), Google account")
  3510. button.attributedTitle = NSAttributedString(string: "")
  3511. button.imagePosition = .imageOnly
  3512. button.imageScaling = .scaleProportionallyDown
  3513. button.symbolConfiguration = nil
  3514. scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize
  3515. scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize
  3516. button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2
  3517. let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
  3518. if let symbol {
  3519. let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize))
  3520. button.image = sized
  3521. button.contentTintColor = palette.textSecondary
  3522. } else {
  3523. button.image = nil
  3524. button.contentTintColor = nil
  3525. }
  3526. scheduleProfileMenuAvatar = button.image
  3527. } else {
  3528. button.setAccessibilityLabel("Sign in with Google")
  3529. let title = "Sign in with Google"
  3530. let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
  3531. let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
  3532. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3533. .font: titleFont,
  3534. .foregroundColor: titleColor
  3535. ])
  3536. let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
  3537. let idealWidth = ceil(textWidth + 80)
  3538. scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
  3539. scheduleGoogleAuthButtonHeightConstraint?.constant = 42
  3540. button.layer?.cornerRadius = 21
  3541. button.imagePosition = .imageLeading
  3542. button.imageScaling = .scaleNone
  3543. if let g = NSImage(named: "GoogleGLogo") {
  3544. button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
  3545. } else {
  3546. button.image = nil
  3547. }
  3548. button.contentTintColor = nil
  3549. }
  3550. applyGoogleAuthButtonSurface()
  3551. }
  3552. private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
  3553. let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
  3554. let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  3555. return GoogleProfileDisplay(
  3556. name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
  3557. email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
  3558. pictureURL: profile.picture.flatMap(URL.init(string:))
  3559. )
  3560. }
  3561. private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
  3562. scheduleProfileImageTask?.cancel()
  3563. scheduleProfileImageTask = nil
  3564. if profile == nil {
  3565. scheduleProfileMenuAvatar = nil
  3566. }
  3567. scheduleCurrentProfile = profile
  3568. updateGoogleAuthButtonTitle()
  3569. guard let profile, let pictureURL = profile.pictureURL else { return }
  3570. let avatarDiameter = scheduleGoogleSignedInAvatarSize
  3571. scheduleProfileImageTask = Task { [weak self] in
  3572. do {
  3573. let (data, _) = try await URLSession.shared.data(from: pictureURL)
  3574. if Task.isCancelled { return }
  3575. guard let image = NSImage(data: data) else { return }
  3576. await MainActor.run { [weak self] in
  3577. guard let self else { return }
  3578. let rounded = self.circularProfileImage(image, diameter: avatarDiameter)
  3579. self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48)
  3580. self.scheduleGoogleAuthButton?.image = rounded
  3581. self.scheduleGoogleAuthButton?.contentTintColor = nil
  3582. }
  3583. } catch {
  3584. // Keep placeholder avatar if image fetch fails.
  3585. }
  3586. }
  3587. }
  3588. private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
  3589. let result = NSImage(size: size)
  3590. result.lockFocus()
  3591. image.draw(in: NSRect(origin: .zero, size: size),
  3592. from: NSRect(origin: .zero, size: image.size),
  3593. operation: .copy,
  3594. fraction: 1.0)
  3595. result.unlockFocus()
  3596. result.isTemplate = false
  3597. return result
  3598. }
  3599. /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square).
  3600. private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  3601. circularNSImage(image, diameter: diameter)
  3602. }
  3603. private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
  3604. let base = resizedImage(image, to: iconSize)
  3605. let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
  3606. let result = NSImage(size: canvas)
  3607. result.lockFocus()
  3608. base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
  3609. from: NSRect(origin: .zero, size: base.size),
  3610. operation: .copy,
  3611. fraction: 1.0)
  3612. result.unlockFocus()
  3613. result.isTemplate = false
  3614. return result
  3615. }
  3616. private func applyGoogleAuthButtonSurface() {
  3617. guard let button = scheduleGoogleAuthButton else { return }
  3618. let signedIn = (googleOAuth.loadTokens() != nil)
  3619. let isDark = darkModeEnabled
  3620. if signedIn {
  3621. button.layer?.backgroundColor = NSColor.clear.cgColor
  3622. button.layer?.borderWidth = 0
  3623. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: isDark, accent: palette.primaryBlue)
  3624. return
  3625. }
  3626. let baseBackground = isDark
  3627. ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  3628. : NSColor.white
  3629. let hoverBlend = isDark ? NSColor.white : NSColor.black
  3630. let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
  3631. let baseBorder = isDark
  3632. ? NSColor(calibratedWhite: 0.50, alpha: 1)
  3633. : NSColor(calibratedWhite: 0.72, alpha: 1)
  3634. let hoverBorder = isDark
  3635. ? NSColor(calibratedWhite: 0.62, alpha: 1)
  3636. : NSColor(calibratedWhite: 0.56, alpha: 1)
  3637. button.layer?.borderWidth = 1
  3638. button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
  3639. button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
  3640. }
  3641. @MainActor
  3642. func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
  3643. if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
  3644. let alert = NSAlert()
  3645. alert.messageText = "Enter Google OAuth credentials"
  3646. alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
  3647. let accessory = NSStackView()
  3648. accessory.orientation = .vertical
  3649. accessory.spacing = 8
  3650. accessory.alignment = .leading
  3651. let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
  3652. idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
  3653. idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
  3654. let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
  3655. secretField.placeholderString = "Client Secret (GOCSPX-...)"
  3656. secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
  3657. accessory.addArrangedSubview(idField)
  3658. accessory.addArrangedSubview(secretField)
  3659. alert.accessoryView = accessory
  3660. alert.addButton(withTitle: "Save")
  3661. alert.addButton(withTitle: "Cancel")
  3662. // Keep this synchronous to avoid additional sheet state handling.
  3663. let response = alert.runModal()
  3664. if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
  3665. let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3666. let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3667. if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
  3668. if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
  3669. googleOAuth.setClientIdForTesting(idValue)
  3670. googleOAuth.setClientSecretForTesting(secretValue)
  3671. }
  3672. func showSimpleError(_ title: String, error: Error) {
  3673. DispatchQueue.main.async {
  3674. let alert = NSAlert()
  3675. alert.alertStyle = .warning
  3676. alert.messageText = title
  3677. alert.informativeText = error.localizedDescription
  3678. alert.addButton(withTitle: "OK")
  3679. alert.runModal()
  3680. }
  3681. }
  3682. }
  3683. private struct Palette {
  3684. let pageBackground: NSColor
  3685. let sidebarBackground: NSColor
  3686. let sectionCard: NSColor
  3687. let tabBarBackground: NSColor
  3688. let tabIdleBackground: NSColor
  3689. let inputBackground: NSColor
  3690. let inputBorder: NSColor
  3691. let primaryBlue: NSColor
  3692. let primaryBlueBorder: NSColor
  3693. let cancelButton: NSColor
  3694. let meetingBadge: NSColor
  3695. let separator: NSColor
  3696. let textPrimary: NSColor
  3697. let textSecondary: NSColor
  3698. let textTertiary: NSColor
  3699. let textMuted: NSColor
  3700. init(isDarkMode: Bool) {
  3701. if isDarkMode {
  3702. pageBackground = NSColor(calibratedRed: 10.0 / 255.0, green: 11.0 / 255.0, blue: 12.0 / 255.0, alpha: 1)
  3703. sidebarBackground = NSColor(calibratedRed: 16.0 / 255.0, green: 17.0 / 255.0, blue: 19.0 / 255.0, alpha: 1)
  3704. sectionCard = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  3705. tabBarBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  3706. tabIdleBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  3707. inputBackground = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  3708. inputBorder = NSColor(calibratedRed: 38.0 / 255.0, green: 40.0 / 255.0, blue: 44.0 / 255.0, alpha: 1)
  3709. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  3710. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  3711. cancelButton = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  3712. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  3713. separator = NSColor(calibratedRed: 26.0 / 255.0, green: 27.0 / 255.0, blue: 30.0 / 255.0, alpha: 1)
  3714. textPrimary = NSColor(calibratedWhite: 0.98, alpha: 1)
  3715. textSecondary = NSColor(calibratedWhite: 0.78, alpha: 1)
  3716. textTertiary = NSColor(calibratedWhite: 0.66, alpha: 1)
  3717. textMuted = NSColor(calibratedWhite: 0.44, alpha: 1)
  3718. } else {
  3719. pageBackground = NSColor(calibratedRed: 244.0 / 255.0, green: 246.0 / 255.0, blue: 249.0 / 255.0, alpha: 1)
  3720. sidebarBackground = NSColor(calibratedRed: 232.0 / 255.0, green: 236.0 / 255.0, blue: 242.0 / 255.0, alpha: 1)
  3721. sectionCard = NSColor.white
  3722. tabBarBackground = NSColor.white
  3723. tabIdleBackground = NSColor.white
  3724. inputBackground = NSColor(calibratedRed: 247.0 / 255.0, green: 249.0 / 255.0, blue: 252.0 / 255.0, alpha: 1)
  3725. inputBorder = NSColor(calibratedRed: 211.0 / 255.0, green: 218.0 / 255.0, blue: 228.0 / 255.0, alpha: 1)
  3726. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  3727. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  3728. cancelButton = NSColor(calibratedRed: 240.0 / 255.0, green: 243.0 / 255.0, blue: 248.0 / 255.0, alpha: 1)
  3729. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  3730. separator = NSColor(calibratedRed: 212.0 / 255.0, green: 219.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
  3731. textPrimary = NSColor(calibratedRed: 32.0 / 255.0, green: 38.0 / 255.0, blue: 47.0 / 255.0, alpha: 1)
  3732. textSecondary = NSColor(calibratedRed: 82.0 / 255.0, green: 92.0 / 255.0, blue: 107.0 / 255.0, alpha: 1)
  3733. textTertiary = NSColor(calibratedRed: 110.0 / 255.0, green: 120.0 / 255.0, blue: 136.0 / 255.0, alpha: 1)
  3734. textMuted = NSColor(calibratedRed: 134.0 / 255.0, green: 145.0 / 255.0, blue: 162.0 / 255.0, alpha: 1)
  3735. }
  3736. }
  3737. }
  3738. private struct Typography {
  3739. let sidebarBrand = NSFont.systemFont(ofSize: 26, weight: .bold)
  3740. let sidebarSection = NSFont.systemFont(ofSize: 11, weight: .medium)
  3741. let sidebarIcon = NSFont.systemFont(ofSize: 12, weight: .medium)
  3742. let sidebarItem = NSFont.systemFont(ofSize: 16, weight: .medium)
  3743. let pageTitle = NSFont.systemFont(ofSize: 27, weight: .semibold)
  3744. let joinWithURLTitle = NSFont.systemFont(ofSize: 17, weight: .semibold)
  3745. let sectionTitleBold = NSFont.systemFont(ofSize: 25, weight: .bold)
  3746. let dateHeading = NSFont.systemFont(ofSize: 18, weight: .medium)
  3747. let tabIcon = NSFont.systemFont(ofSize: 13, weight: .regular)
  3748. let tabTitle = NSFont.systemFont(ofSize: 31 / 2, weight: .semibold)
  3749. let fieldLabel = NSFont.systemFont(ofSize: 15, weight: .medium)
  3750. let inputPlaceholder = NSFont.systemFont(ofSize: 14, weight: .regular)
  3751. let buttonText = NSFont.systemFont(ofSize: 16, weight: .medium)
  3752. let filterText = NSFont.systemFont(ofSize: 15, weight: .regular)
  3753. let filterArrow = NSFont.systemFont(ofSize: 12, weight: .regular)
  3754. let iconButton = NSFont.systemFont(ofSize: 14, weight: .medium)
  3755. let cardIcon = NSFont.systemFont(ofSize: 8, weight: .bold)
  3756. let cardTitle = NSFont.systemFont(ofSize: 15, weight: .semibold)
  3757. let cardSubtitle = NSFont.systemFont(ofSize: 13, weight: .bold)
  3758. let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
  3759. }
  3760. // MARK: - In-app browser (macOS WKWebView + chrome)
  3761. // Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
  3762. private enum InAppBrowserURLPolicy: Equatable {
  3763. case allowAll
  3764. case whitelist(hostSuffixes: [String])
  3765. }
  3766. private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
  3767. let scheme = (url.scheme ?? "").lowercased()
  3768. if scheme == "about" { return true }
  3769. guard scheme == "http" || scheme == "https" else { return false }
  3770. guard let host = url.host?.lowercased() else { return false }
  3771. switch policy {
  3772. case .allowAll:
  3773. return true
  3774. case .whitelist(let suffixes):
  3775. for suffix in suffixes {
  3776. let s = suffix.lowercased()
  3777. if host == s || host.hasSuffix("." + s) { return true }
  3778. }
  3779. return false
  3780. }
  3781. }
  3782. private enum InAppBrowserWebKitSupport {
  3783. static func makeWebViewConfiguration() -> WKWebViewConfiguration {
  3784. let config = WKWebViewConfiguration()
  3785. config.websiteDataStore = .default()
  3786. config.preferences.javaScriptCanOpenWindowsAutomatically = true
  3787. if #available(macOS 12.3, *) {
  3788. config.preferences.isElementFullscreenEnabled = true
  3789. }
  3790. config.mediaTypesRequiringUserActionForPlayback = []
  3791. if #available(macOS 11.0, *) {
  3792. config.defaultWebpagePreferences.allowsContentJavaScript = true
  3793. }
  3794. config.applicationNameForUserAgent = "MeetingsApp/1.0"
  3795. return config
  3796. }
  3797. }
  3798. private final class InAppBrowserWindowController: NSWindowController {
  3799. private static let defaultContentSize = NSSize(width: 1100, height: 760)
  3800. private static let minimumContentSize = NSSize(width: 800, height: 520)
  3801. private let browserViewController = InAppBrowserContainerViewController()
  3802. init() {
  3803. let browserWindow = NSWindow(
  3804. contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
  3805. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  3806. backing: .buffered,
  3807. defer: false
  3808. )
  3809. browserWindow.title = "Browser"
  3810. browserWindow.isRestorable = false
  3811. browserWindow.setFrameAutosaveName("")
  3812. browserWindow.minSize = browserWindow.frameRect(forContentRect: NSRect(origin: .zero, size: Self.minimumContentSize)).size
  3813. browserWindow.center()
  3814. browserWindow.contentViewController = browserViewController
  3815. super.init(window: browserWindow)
  3816. }
  3817. @available(*, unavailable)
  3818. required init?(coder: NSCoder) {
  3819. nil
  3820. }
  3821. /// Resets size and position each time the browser is shown so a previously tiny window is never reused.
  3822. func applyDefaultFrameCenteredOnVisibleScreen() {
  3823. guard let w = window, let screen = w.screen ?? NSScreen.main else { return }
  3824. let windowFrame = w.frameRect(forContentRect: NSRect(origin: .zero, size: Self.defaultContentSize))
  3825. let vf = screen.visibleFrame
  3826. var frame = windowFrame
  3827. frame.origin.x = vf.midX - frame.width / 2
  3828. frame.origin.y = vf.midY - frame.height / 2
  3829. if frame.maxX > vf.maxX { frame.origin.x = vf.maxX - frame.width }
  3830. if frame.minX < vf.minX { frame.origin.x = vf.minX }
  3831. if frame.maxY > vf.maxY { frame.origin.y = vf.maxY - frame.height }
  3832. if frame.minY < vf.minY { frame.origin.y = vf.minY }
  3833. w.setFrame(frame, display: true)
  3834. }
  3835. func load(url: URL, policy: InAppBrowserURLPolicy) {
  3836. browserViewController.setNavigationPolicy(policy)
  3837. browserViewController.load(url: url)
  3838. }
  3839. }
  3840. private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
  3841. private var webView: WKWebView!
  3842. private var webContainerView: NSView!
  3843. private weak var urlField: NSTextField?
  3844. private var backButton: NSButton!
  3845. private var forwardButton: NSButton!
  3846. private var reloadStopButton: NSButton!
  3847. private var goButton: NSButton!
  3848. private var progressBar: NSProgressIndicator!
  3849. private var lastLoadedURL: URL?
  3850. private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
  3851. private var processTerminateRetryCount = 0
  3852. /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
  3853. private let maxProcessTerminateRetries = 3
  3854. private var kvoTokens: [NSKeyValueObservation] = []
  3855. deinit {
  3856. kvoTokens.removeAll()
  3857. }
  3858. func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
  3859. navigationPolicy = policy
  3860. }
  3861. override func loadView() {
  3862. let root = NSView()
  3863. root.translatesAutoresizingMaskIntoConstraints = false
  3864. let wv = makeWebView()
  3865. webView = wv
  3866. let webHost = NSView()
  3867. webHost.translatesAutoresizingMaskIntoConstraints = false
  3868. webHost.wantsLayer = true
  3869. webHost.addSubview(wv)
  3870. NSLayoutConstraint.activate([
  3871. wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
  3872. wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
  3873. wv.topAnchor.constraint(equalTo: webHost.topAnchor),
  3874. wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
  3875. ])
  3876. webContainerView = webHost
  3877. let toolbar = NSStackView()
  3878. toolbar.translatesAutoresizingMaskIntoConstraints = false
  3879. toolbar.orientation = .horizontal
  3880. toolbar.spacing = 8
  3881. toolbar.alignment = .centerY
  3882. toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
  3883. backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
  3884. backButton.target = self
  3885. backButton.action = #selector(goBack)
  3886. forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
  3887. forwardButton.target = self
  3888. forwardButton.action = #selector(goForward)
  3889. reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
  3890. reloadStopButton.target = self
  3891. reloadStopButton.action = #selector(reloadOrStop)
  3892. let field = NSTextField(string: "")
  3893. field.translatesAutoresizingMaskIntoConstraints = false
  3894. field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  3895. field.placeholderString = "Address"
  3896. field.cell?.sendsActionOnEndEditing = false
  3897. field.delegate = self
  3898. urlField = field
  3899. goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
  3900. goButton.translatesAutoresizingMaskIntoConstraints = false
  3901. goButton.bezelStyle = .rounded
  3902. toolbar.addArrangedSubview(backButton)
  3903. toolbar.addArrangedSubview(forwardButton)
  3904. toolbar.addArrangedSubview(reloadStopButton)
  3905. toolbar.addArrangedSubview(field)
  3906. toolbar.addArrangedSubview(goButton)
  3907. field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
  3908. let bar = NSProgressIndicator()
  3909. bar.translatesAutoresizingMaskIntoConstraints = false
  3910. bar.style = .bar
  3911. bar.isIndeterminate = false
  3912. bar.minValue = 0
  3913. bar.maxValue = 1
  3914. bar.doubleValue = 0
  3915. bar.isHidden = true
  3916. progressBar = bar
  3917. let separator = NSBox()
  3918. separator.translatesAutoresizingMaskIntoConstraints = false
  3919. separator.boxType = .separator
  3920. webView.navigationDelegate = self
  3921. webView.uiDelegate = self
  3922. root.addSubview(toolbar)
  3923. root.addSubview(bar)
  3924. root.addSubview(separator)
  3925. root.addSubview(webHost)
  3926. NSLayoutConstraint.activate([
  3927. toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3928. toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3929. toolbar.topAnchor.constraint(equalTo: root.topAnchor),
  3930. bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3931. bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3932. bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
  3933. bar.heightAnchor.constraint(equalToConstant: 3),
  3934. separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3935. separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3936. separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
  3937. webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3938. webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3939. webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
  3940. webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  3941. ])
  3942. view = root
  3943. installWebViewObservers()
  3944. syncToolbarFromWebView()
  3945. }
  3946. private func makeWebView() -> WKWebView {
  3947. let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
  3948. wv.translatesAutoresizingMaskIntoConstraints = false
  3949. return wv
  3950. }
  3951. private func teardownWebViewObservers() {
  3952. kvoTokens.removeAll()
  3953. }
  3954. /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
  3955. private func replaceWebViewAndLoad(url: URL) {
  3956. teardownWebViewObservers()
  3957. webView.navigationDelegate = nil
  3958. webView.uiDelegate = nil
  3959. webView.removeFromSuperview()
  3960. let wv = makeWebView()
  3961. webView = wv
  3962. webContainerView.addSubview(wv)
  3963. NSLayoutConstraint.activate([
  3964. wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
  3965. wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
  3966. wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
  3967. wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
  3968. ])
  3969. webView.navigationDelegate = self
  3970. webView.uiDelegate = self
  3971. installWebViewObservers()
  3972. syncToolbarFromWebView()
  3973. webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
  3974. }
  3975. private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
  3976. let b = NSButton()
  3977. b.translatesAutoresizingMaskIntoConstraints = false
  3978. b.bezelStyle = .texturedRounded
  3979. b.setAccessibilityLabel(accessibilityDescription)
  3980. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
  3981. b.image = img
  3982. b.imagePosition = .imageOnly
  3983. } else {
  3984. b.title = title
  3985. }
  3986. b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
  3987. return b
  3988. }
  3989. private func installWebViewObservers() {
  3990. kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
  3991. self?.syncToolbarFromWebView()
  3992. })
  3993. kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
  3994. self?.syncToolbarFromWebView()
  3995. })
  3996. kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
  3997. self?.syncToolbarFromWebView()
  3998. })
  3999. kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
  4000. self?.syncProgressFromWebView()
  4001. })
  4002. kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
  4003. self?.syncWindowTitle()
  4004. })
  4005. kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
  4006. self?.syncAddressFieldFromWebView()
  4007. })
  4008. }
  4009. private func syncToolbarFromWebView() {
  4010. backButton?.isEnabled = webView.canGoBack
  4011. forwardButton?.isEnabled = webView.canGoForward
  4012. if webView.isLoading {
  4013. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
  4014. reloadStopButton.image = img
  4015. reloadStopButton.imagePosition = .imageOnly
  4016. reloadStopButton.title = ""
  4017. } else {
  4018. reloadStopButton.title = "Stop"
  4019. }
  4020. } else {
  4021. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
  4022. reloadStopButton.image = img
  4023. reloadStopButton.imagePosition = .imageOnly
  4024. reloadStopButton.title = ""
  4025. } else {
  4026. reloadStopButton.title = "Reload"
  4027. }
  4028. }
  4029. syncProgressFromWebView()
  4030. }
  4031. private func syncProgressFromWebView() {
  4032. guard let progressBar else { return }
  4033. if webView.isLoading {
  4034. progressBar.isHidden = false
  4035. progressBar.doubleValue = webView.estimatedProgress
  4036. } else {
  4037. progressBar.isHidden = true
  4038. progressBar.doubleValue = 0
  4039. }
  4040. }
  4041. private func syncAddressFieldFromWebView() {
  4042. guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
  4043. urlField.stringValue = url.absoluteString
  4044. }
  4045. private func syncWindowTitle() {
  4046. let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  4047. let host = webView.url?.host ?? ""
  4048. view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
  4049. }
  4050. func load(url: URL) {
  4051. lastLoadedURL = url
  4052. processTerminateRetryCount = 0
  4053. urlField?.stringValue = url.absoluteString
  4054. webView.load(URLRequest(url: url))
  4055. syncWindowTitle()
  4056. }
  4057. @objc private func goBack() {
  4058. webView.goBack()
  4059. }
  4060. @objc private func goForward() {
  4061. webView.goForward()
  4062. }
  4063. @objc private func reloadOrStop() {
  4064. if webView.isLoading {
  4065. webView.stopLoading()
  4066. } else {
  4067. webView.reload()
  4068. }
  4069. }
  4070. @objc private func addressFieldSubmitted() {
  4071. let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  4072. guard raw.isEmpty == false else { return }
  4073. var normalized = raw
  4074. if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
  4075. normalized = "https://\(normalized)"
  4076. }
  4077. guard let url = URL(string: normalized),
  4078. let scheme = url.scheme?.lowercased(),
  4079. scheme == "http" || scheme == "https",
  4080. url.host != nil
  4081. else {
  4082. let alert = NSAlert()
  4083. alert.messageText = "Invalid address"
  4084. alert.informativeText = "Enter a valid web address, for example https://example.com"
  4085. alert.addButton(withTitle: "OK")
  4086. alert.runModal()
  4087. return
  4088. }
  4089. guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
  4090. presentBlockedHostAlert()
  4091. return
  4092. }
  4093. load(url: url)
  4094. }
  4095. private func presentBlockedHostAlert() {
  4096. let alert = NSAlert()
  4097. alert.messageText = "Address not allowed"
  4098. alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
  4099. alert.addButton(withTitle: "OK")
  4100. alert.runModal()
  4101. }
  4102. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  4103. processTerminateRetryCount = 0
  4104. syncAddressFieldFromWebView()
  4105. syncWindowTitle()
  4106. }
  4107. func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
  4108. let nsError = error as NSError
  4109. if nsError.code == NSURLErrorCancelled {
  4110. return
  4111. }
  4112. let alert = NSAlert()
  4113. alert.messageText = "Unable to load page"
  4114. alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
  4115. alert.addButton(withTitle: "Try Again")
  4116. alert.addButton(withTitle: "OK")
  4117. if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
  4118. processTerminateRetryCount = 0
  4119. webView.load(URLRequest(url: url))
  4120. }
  4121. }
  4122. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  4123. guard let url = lastLoadedURL else { return }
  4124. if processTerminateRetryCount < maxProcessTerminateRetries {
  4125. processTerminateRetryCount += 1
  4126. replaceWebViewAndLoad(url: url)
  4127. return
  4128. }
  4129. let alert = NSAlert()
  4130. alert.messageText = "Page stopped loading"
  4131. alert.informativeText =
  4132. "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
  4133. alert.addButton(withTitle: "Try Again")
  4134. alert.addButton(withTitle: "OK")
  4135. if alert.runModal() == .alertFirstButtonReturn {
  4136. processTerminateRetryCount = 0
  4137. replaceWebViewAndLoad(url: url)
  4138. }
  4139. }
  4140. func webView(
  4141. _ webView: WKWebView,
  4142. decidePolicyFor navigationAction: WKNavigationAction,
  4143. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  4144. ) {
  4145. guard let url = navigationAction.request.url else {
  4146. decisionHandler(.allow)
  4147. return
  4148. }
  4149. let scheme = (url.scheme ?? "").lowercased()
  4150. if scheme == "mailto" || scheme == "tel" {
  4151. decisionHandler(.cancel)
  4152. return
  4153. }
  4154. if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
  4155. if navigationAction.targetFrame?.isMainFrame != false {
  4156. DispatchQueue.main.async { [weak self] in
  4157. self?.presentBlockedHostAlert()
  4158. }
  4159. }
  4160. decisionHandler(.cancel)
  4161. return
  4162. }
  4163. decisionHandler(.allow)
  4164. }
  4165. func webView(
  4166. _ webView: WKWebView,
  4167. createWebViewWith configuration: WKWebViewConfiguration,
  4168. for navigationAction: WKNavigationAction,
  4169. windowFeatures: WKWindowFeatures
  4170. ) -> WKWebView? {
  4171. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  4172. if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
  4173. webView.load(URLRequest(url: requestURL))
  4174. } else {
  4175. presentBlockedHostAlert()
  4176. }
  4177. }
  4178. return nil
  4179. }
  4180. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  4181. if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  4182. addressFieldSubmitted()
  4183. return true
  4184. }
  4185. return false
  4186. }
  4187. }