Без опису

ViewController.swift 242KB

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