Brak opisu

ViewController.swift 322KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314
  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. case privacyPolicy = 6
  30. case termsOfServices = 7
  31. }
  32. private enum PremiumPlan: Int {
  33. case weekly = 0
  34. case monthly = 1
  35. case yearly = 2
  36. case lifetime = 3
  37. }
  38. private enum PremiumStoreProduct {
  39. static let weekly = "com.mqldev.meetingsapp.premium.weekly"
  40. static let monthly = "com.mqldev.meetingsapp.premium.monthly"
  41. static let yearly = "com.mqldev.meetingsapp.premium.yearly"
  42. static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
  43. static let allIDs = [weekly, monthly, yearly, lifetime]
  44. static func productID(for plan: PremiumPlan) -> String {
  45. switch plan {
  46. case .weekly: return weekly
  47. case .monthly: return monthly
  48. case .yearly: return yearly
  49. case .lifetime: return lifetime
  50. }
  51. }
  52. static func plan(for productID: String) -> PremiumPlan? {
  53. switch productID {
  54. case weekly: return .weekly
  55. case monthly: return .monthly
  56. case yearly: return .yearly
  57. case lifetime: return .lifetime
  58. default: return nil
  59. }
  60. }
  61. }
  62. @MainActor
  63. private final class StoreKitCoordinator {
  64. enum PurchaseOutcome {
  65. case success
  66. case cancelled
  67. case pending
  68. case unavailable
  69. case alreadyOwned
  70. case failed(String)
  71. }
  72. private(set) var productsByID: [String: Product] = [:]
  73. private(set) var activeEntitlementProductIDs: Set<String> = []
  74. private(set) var lastProductLoadError: String?
  75. var onEntitlementsChanged: ((Bool) -> Void)?
  76. var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
  77. var hasLifetimeAccess: Bool { activeEntitlementProductIDs.contains(PremiumStoreProduct.lifetime) }
  78. var activeNonLifetimePlan: PremiumPlan? {
  79. activeEntitlementProductIDs
  80. .compactMap { PremiumStoreProduct.plan(for: $0) }
  81. .filter { $0 != .lifetime }
  82. .max(by: { $0.rawValue < $1.rawValue })
  83. }
  84. private var transactionUpdatesTask: Task<Void, Never>?
  85. deinit {
  86. transactionUpdatesTask?.cancel()
  87. }
  88. func start() async {
  89. if transactionUpdatesTask == nil {
  90. transactionUpdatesTask = Task { [weak self] in
  91. await self?.observeTransactionUpdates()
  92. }
  93. }
  94. await refreshProducts()
  95. await refreshEntitlements()
  96. }
  97. func refreshProducts() async {
  98. do {
  99. let products = try await Product.products(for: PremiumStoreProduct.allIDs)
  100. productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
  101. lastProductLoadError = nil
  102. } catch {
  103. productsByID = [:]
  104. lastProductLoadError = error.localizedDescription
  105. }
  106. }
  107. func refreshEntitlements() async {
  108. let previousHasPremiumAccess = hasPremiumAccess
  109. var active = Set<String>()
  110. for await entitlement in Transaction.currentEntitlements {
  111. guard case .verified(let transaction) = entitlement else { continue }
  112. guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
  113. if Self.isTransactionActive(transaction) {
  114. active.insert(transaction.productID)
  115. }
  116. }
  117. // Some StoreKit test timelines can briefly report empty current entitlements
  118. // even though a latest verified transaction exists for a non-consumable.
  119. // Merge in latest transactions to keep launch access state accurate.
  120. for productID in PremiumStoreProduct.allIDs {
  121. guard let latest = await Transaction.latest(for: productID),
  122. case .verified(let transaction) = latest,
  123. Self.isTransactionActive(transaction) else { continue }
  124. active.insert(productID)
  125. }
  126. activeEntitlementProductIDs = active
  127. let newHasPremiumAccess = hasPremiumAccess
  128. if newHasPremiumAccess != previousHasPremiumAccess {
  129. onEntitlementsChanged?(newHasPremiumAccess)
  130. }
  131. }
  132. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  133. let productID = PremiumStoreProduct.productID(for: plan)
  134. if activeEntitlementProductIDs.contains(productID) {
  135. return .alreadyOwned
  136. }
  137. guard let product = productsByID[productID] else {
  138. await refreshProducts()
  139. guard let refreshed = productsByID[productID] else {
  140. if let lastProductLoadError, !lastProductLoadError.isEmpty {
  141. return .failed("Unable to load products: \(lastProductLoadError)")
  142. }
  143. let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
  144. let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
  145. return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
  146. }
  147. return await purchase(product: refreshed)
  148. }
  149. return await purchase(product: product)
  150. }
  151. func restorePurchases() async -> String {
  152. do {
  153. try await AppStore.sync()
  154. await refreshEntitlements()
  155. if hasPremiumAccess {
  156. return "Purchases restored successfully."
  157. }
  158. return "No previous premium purchase was found for this Apple ID."
  159. } catch {
  160. return "Restore failed: \(error.localizedDescription)"
  161. }
  162. }
  163. private func purchase(product: Product) async -> PurchaseOutcome {
  164. do {
  165. let result = try await product.purchase()
  166. switch result {
  167. case .success(let verificationResult):
  168. guard case .verified(let transaction) = verificationResult else {
  169. return .failed("Purchase verification failed.")
  170. }
  171. await transaction.finish()
  172. await refreshEntitlements()
  173. return .success
  174. case .pending:
  175. return .pending
  176. case .userCancelled:
  177. return .cancelled
  178. @unknown default:
  179. return .failed("Unknown purchase state.")
  180. }
  181. } catch {
  182. return .failed(error.localizedDescription)
  183. }
  184. }
  185. private func observeTransactionUpdates() async {
  186. for await update in Transaction.updates {
  187. guard case .verified(let transaction) = update else { continue }
  188. if PremiumStoreProduct.allIDs.contains(transaction.productID) {
  189. await refreshEntitlements()
  190. }
  191. await transaction.finish()
  192. }
  193. }
  194. private static func isTransactionActive(_ transaction: Transaction) -> Bool {
  195. if transaction.revocationDate != nil { return false }
  196. if let expirationDate = transaction.expirationDate {
  197. return expirationDate > Date()
  198. }
  199. return true
  200. }
  201. }
  202. final class ViewController: NSViewController {
  203. private enum PaywallFooterAction {
  204. case manageSubscription
  205. case restorePurchase
  206. case continueWithFreePlan
  207. case privacyPolicy
  208. case support
  209. case termsOfServices
  210. }
  211. private struct GoogleProfileDisplay {
  212. let name: String
  213. let email: String
  214. let pictureURL: URL?
  215. }
  216. private var palette = Palette(isDarkMode: true)
  217. private let typography = Typography()
  218. private let launchContentSize = NSSize(width: 920, height: 690)
  219. private let launchMinContentSize = NSSize(width: 760, height: 600)
  220. private var mainContentHost: NSView?
  221. /// Pin constraints for the current page inside `mainContentHost`; deactivated before each swap so relayout never stacks duplicates.
  222. private var mainContentHostPinConstraints: [NSLayoutConstraint] = []
  223. private var sidebarRowViews: [SidebarPage: NSView] = [:]
  224. private var selectedSidebarPage: SidebarPage = .joinMeetings
  225. private var selectedZoomJoinMode: ZoomJoinMode = .id
  226. private var pageCache: [SidebarPage: NSView] = [:]
  227. private var sidebarPageByView = [ObjectIdentifier: SidebarPage]()
  228. private var zoomJoinModeByView = [ObjectIdentifier: ZoomJoinMode]()
  229. private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
  230. private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
  231. private var paywallWindow: NSWindow?
  232. private weak var paywallOverlayView: NSView?
  233. private let paywallContentWidth: CGFloat = 520
  234. private let launchWindowLeftOffset: CGFloat = 80
  235. private var selectedPremiumPlan: PremiumPlan = .monthly
  236. private var paywallPlanViews: [PremiumPlan: NSView] = [:]
  237. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  238. private var paywallFooterActionByView = [ObjectIdentifier: PaywallFooterAction]()
  239. private weak var paywallOfferLabel: NSTextField?
  240. private weak var paywallContinueLabel: NSTextField?
  241. private weak var paywallContinueButton: NSView?
  242. private weak var sidebarPremiumTitleLabel: NSTextField?
  243. private weak var sidebarPremiumIconView: NSImageView?
  244. private weak var sidebarPremiumButtonView: HoverTrackingView?
  245. private weak var instantMeetCardView: HoverSurfaceView?
  246. private weak var instantMeetTitleLabel: NSTextField?
  247. private weak var instantMeetSubtitleLabel: NSTextField?
  248. private weak var joinWithLinkCardView: HoverSurfaceView?
  249. private weak var joinWithLinkTitleLabel: NSTextField?
  250. private weak var joinMeetPrimaryButton: NSButton?
  251. private weak var meetLinkField: NSTextField?
  252. private weak var browseAddressField: NSTextField?
  253. private var inAppBrowserWindowController: InAppBrowserWindowController?
  254. private let googleOAuth = GoogleOAuthService.shared
  255. private let calendarClient = GoogleCalendarClient()
  256. private let storeKitCoordinator = StoreKitCoordinator()
  257. private var storeKitStartupTask: Task<Void, Never>?
  258. private var paywallPurchaseTask: Task<Void, Never>?
  259. private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
  260. private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
  261. private var paywallContinueEnabled = true
  262. private var paywallUpgradeFlowEnabled = false
  263. private let launchPaywallDelay: TimeInterval = 3
  264. private var hasCompletedInitialStoreKitSync = false
  265. private var hasPresentedLaunchPaywall = false
  266. private var launchPaywallWorkItem: DispatchWorkItem?
  267. private var hasViewAppearedOnce = false
  268. private var lastKnownPremiumAccess = false
  269. private var displayedScheduleMeetings: [ScheduledMeeting] = []
  270. private var appUsageSessionStartDate: Date?
  271. private var hasObservedAppLifecycleForUsage = false
  272. private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
  273. private enum ScheduleFilter: Int {
  274. case all = 0
  275. case today = 1
  276. case week = 2
  277. }
  278. private enum SchedulePageFilter: Int {
  279. case all = 0
  280. case today = 1
  281. case week = 2
  282. case month = 3
  283. case customRange = 4
  284. }
  285. private var scheduleFilter: ScheduleFilter = .all
  286. private weak var scheduleDateHeadingLabel: NSTextField?
  287. private weak var scheduleCardsStack: NSStackView?
  288. private weak var scheduleCardsScrollView: NSScrollView?
  289. private weak var scheduleScrollLeftButton: NSView?
  290. private weak var scheduleScrollRightButton: NSView?
  291. private weak var scheduleFilterDropdown: NSPopUpButton?
  292. private weak var scheduleGoogleAuthButton: NSButton?
  293. private weak var scheduleGoogleAuthHostView: GoogleProfileAuthHostView?
  294. private var scheduleGoogleAuthHostPadWidthConstraint: NSLayoutConstraint?
  295. private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
  296. private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
  297. private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
  298. /// Circular avatar size when signed in (top-right, Google-style).
  299. private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
  300. private var scheduleGoogleAuthHovering = false
  301. private var scheduleCurrentProfile: GoogleProfileDisplay?
  302. /// Larger copy of the header avatar for the account popover (optional).
  303. private var scheduleProfileMenuAvatar: NSImage?
  304. private var scheduleProfileImageTask: Task<Void, Never>?
  305. private var googleAccountPopover: NSPopover?
  306. private var scheduleCachedMeetings: [ScheduledMeeting] = []
  307. private var schedulePageFilter: SchedulePageFilter = .all
  308. private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
  309. private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date())
  310. private var schedulePageFilteredMeetings: [ScheduledMeeting] = []
  311. private var schedulePageVisibleCount: Int = 0
  312. private let schedulePageBatchSize: Int = 6
  313. private let schedulePageCardsPerRow: Int = 3
  314. private let schedulePageCardSpacing: CGFloat = 20
  315. private let schedulePageCardHeight: CGFloat = 182
  316. /// Match `makeJoinMeetingsContent` vertical rhythm between sections.
  317. private let schedulePageStackSpacing: CGFloat = 14
  318. /// Tighter gap from header block (title + filters) to the date line below.
  319. private let schedulePageHeaderToDateSpacing: CGFloat = 10
  320. /// Join Meetings: gap from “Schedule” row to date heading, and date heading to card strip (keeps cards aligned with the rest of the column).
  321. private let joinPageScheduleHeaderToDateSpacing: CGFloat = 8
  322. private let joinPageDateToMeetingCardsSpacing: CGFloat = 8
  323. /// Match Join Meetings main content insets so the top auth/profile bar lines up with page edges.
  324. private let schedulePageLeadingInset: CGFloat = 28
  325. private let schedulePageTrailingInset: CGFloat = 28
  326. private var schedulePageScrollObservation: NSObjectProtocol?
  327. private weak var schedulePageDateHeadingLabel: NSTextField?
  328. private weak var schedulePageFilterDropdown: NSPopUpButton?
  329. private weak var schedulePageFromDatePicker: NSDatePicker?
  330. private weak var schedulePageToDatePicker: NSDatePicker?
  331. private weak var schedulePageRangeErrorLabel: NSTextField?
  332. private weak var schedulePageCardsStack: NSStackView?
  333. private weak var schedulePageCardsScrollView: NSScrollView?
  334. // MARK: - Calendar page (custom month UI)
  335. private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
  336. private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date())
  337. private weak var calendarPageMonthLabel: NSTextField?
  338. private weak var calendarPageGridStack: NSStackView?
  339. private var calendarPageGridHeightConstraint: NSLayoutConstraint?
  340. private weak var calendarPageDaySummaryLabel: NSTextField?
  341. private var calendarPageActionPopover: NSPopover?
  342. private var calendarPageCreatePopover: NSPopover?
  343. private weak var topToastView: NSVisualEffectView?
  344. private var topToastHideWorkItem: DispatchWorkItem?
  345. /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
  346. private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
  347. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  348. private let appUsageAccumulatedSecondsDefaultsKey = "rating.appUsageAccumulatedSeconds"
  349. private let userHasRatedDefaultsKey = "rating.userHasRated"
  350. private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
  351. private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
  352. private var darkModeEnabled: Bool {
  353. get {
  354. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  355. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  356. }
  357. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  358. }
  359. private func makeSettingsPopover() -> NSPopover {
  360. let popover = NSPopover()
  361. popover.behavior = .transient
  362. popover.animates = true
  363. let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
  364. let showRateUsInSettings = shouldShowRateUsInSettings
  365. popover.contentViewController = SettingsMenuViewController(
  366. palette: palette,
  367. typography: typography,
  368. darkModeEnabled: darkModeEnabled,
  369. showRateUsInSettings: showRateUsInSettings,
  370. showUpgradeInSettings: showUpgradeInSettings,
  371. onToggleDarkMode: { [weak self] enabled in
  372. self?.setDarkMode(enabled)
  373. },
  374. onAction: { [weak self] action, sourceView, clickPoint in
  375. self?.handleSettingsAction(action, sourceView: sourceView, clickLocationInSourceView: clickPoint)
  376. }
  377. )
  378. return popover
  379. }
  380. private var settingsPopover: NSPopover?
  381. override func viewDidLoad() {
  382. super.viewDidLoad()
  383. // Sync toggle + palette with current macOS appearance on launch.
  384. darkModeEnabled = systemPrefersDarkMode()
  385. palette = Palette(isDarkMode: darkModeEnabled)
  386. storeKitCoordinator.onEntitlementsChanged = { [weak self] hasPremiumAccess in
  387. guard let self else { return }
  388. self.handlePremiumAccessChanged(hasPremiumAccess)
  389. }
  390. migrateLegacyRatingStateIfNeeded()
  391. beginUsageTrackingSessionIfNeeded()
  392. observeAppLifecycleForUsageTrackingIfNeeded()
  393. setupRootView()
  394. buildMainLayout()
  395. startStoreKit()
  396. }
  397. override func viewDidAppear() {
  398. super.viewDidAppear()
  399. hasViewAppearedOnce = true
  400. presentLaunchPaywallIfNeeded()
  401. applyWindowTitle(for: selectedSidebarPage)
  402. guard let window = view.window else { return }
  403. configureMainWindowChrome(window)
  404. // Ensure launch size is applied even when macOS tries to restore prior window state.
  405. window.isRestorable = false
  406. window.setFrameAutosaveName("")
  407. DispatchQueue.main.async { [weak self, weak window] in
  408. guard let self, let window else { return }
  409. let frameSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchContentSize)).size
  410. var newFrame = window.frame
  411. newFrame.size = frameSize
  412. window.setFrame(newFrame, display: true)
  413. window.center()
  414. if let screen = window.screen ?? NSScreen.main {
  415. var adjustedFrame = window.frame
  416. adjustedFrame.origin.x -= self.launchWindowLeftOffset
  417. let minX = screen.visibleFrame.minX
  418. adjustedFrame.origin.x = max(minX, adjustedFrame.origin.x)
  419. window.setFrame(adjustedFrame, display: true)
  420. }
  421. window.minSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchMinContentSize)).size
  422. }
  423. }
  424. override var representedObject: Any? {
  425. didSet {}
  426. }
  427. deinit {
  428. premiumUpgradeRatingPromptWorkItem?.cancel()
  429. endUsageTrackingSession()
  430. if hasObservedAppLifecycleForUsage {
  431. NotificationCenter.default.removeObserver(self)
  432. }
  433. if let observer = schedulePageScrollObservation {
  434. NotificationCenter.default.removeObserver(observer)
  435. }
  436. storeKitStartupTask?.cancel()
  437. paywallPurchaseTask?.cancel()
  438. launchPaywallWorkItem?.cancel()
  439. }
  440. }
  441. private extension ViewController {
  442. func setupRootView() {
  443. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  444. view.wantsLayer = true
  445. view.layer?.backgroundColor = palette.pageBackground.cgColor
  446. }
  447. func systemPrefersDarkMode() -> Bool {
  448. // Use the system-wide appearance setting (not app/window effective appearance).
  449. // When the key is missing, macOS is in Light mode.
  450. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  451. let style = global?["AppleInterfaceStyle"] as? String
  452. return style?.lowercased() == "dark"
  453. }
  454. func buildMainLayout() {
  455. let splitContainer = NSStackView()
  456. splitContainer.translatesAutoresizingMaskIntoConstraints = false
  457. splitContainer.orientation = .horizontal
  458. splitContainer.spacing = 14
  459. splitContainer.distribution = .fill
  460. splitContainer.alignment = .top
  461. view.addSubview(splitContainer)
  462. NSLayoutConstraint.activate([
  463. splitContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  464. splitContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  465. splitContainer.topAnchor.constraint(equalTo: view.topAnchor),
  466. splitContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  467. ])
  468. let sidebar = makeSidebar()
  469. let mainPanel = makeMainPanel()
  470. sidebar.setContentHuggingPriority(.required, for: .horizontal)
  471. sidebar.setContentCompressionResistancePriority(.required, for: .horizontal)
  472. mainPanel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  473. mainPanel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  474. splitContainer.addArrangedSubview(sidebar)
  475. splitContainer.addArrangedSubview(mainPanel)
  476. }
  477. @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) {
  478. guard let view = sender.view else { return }
  479. activateSidebarItem(view)
  480. }
  481. private func activateSidebarItem(_ view: NSView) {
  482. guard let page = sidebarPageByView[ObjectIdentifier(view)],
  483. page != selectedSidebarPage || page == .settings else { return }
  484. showSidebarPage(page)
  485. }
  486. @objc private func zoomJoinModeClicked(_ sender: NSClickGestureRecognizer) {
  487. guard let view = sender.view,
  488. let mode = zoomJoinModeByView[ObjectIdentifier(view)],
  489. mode != selectedZoomJoinMode else { return }
  490. selectedZoomJoinMode = mode
  491. updateZoomJoinModeAppearance()
  492. if selectedSidebarPage == .joinMeetings {
  493. pageCache[.joinMeetings] = nil
  494. showSidebarPage(.joinMeetings)
  495. }
  496. }
  497. @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
  498. if storeKitCoordinator.hasLifetimeAccess {
  499. openManageSubscriptions()
  500. } else if storeKitCoordinator.hasPremiumAccess {
  501. showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
  502. } else {
  503. showPaywall()
  504. }
  505. }
  506. @objc private func sidebarButtonClicked(_ sender: NSButton) {
  507. guard let page = SidebarPage(rawValue: sender.tag),
  508. page != selectedSidebarPage || page == .settings else { return }
  509. showSidebarPage(page)
  510. }
  511. @objc private func joinMeetClicked(_ sender: Any?) {
  512. let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  513. guard let url = normalizedMeetJoinURL(from: rawInput) else {
  514. showSimpleAlert(
  515. title: "Invalid Meet link",
  516. 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)."
  517. )
  518. return
  519. }
  520. openInDefaultBrowser(url: url)
  521. }
  522. @objc private func joinWithLinkCardClicked(_ sender: NSClickGestureRecognizer) {
  523. meetLinkField?.window?.makeFirstResponder(meetLinkField)
  524. }
  525. @objc private func cancelMeetJoinClicked(_ sender: Any?) {
  526. meetLinkField?.stringValue = ""
  527. }
  528. @objc private func browseOpenAddressClicked(_ sender: Any?) {
  529. let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  530. guard raw.isEmpty == false else {
  531. showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
  532. return
  533. }
  534. let normalized = normalizedURLString(from: raw)
  535. guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
  536. showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
  537. return
  538. }
  539. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  540. }
  541. @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
  542. guard let url = URL(string: "https://meet.google.com/") else { return }
  543. openInDefaultBrowser(url: url)
  544. }
  545. @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
  546. guard let url = URL(string: "https://support.google.com/meet") else { return }
  547. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  548. }
  549. @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
  550. guard let url = URL(string: "https://support.zoom.us") else { return }
  551. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  552. }
  553. @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
  554. guard let url = URL(string: "https://meet.google.com/new") else { return }
  555. openInDefaultBrowser(url: url)
  556. }
  557. private func normalizedURLString(from value: String) -> String {
  558. if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
  559. return value
  560. }
  561. return "https://\(value)"
  562. }
  563. /// Typical Meet meeting code shape: three hyphen-separated groups (e.g. `nkd-grps-duv`).
  564. private func isValidMeetMeetingCode(_ code: String) -> Bool {
  565. let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
  566. guard trimmed.isEmpty == false else { return false }
  567. let pattern = "^[a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{3}$"
  568. return trimmed.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
  569. }
  570. /// Accepts `https://meet.google.com/...`, `meet.google.com/...`, or a bare code; returns canonical Meet URL or `nil`.
  571. private func normalizedMeetJoinURL(from rawInput: String) -> URL? {
  572. let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
  573. guard trimmed.isEmpty == false else { return nil }
  574. let lower = trimmed.lowercased()
  575. if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
  576. guard let url = URL(string: trimmed),
  577. let host = url.host?.lowercased(),
  578. host == "meet.google.com" || host.hasSuffix(".meet.google.com") else {
  579. return nil
  580. }
  581. let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  582. guard path.isEmpty == false else { return nil }
  583. let firstSegment = path.split(separator: "/").first.map(String.init) ?? path
  584. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  585. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  586. }
  587. if lower.hasPrefix("meet.google.com/") {
  588. let afterHost = trimmed.dropFirst("meet.google.com/".count)
  589. let beforeQuery = String(afterHost).split(separator: "?").first.map(String.init) ?? String(afterHost)
  590. let firstSegment = beforeQuery.split(separator: "/").first.map(String.init) ?? beforeQuery
  591. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  592. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  593. }
  594. if isValidMeetMeetingCode(trimmed) {
  595. return URL(string: "https://meet.google.com/\(trimmed.lowercased())")
  596. }
  597. return nil
  598. }
  599. private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
  600. let browserController: InAppBrowserWindowController
  601. if let existing = inAppBrowserWindowController {
  602. browserController = existing
  603. } else {
  604. browserController = InAppBrowserWindowController()
  605. inAppBrowserWindowController = browserController
  606. }
  607. browserController.load(url: url, policy: policy)
  608. browserController.applyDefaultFrameCenteredOnVisibleScreen()
  609. browserController.showWindow(nil)
  610. browserController.window?.makeKeyAndOrderFront(nil)
  611. browserController.window?.orderFrontRegardless()
  612. NSApp.activate(ignoringOtherApps: true)
  613. }
  614. private func openInDefaultBrowser(url: URL) {
  615. NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
  616. if let error {
  617. DispatchQueue.main.async {
  618. self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription)
  619. }
  620. }
  621. }
  622. }
  623. private func openRateUsDestination() {
  624. let configured = (Bundle.main.object(forInfoDictionaryKey: "RateUsURL") as? String)?
  625. .trimmingCharacters(in: .whitespacesAndNewlines)
  626. let placeholder = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String)?
  627. .trimmingCharacters(in: .whitespacesAndNewlines)
  628. let hardcodedRateUsURL = "https://apps.apple.com/pk/app/meeting-app-for-google-meet/id6654920763?mt=12"
  629. var candidateStrings = [String]()
  630. if let configured, !configured.isEmpty {
  631. candidateStrings.append(configured)
  632. if let appID = extractAppleAppID(from: configured) {
  633. candidateStrings.append("macappstore://apps.apple.com/app/id\(appID)")
  634. candidateStrings.append("macappstore://itunes.apple.com/app/id\(appID)")
  635. }
  636. }
  637. candidateStrings.append(hardcodedRateUsURL)
  638. candidateStrings.append("macappstore://apps.apple.com/app/id6654920763")
  639. candidateStrings.append("macappstore://itunes.apple.com/app/id6654920763")
  640. if let placeholder, !placeholder.isEmpty {
  641. candidateStrings.append(placeholder)
  642. }
  643. for candidate in candidateStrings {
  644. guard let url = URL(string: candidate) else { continue }
  645. if NSWorkspace.shared.open(url) {
  646. return
  647. }
  648. }
  649. showSimpleAlert(
  650. title: "Unable to Open Rate Page",
  651. message: "Could not open the App Store rating page. Please try again."
  652. )
  653. requestAppRatingIfEligible(markAsRated: true)
  654. }
  655. private func extractAppleAppID(from urlString: String) -> String? {
  656. guard let match = urlString.range(of: "id[0-9]+", options: .regularExpression) else {
  657. return nil
  658. }
  659. let token = String(urlString[match])
  660. return String(token.dropFirst())
  661. }
  662. private func openInSafari(url: URL) {
  663. guard let safariAppURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else {
  664. NSWorkspace.shared.open(url)
  665. return
  666. }
  667. let configuration = NSWorkspace.OpenConfiguration()
  668. NSWorkspace.shared.open([url], withApplicationAt: safariAppURL, configuration: configuration) { _, error in
  669. if let error {
  670. self.showSimpleAlert(title: "Unable to Open Safari", message: error.localizedDescription)
  671. }
  672. }
  673. }
  674. private func openManageSubscriptions() {
  675. if let appStoreURL = URL(string: "macappstore://apps.apple.com/account/subscriptions"),
  676. NSWorkspace.shared.open(appStoreURL) {
  677. return
  678. }
  679. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else {
  680. showSimpleAlert(title: "Unable to Open Subscriptions", message: "The subscriptions URL is invalid.")
  681. return
  682. }
  683. openInDefaultBrowser(url: url)
  684. }
  685. private func openRestoreSubscriptionPage() {
  686. let fallbackURL = "https://support.apple.com/en-us/108096"
  687. let restoreURL = (Bundle.main.object(forInfoDictionaryKey: "RestoreSubscriptionURL") as? String) ?? fallbackURL
  688. guard let url = URL(string: restoreURL) else {
  689. if let fallback = URL(string: fallbackURL) {
  690. NSWorkspace.shared.open(fallback)
  691. }
  692. return
  693. }
  694. NSWorkspace.shared.open(url)
  695. }
  696. private func performRestorePurchases() {
  697. Task { [weak self] in
  698. guard let self else { return }
  699. let message = await self.storeKitCoordinator.restorePurchases()
  700. self.refreshPaywallStoreUI()
  701. self.showSimpleAlert(title: "Restore Purchases", message: message)
  702. }
  703. }
  704. private func shareAppURL() -> URL? {
  705. if let configured = Bundle.main.object(forInfoDictionaryKey: "AppShareURL") as? String {
  706. let trimmed = configured.trimmingCharacters(in: .whitespacesAndNewlines)
  707. if trimmed.isEmpty == false, let url = URL(string: trimmed) {
  708. return url
  709. }
  710. }
  711. return nil
  712. }
  713. private func shareAppFromSettingsMenu(sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) {
  714. let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
  715. ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
  716. ?? "Meetings App"
  717. let message = "Check out \(appName) for managing and joining meetings."
  718. let appURL = shareAppURL()
  719. let shareItems: [Any] = appURL.map { [message, $0] } ?? [message]
  720. let picker = NSSharingServicePicker(items: shareItems)
  721. let anchorView = sourceView ?? sidebarRowViews[.settings] ?? view
  722. let anchorPoint = clickLocationInSourceView
  723. ?? NSPoint(x: anchorView.bounds.midX, y: anchorView.bounds.midY)
  724. let anchorRect = NSRect(x: anchorPoint.x, y: anchorPoint.y, width: 1, height: 1)
  725. picker.show(relativeTo: anchorRect, of: anchorView, preferredEdge: .minY)
  726. let clipboardText = ([message, appURL?.absoluteString].compactMap { $0 }).joined(separator: "\n")
  727. NSPasteboard.general.clearContents()
  728. NSPasteboard.general.setString(clipboardText, forType: .string)
  729. }
  730. private func showSidebarPage(_ page: SidebarPage) {
  731. selectedSidebarPage = page
  732. updateSidebarAppearance()
  733. applyWindowTitle(for: page)
  734. guard let host = mainContentHost else { return }
  735. NSLayoutConstraint.deactivate(mainContentHostPinConstraints)
  736. mainContentHostPinConstraints.removeAll()
  737. host.subviews.forEach { $0.removeFromSuperview() }
  738. let child = viewForPage(page)
  739. child.translatesAutoresizingMaskIntoConstraints = false
  740. host.addSubview(child)
  741. mainContentHostPinConstraints = [
  742. child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  743. child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  744. child.topAnchor.constraint(equalTo: host.topAnchor),
  745. child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
  746. ]
  747. NSLayoutConstraint.activate(mainContentHostPinConstraints)
  748. }
  749. private func showSettingsPopover() {
  750. guard let anchor = sidebarRowViews[.settings] else { return }
  751. if settingsPopover?.isShown == true {
  752. settingsPopover?.performClose(nil)
  753. return
  754. }
  755. settingsPopover = makeSettingsPopover()
  756. if let menu = settingsPopover?.contentViewController as? SettingsMenuViewController {
  757. menu.setDarkModeEnabled(darkModeEnabled)
  758. }
  759. settingsPopover?.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
  760. }
  761. private func setDarkMode(_ enabled: Bool) {
  762. darkModeEnabled = enabled
  763. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  764. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  765. palette = Palette(isDarkMode: enabled)
  766. reloadTheme()
  767. }
  768. private func reloadTheme() {
  769. pageCache.removeAll()
  770. if let observer = schedulePageScrollObservation {
  771. NotificationCenter.default.removeObserver(observer)
  772. }
  773. schedulePageScrollObservation = nil
  774. sidebarRowViews.removeAll()
  775. sidebarPageByView.removeAll()
  776. zoomJoinModeByView.removeAll()
  777. zoomJoinModeViews.removeAll()
  778. settingsActionByView.removeAll()
  779. paywallPlanViews.removeAll()
  780. premiumPlanByView.removeAll()
  781. paywallFooterActionByView.removeAll()
  782. paywallPriceLabels.removeAll()
  783. paywallSubtitleLabels.removeAll()
  784. paywallContinueLabel = nil
  785. paywallContinueButton = nil
  786. paywallContinueEnabled = true
  787. googleAccountPopover?.performClose(nil)
  788. googleAccountPopover = nil
  789. NSLayoutConstraint.deactivate(mainContentHostPinConstraints)
  790. mainContentHostPinConstraints.removeAll()
  791. mainContentHost = nil
  792. view.subviews.forEach { $0.removeFromSuperview() }
  793. setupRootView()
  794. buildMainLayout()
  795. showSidebarPage(selectedSidebarPage)
  796. }
  797. private func handleSettingsAction(_ action: SettingsAction, sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) {
  798. switch action {
  799. case .restore:
  800. performRestorePurchases()
  801. case .rateUs:
  802. openRateUsDestination()
  803. case .support:
  804. openSettingsLink(infoKey: "SupportURL")
  805. case .privacyPolicy:
  806. openSettingsLink(infoKey: "PrivacyPolicyURL")
  807. case .termsOfServices:
  808. openSettingsLink(infoKey: "TermsOfServiceURL")
  809. case .moreApps:
  810. if let moreAppsURL = Bundle.main.object(forInfoDictionaryKey: "MoreAppsURL") as? String,
  811. let url = URL(string: moreAppsURL) {
  812. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  813. }
  814. case .shareApp:
  815. shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
  816. case .upgrade:
  817. showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
  818. }
  819. }
  820. private func openSettingsLink(infoKey: String) {
  821. let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon"
  822. let urlString = (Bundle.main.object(forInfoDictionaryKey: infoKey) as? String) ?? defaultURL
  823. guard let url = URL(string: urlString) else { return }
  824. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  825. }
  826. private func showSimpleAlert(title: String, message: String) {
  827. let alert = NSAlert()
  828. alert.messageText = title
  829. alert.informativeText = message
  830. alert.addButton(withTitle: "OK")
  831. alert.runModal()
  832. }
  833. private func showTopToast(message: String, isError: Bool) {
  834. topToastHideWorkItem?.cancel()
  835. topToastHideWorkItem = nil
  836. topToastView?.removeFromSuperview()
  837. topToastView = nil
  838. let toast = NSVisualEffectView()
  839. toast.translatesAutoresizingMaskIntoConstraints = false
  840. toast.material = darkModeEnabled ? .hudWindow : .popover
  841. toast.blendingMode = .withinWindow
  842. toast.state = .active
  843. toast.wantsLayer = true
  844. toast.layer?.cornerRadius = 10
  845. toast.layer?.masksToBounds = true
  846. toast.layer?.borderWidth = 1
  847. toast.layer?.borderColor = NSColor.white.withAlphaComponent(darkModeEnabled ? 0.10 : 0.18).cgColor
  848. toast.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.68 : 0.78).cgColor
  849. toast.alphaValue = 0
  850. let row = NSStackView()
  851. row.translatesAutoresizingMaskIntoConstraints = false
  852. row.orientation = .horizontal
  853. row.alignment = .centerY
  854. row.spacing = 8
  855. row.distribution = .fill
  856. let icon = NSImageView()
  857. icon.translatesAutoresizingMaskIntoConstraints = false
  858. icon.imageScaling = .scaleProportionallyDown
  859. icon.image = NSImage(
  860. systemSymbolName: isError ? "xmark.circle.fill" : "checkmark.circle.fill",
  861. accessibilityDescription: isError ? "Error" : "Success"
  862. )
  863. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
  864. icon.contentTintColor = isError ? NSColor.systemRed : NSColor.systemGreen
  865. let label = textLabel(
  866. message,
  867. font: NSFont.systemFont(ofSize: 13, weight: .semibold),
  868. color: NSColor.white
  869. )
  870. label.alignment = .left
  871. label.maximumNumberOfLines = 2
  872. label.lineBreakMode = .byWordWrapping
  873. row.addArrangedSubview(icon)
  874. row.addArrangedSubview(label)
  875. toast.addSubview(row)
  876. view.addSubview(toast)
  877. NSLayoutConstraint.activate([
  878. toast.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  879. toast.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  880. toast.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -40),
  881. toast.heightAnchor.constraint(greaterThanOrEqualToConstant: 40),
  882. icon.widthAnchor.constraint(equalToConstant: 16),
  883. icon.heightAnchor.constraint(equalToConstant: 16),
  884. row.leadingAnchor.constraint(equalTo: toast.leadingAnchor, constant: 14),
  885. row.trailingAnchor.constraint(equalTo: toast.trailingAnchor, constant: -14),
  886. row.topAnchor.constraint(equalTo: toast.topAnchor, constant: 10),
  887. row.bottomAnchor.constraint(equalTo: toast.bottomAnchor, constant: -10)
  888. ])
  889. topToastView = toast
  890. NSAnimationContext.runAnimationGroup { context in
  891. context.duration = 0.18
  892. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  893. toast.animator().alphaValue = 1
  894. }
  895. let hideWorkItem = DispatchWorkItem { [weak self, weak toast] in
  896. guard let self, let toast else { return }
  897. NSAnimationContext.runAnimationGroup({ context in
  898. context.duration = 0.2
  899. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  900. toast.animator().alphaValue = 0
  901. }, completionHandler: {
  902. toast.removeFromSuperview()
  903. if self.topToastView === toast {
  904. self.topToastView = nil
  905. }
  906. })
  907. }
  908. topToastHideWorkItem = hideWorkItem
  909. DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: hideWorkItem)
  910. }
  911. private func confirmPremiumUpgrade(for targetPlan: PremiumPlan) -> Bool {
  912. let alert = NSAlert()
  913. if targetPlan == .lifetime, storeKitCoordinator.hasPremiumAccess, !storeKitCoordinator.hasLifetimeAccess {
  914. alert.messageText = "Switching to Lifetime"
  915. alert.informativeText = "You already have an active subscription. If you buy Lifetime, cancel your current subscription in App Store Subscriptions to avoid future renewal charges."
  916. } else {
  917. alert.messageText = "Already Premium"
  918. alert.informativeText = "You are already premium. Do you want to continue with this purchase?"
  919. }
  920. alert.addButton(withTitle: "Continue")
  921. alert.addButton(withTitle: "Cancel")
  922. return alert.runModal() == .alertFirstButtonReturn
  923. }
  924. private func showPaywall(upgradeFlow: Bool = false, preferredPlan: PremiumPlan? = nil) {
  925. if !Thread.isMainThread {
  926. DispatchQueue.main.async { [weak self] in
  927. self?.showPaywall(upgradeFlow: upgradeFlow, preferredPlan: preferredPlan)
  928. }
  929. return
  930. }
  931. paywallUpgradeFlowEnabled = upgradeFlow
  932. if let preferredPlan {
  933. selectedPremiumPlan = preferredPlan
  934. }
  935. if let existingOverlay = paywallOverlayView {
  936. refreshPaywallStoreUI()
  937. existingOverlay.alphaValue = 1
  938. view.addSubview(existingOverlay, positioned: .above, relativeTo: nil)
  939. return
  940. }
  941. if let existing = paywallWindow {
  942. refreshPaywallStoreUI()
  943. animatePaywallPresentation(existing)
  944. existing.makeKeyAndOrderFront(nil)
  945. NSApp.activate(ignoringOtherApps: true)
  946. return
  947. }
  948. let content = makePaywallContent()
  949. let overlay = NSView()
  950. overlay.translatesAutoresizingMaskIntoConstraints = false
  951. overlay.wantsLayer = true
  952. overlay.layer?.backgroundColor = palette.pageBackground.withAlphaComponent(0.98).cgColor
  953. overlay.alphaValue = 0
  954. overlay.addSubview(content)
  955. view.addSubview(overlay, positioned: .above, relativeTo: nil)
  956. NSLayoutConstraint.activate([
  957. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  958. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  959. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  960. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  961. content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  962. content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  963. content.topAnchor.constraint(equalTo: overlay.topAnchor),
  964. content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
  965. ])
  966. paywallOverlayView = overlay
  967. NSAnimationContext.runAnimationGroup { context in
  968. context.duration = 0.20
  969. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  970. overlay.animator().alphaValue = 1
  971. }
  972. Task { [weak self] in
  973. guard let self else { return }
  974. await self.storeKitCoordinator.refreshProducts()
  975. self.refreshPaywallStoreUI()
  976. }
  977. }
  978. private func animatePaywallPresentation(_ window: NSWindow) {
  979. let finalFrame = window.frame
  980. let targetScreen = window.screen ?? NSScreen.main
  981. let startY: CGFloat
  982. if let screen = targetScreen {
  983. startY = screen.visibleFrame.maxY + 12
  984. } else {
  985. startY = finalFrame.origin.y + 120
  986. }
  987. let startFrame = NSRect(x: finalFrame.origin.x, y: startY, width: finalFrame.width, height: finalFrame.height)
  988. window.setFrame(startFrame, display: false)
  989. window.alphaValue = 0
  990. NSAnimationContext.runAnimationGroup { context in
  991. context.duration = 0.28
  992. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  993. window.animator().alphaValue = 1
  994. window.animator().setFrame(finalFrame, display: true)
  995. }
  996. }
  997. @objc private func closePaywallClicked(_ sender: Any?) {
  998. if let overlay = paywallOverlayView {
  999. paywallOverlayView = nil
  1000. paywallUpgradeFlowEnabled = false
  1001. overlay.removeFromSuperview()
  1002. return
  1003. }
  1004. if let win = paywallWindow {
  1005. win.performClose(nil)
  1006. return
  1007. }
  1008. if let gesture = sender as? NSGestureRecognizer, let win = gesture.view?.window {
  1009. win.performClose(nil)
  1010. return
  1011. }
  1012. if let view = sender as? NSView, let win = view.window {
  1013. win.performClose(nil)
  1014. return
  1015. }
  1016. }
  1017. private func dismissPaywallIfPresented() {
  1018. if !Thread.isMainThread {
  1019. DispatchQueue.main.async { [weak self] in
  1020. self?.dismissPaywallIfPresented()
  1021. }
  1022. return
  1023. }
  1024. closePaywallClicked(nil)
  1025. }
  1026. @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
  1027. guard let view = sender.view else { return }
  1028. let action = paywallFooterActionByView[ObjectIdentifier(view)]
  1029. let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
  1030. guard let action else {
  1031. showSimpleAlert(title: text, message: "\(text) tapped.")
  1032. return
  1033. }
  1034. handlePaywallFooterAction(action)
  1035. }
  1036. private func handlePaywallFooterAction(_ action: PaywallFooterAction) {
  1037. switch action {
  1038. case .manageSubscription:
  1039. openManageSubscriptions()
  1040. case .restorePurchase:
  1041. openRestoreSubscriptionPage()
  1042. case .continueWithFreePlan:
  1043. closePaywallClicked(nil)
  1044. case .privacyPolicy:
  1045. openSettingsLink(infoKey: "PrivacyPolicyURL")
  1046. case .support:
  1047. openSettingsLink(infoKey: "SupportURL")
  1048. case .termsOfServices:
  1049. openSettingsLink(infoKey: "TermsOfServiceURL")
  1050. }
  1051. }
  1052. @objc private func paywallFooterButtonPressed(_ sender: NSButton) {
  1053. guard let action = paywallFooterActionByView[ObjectIdentifier(sender)] else { return }
  1054. handlePaywallFooterAction(action)
  1055. }
  1056. @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
  1057. guard let view = sender.view,
  1058. let plan = premiumPlanByView[ObjectIdentifier(view)] else { return }
  1059. selectedPremiumPlan = plan
  1060. updatePaywallPlanSelection()
  1061. }
  1062. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  1063. guard let plan = PremiumPlan(rawValue: sender.tag) else { return }
  1064. selectedPremiumPlan = plan
  1065. updatePaywallPlanSelection()
  1066. }
  1067. private func updatePaywallPlanSelection() {
  1068. for (plan, view) in paywallPlanViews {
  1069. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  1070. }
  1071. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  1072. }
  1073. private func paywallOfferText(for plan: PremiumPlan) -> String {
  1074. if storeKitCoordinator.hasPremiumAccess {
  1075. if storeKitCoordinator.hasLifetimeAccess {
  1076. return "Lifetime premium is active on this Apple ID."
  1077. }
  1078. if paywallUpgradeFlowEnabled {
  1079. let currentPlanName = storeKitCoordinator.activeNonLifetimePlan?.displayName ?? "Premium"
  1080. if plan == .lifetime {
  1081. return "Current plan: \(currentPlanName). Tap Continue to upgrade to Lifetime."
  1082. }
  1083. return "Current plan: \(currentPlanName). Select Lifetime to upgrade."
  1084. }
  1085. return "Premium is active on this Apple ID."
  1086. }
  1087. let productID = PremiumStoreProduct.productID(for: plan)
  1088. if let product = storeKitCoordinator.productsByID[productID] {
  1089. let pkrPrice = pkrDisplayPrice(product.displayPrice)
  1090. if product.type == .nonConsumable {
  1091. return "\(pkrPrice) one-time purchase"
  1092. }
  1093. if let subscription = product.subscription {
  1094. let billingText = "\(pkrPrice)/\(subscriptionUnitText(subscription.subscriptionPeriod.unit))"
  1095. if let introOffer = subscription.introductoryOffer,
  1096. introOffer.paymentMode == .freeTrial {
  1097. return "Free for \(subscriptionPeriodText(introOffer.period)), then \(billingText)"
  1098. }
  1099. return billingText
  1100. }
  1101. return pkrPrice
  1102. }
  1103. switch plan {
  1104. case .weekly:
  1105. return "PKR 1,100.00/week"
  1106. case .monthly:
  1107. return "PKR 2,500.00/month (3 days free trial)"
  1108. case .yearly:
  1109. return "PKR 9,900.00/year (about 190.38/week)"
  1110. case .lifetime:
  1111. return "PKR 14,900.00 one-time purchase"
  1112. }
  1113. }
  1114. private func pkrDisplayPrice(_ value: String) -> String {
  1115. if value.hasPrefix("PKR ") { return value }
  1116. if value.hasPrefix("Rs ") {
  1117. return "PKR " + value.dropFirst(3)
  1118. }
  1119. if value.contains("PKR") { return value }
  1120. return "PKR \(value)"
  1121. }
  1122. private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
  1123. switch unit {
  1124. case .day: return "day"
  1125. case .week: return "week"
  1126. case .month: return "month"
  1127. case .year: return "year"
  1128. @unknown default: return "period"
  1129. }
  1130. }
  1131. private func subscriptionPeriodText(_ period: Product.SubscriptionPeriod) -> String {
  1132. let unit = subscriptionUnitText(period.unit)
  1133. if period.value == 1 {
  1134. return "1 \(unit)"
  1135. }
  1136. return "\(period.value) \(unit)s"
  1137. }
  1138. private func freeTrialPackageText(for product: Product) -> String? {
  1139. guard let introOffer = product.subscription?.introductoryOffer,
  1140. introOffer.paymentMode == .freeTrial else {
  1141. return nil
  1142. }
  1143. return "\(subscriptionPeriodText(introOffer.period)) free trial"
  1144. }
  1145. private func startStoreKit() {
  1146. storeKitStartupTask?.cancel()
  1147. storeKitStartupTask = Task { [weak self] in
  1148. guard let self else { return }
  1149. await self.storeKitCoordinator.start()
  1150. self.hasCompletedInitialStoreKitSync = true
  1151. self.refreshPaywallStoreUI()
  1152. self.presentLaunchPaywallIfNeeded()
  1153. }
  1154. }
  1155. private func refreshPaywallStoreUI() {
  1156. for (plan, label) in paywallPriceLabels {
  1157. let productID = PremiumStoreProduct.productID(for: plan)
  1158. if let product = storeKitCoordinator.productsByID[productID] {
  1159. label.stringValue = pkrDisplayPrice(product.displayPrice)
  1160. }
  1161. }
  1162. for (plan, label) in paywallSubtitleLabels {
  1163. let productID = PremiumStoreProduct.productID(for: plan)
  1164. guard let product = storeKitCoordinator.productsByID[productID],
  1165. let period = product.subscription?.subscriptionPeriod else {
  1166. // Show neutral fallback text when subscription metadata isn't available.
  1167. label.stringValue = "Billed via App Store"
  1168. continue
  1169. }
  1170. let recurringText = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
  1171. if let trialText = freeTrialPackageText(for: product) {
  1172. label.stringValue = "\(recurringText) • \(trialText)"
  1173. } else {
  1174. label.stringValue = recurringText
  1175. }
  1176. }
  1177. refreshSidebarPremiumButton()
  1178. refreshInstantMeetPremiumState()
  1179. updatePaywallPlanSelection()
  1180. updatePaywallContinueState(isLoading: false)
  1181. }
  1182. private func refreshInstantMeetPremiumState() {
  1183. instantMeetCardView?.alphaValue = 1.0
  1184. instantMeetTitleLabel?.alphaValue = 1.0
  1185. instantMeetSubtitleLabel?.alphaValue = 1.0
  1186. instantMeetCardView?.toolTip = nil
  1187. instantMeetCardView?.onHoverChanged?(false)
  1188. joinWithLinkCardView?.alphaValue = 1.0
  1189. joinWithLinkTitleLabel?.alphaValue = 1.0
  1190. meetLinkField?.isEditable = true
  1191. meetLinkField?.isSelectable = true
  1192. meetLinkField?.alphaValue = 1.0
  1193. joinMeetPrimaryButton?.isEnabled = true
  1194. joinMeetPrimaryButton?.alphaValue = 1.0
  1195. joinWithLinkCardView?.toolTip = nil
  1196. }
  1197. private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
  1198. let hadPremiumAccess = lastKnownPremiumAccess
  1199. lastKnownPremiumAccess = hasPremiumAccess
  1200. premiumUpgradeRatingPromptWorkItem?.cancel()
  1201. refreshPaywallStoreUI()
  1202. refreshScheduleCardsForPremiumStateChange()
  1203. refreshPagesAfterPremiumStateUpdate()
  1204. Task { [weak self] in
  1205. await self?.loadSchedule()
  1206. }
  1207. if !hadPremiumAccess && hasPremiumAccess {
  1208. dismissPaywallIfPresented()
  1209. if selectedSidebarPage != .joinMeetings {
  1210. Task { [weak self] in
  1211. await self?.loadSchedule()
  1212. }
  1213. }
  1214. // Skip delayed review prompt during initial launch entitlement sync.
  1215. // We only want this after a real in-session upgrade.
  1216. if hasCompletedInitialStoreKitSync {
  1217. scheduleRatingPromptAfterPremiumUpgrade()
  1218. }
  1219. }
  1220. if hadPremiumAccess && !hasPremiumAccess {
  1221. showPaywall()
  1222. }
  1223. }
  1224. private func refreshPagesAfterPremiumStateUpdate() {
  1225. pageCache[.joinMeetings] = nil
  1226. pageCache[.photo] = nil
  1227. pageCache[.video] = nil
  1228. pageCache[.settings] = nil
  1229. showSidebarPage(selectedSidebarPage)
  1230. }
  1231. private var userHasRated: Bool {
  1232. UserDefaults.standard.bool(forKey: userHasRatedDefaultsKey)
  1233. }
  1234. private var accumulatedAppUsageSeconds: TimeInterval {
  1235. get {
  1236. UserDefaults.standard.double(forKey: appUsageAccumulatedSecondsDefaultsKey)
  1237. }
  1238. set {
  1239. UserDefaults.standard.set(newValue, forKey: appUsageAccumulatedSecondsDefaultsKey)
  1240. }
  1241. }
  1242. private var totalTrackedUsageSeconds: TimeInterval {
  1243. let liveSessionSeconds: TimeInterval
  1244. if let start = appUsageSessionStartDate {
  1245. liveSessionSeconds = max(0, Date().timeIntervalSince(start))
  1246. } else {
  1247. liveSessionSeconds = 0
  1248. }
  1249. return accumulatedAppUsageSeconds + liveSessionSeconds
  1250. }
  1251. private var hasReachedRatingUsageThreshold: Bool {
  1252. totalTrackedUsageSeconds >= ratingEligibleUsageSeconds
  1253. }
  1254. private var shouldShowRateUsInSettings: Bool {
  1255. true
  1256. }
  1257. private func migrateLegacyRatingStateIfNeeded() {
  1258. let defaults = UserDefaults.standard
  1259. guard !defaults.bool(forKey: ratingStateMigrationV2DoneDefaultsKey) else { return }
  1260. // Legacy behavior marked "rated" immediately after requesting review.
  1261. // Clear once so testing and new logic can run correctly.
  1262. defaults.set(false, forKey: userHasRatedDefaultsKey)
  1263. defaults.set(true, forKey: ratingStateMigrationV2DoneDefaultsKey)
  1264. }
  1265. private func beginUsageTrackingSessionIfNeeded() {
  1266. guard appUsageSessionStartDate == nil else { return }
  1267. appUsageSessionStartDate = Date()
  1268. }
  1269. private func endUsageTrackingSession() {
  1270. guard let start = appUsageSessionStartDate else { return }
  1271. let sessionElapsedSeconds = max(0, Date().timeIntervalSince(start))
  1272. accumulatedAppUsageSeconds += sessionElapsedSeconds
  1273. appUsageSessionStartDate = nil
  1274. }
  1275. private func observeAppLifecycleForUsageTrackingIfNeeded() {
  1276. guard !hasObservedAppLifecycleForUsage else { return }
  1277. hasObservedAppLifecycleForUsage = true
  1278. NotificationCenter.default.addObserver(
  1279. self,
  1280. selector: #selector(applicationDidBecomeActiveForUsageTracking),
  1281. name: NSApplication.didBecomeActiveNotification,
  1282. object: nil
  1283. )
  1284. NotificationCenter.default.addObserver(
  1285. self,
  1286. selector: #selector(applicationWillResignActiveForUsageTracking),
  1287. name: NSApplication.willResignActiveNotification,
  1288. object: nil
  1289. )
  1290. NotificationCenter.default.addObserver(
  1291. self,
  1292. selector: #selector(applicationWillTerminateForUsageTracking),
  1293. name: NSApplication.willTerminateNotification,
  1294. object: nil
  1295. )
  1296. }
  1297. @objc private func applicationDidBecomeActiveForUsageTracking() {
  1298. beginUsageTrackingSessionIfNeeded()
  1299. }
  1300. @objc private func applicationWillResignActiveForUsageTracking() {
  1301. endUsageTrackingSession()
  1302. }
  1303. @objc private func applicationWillTerminateForUsageTracking() {
  1304. endUsageTrackingSession()
  1305. }
  1306. private func scheduleRatingPromptAfterPremiumUpgrade() {
  1307. guard !userHasRated else { return }
  1308. let workItem = DispatchWorkItem { [weak self] in
  1309. self?.requestAppRatingIfEligible(markAsRated: false)
  1310. }
  1311. premiumUpgradeRatingPromptWorkItem = workItem
  1312. DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: workItem)
  1313. }
  1314. private func requestAppRatingIfEligible(markAsRated: Bool) {
  1315. guard storeKitCoordinator.hasPremiumAccess, !userHasRated else { return }
  1316. SKStoreReviewController.requestReview()
  1317. if markAsRated {
  1318. UserDefaults.standard.set(true, forKey: userHasRatedDefaultsKey)
  1319. }
  1320. }
  1321. private func refreshScheduleCardsForPremiumStateChange() {
  1322. if let stack = scheduleCardsStack {
  1323. renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
  1324. }
  1325. applySchedulePageFiltersAndRender()
  1326. }
  1327. private func refreshSidebarPremiumButton() {
  1328. let isPremium = storeKitCoordinator.hasPremiumAccess
  1329. if isPremium {
  1330. sidebarPremiumTitleLabel?.stringValue = "Manage Subscription"
  1331. sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "crown.fill")
  1332. } else {
  1333. sidebarPremiumTitleLabel?.stringValue = "Get Premium"
  1334. sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "star.fill")
  1335. }
  1336. sidebarPremiumIconView?.contentTintColor = .white
  1337. sidebarPremiumButtonView?.onHoverChanged?(false)
  1338. }
  1339. private func premiumButtonSymbolImage(named symbolName: String) -> NSImage? {
  1340. let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  1341. return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
  1342. .withSymbolConfiguration(configuration)
  1343. }
  1344. private func presentLaunchPaywallIfNeeded() {
  1345. guard hasCompletedInitialStoreKitSync, hasViewAppearedOnce, !hasPresentedLaunchPaywall else { return }
  1346. hasPresentedLaunchPaywall = true
  1347. if !storeKitCoordinator.hasPremiumAccess {
  1348. launchPaywallWorkItem?.cancel()
  1349. let workItem = DispatchWorkItem { [weak self] in
  1350. guard let self else { return }
  1351. self.launchPaywallWorkItem = nil
  1352. guard !self.storeKitCoordinator.hasPremiumAccess else { return }
  1353. self.showPaywall()
  1354. }
  1355. launchPaywallWorkItem = workItem
  1356. DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
  1357. }
  1358. }
  1359. @objc private func paywallContinueClicked(_ sender: Any?) {
  1360. startSelectedPlanPurchase()
  1361. }
  1362. private func startSelectedPlanPurchase() {
  1363. guard paywallContinueEnabled else {
  1364. if storeKitCoordinator.hasPremiumAccess {
  1365. showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
  1366. } else {
  1367. showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
  1368. }
  1369. return
  1370. }
  1371. if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess && !confirmPremiumUpgrade(for: selectedPremiumPlan) {
  1372. return
  1373. }
  1374. paywallPurchaseTask?.cancel()
  1375. updatePaywallContinueState(isLoading: true)
  1376. let selectedPlan = selectedPremiumPlan
  1377. paywallPurchaseTask = Task { [weak self] in
  1378. guard let self else { return }
  1379. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  1380. self.updatePaywallContinueState(isLoading: false)
  1381. self.refreshPaywallStoreUI()
  1382. switch result {
  1383. case .success:
  1384. self.refreshPagesAfterPremiumStateUpdate()
  1385. Task { [weak self] in
  1386. await self?.loadSchedule()
  1387. }
  1388. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  1389. self.dismissPaywallIfPresented()
  1390. self.scheduleRatingPromptAfterPremiumUpgrade()
  1391. case .cancelled:
  1392. break
  1393. case .pending:
  1394. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  1395. case .unavailable:
  1396. self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
  1397. case .alreadyOwned:
  1398. self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
  1399. case .failed(let message):
  1400. self.showSimpleAlert(title: "Purchase Failed", message: message)
  1401. }
  1402. }
  1403. }
  1404. private func updatePaywallContinueState(isLoading: Bool) {
  1405. if isLoading {
  1406. paywallContinueEnabled = false
  1407. paywallContinueLabel?.stringValue = "Processing..."
  1408. paywallContinueButton?.alphaValue = 0.75
  1409. return
  1410. }
  1411. if storeKitCoordinator.hasLifetimeAccess {
  1412. paywallContinueEnabled = false
  1413. paywallContinueLabel?.stringValue = "Premium Active"
  1414. paywallContinueButton?.alphaValue = 0.75
  1415. } else if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess {
  1416. paywallContinueEnabled = true
  1417. paywallContinueLabel?.stringValue = "Continue"
  1418. paywallContinueButton?.alphaValue = 1.0
  1419. } else {
  1420. paywallContinueEnabled = true
  1421. paywallContinueLabel?.stringValue = "Continue"
  1422. paywallContinueButton?.alphaValue = 1.0
  1423. }
  1424. }
  1425. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  1426. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  1427. let idleBorder = palette.inputBorder
  1428. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1429. let hoverIdleBackground =
  1430. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  1431. let selectedBackground = darkModeEnabled
  1432. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  1433. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  1434. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  1435. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  1436. card.layer?.borderWidth = isSelected ? 2 : 1
  1437. card.layer?.shadowColor = NSColor.black.cgColor
  1438. card.layer?.shadowOpacity = isSelected ? (darkModeEnabled ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  1439. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1440. card.layer?.shadowRadius = isSelected ? (darkModeEnabled ? 10 : 6) : (hovering ? 7 : 5)
  1441. }
  1442. private func viewForPage(_ page: SidebarPage) -> NSView {
  1443. if let cached = pageCache[page] { return cached }
  1444. let built: NSView
  1445. switch page {
  1446. case .joinMeetings:
  1447. built = makeJoinMeetingsContent()
  1448. case .photo:
  1449. built = makeSchedulePageContent()
  1450. case .video:
  1451. built = makeCalendarPageContent()
  1452. case .settings:
  1453. built = makeSettingsPageContent()
  1454. }
  1455. pageCache[page] = built
  1456. return built
  1457. }
  1458. private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
  1459. let panel = NSView()
  1460. panel.translatesAutoresizingMaskIntoConstraints = false
  1461. let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary)
  1462. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1463. let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary)
  1464. sub.translatesAutoresizingMaskIntoConstraints = false
  1465. panel.addSubview(titleLabel)
  1466. panel.addSubview(sub)
  1467. NSLayoutConstraint.activate([
  1468. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1469. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor),
  1470. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1471. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  1472. ])
  1473. return panel
  1474. }
  1475. private func makeSettingsPageContent() -> NSView {
  1476. let panel = NSView()
  1477. panel.translatesAutoresizingMaskIntoConstraints = false
  1478. let scroll = NSScrollView()
  1479. scroll.translatesAutoresizingMaskIntoConstraints = false
  1480. scroll.drawsBackground = false
  1481. scroll.hasHorizontalScroller = false
  1482. scroll.hasVerticalScroller = true
  1483. scroll.autohidesScrollers = true
  1484. scroll.borderType = .noBorder
  1485. scroll.scrollerStyle = .overlay
  1486. scroll.automaticallyAdjustsContentInsets = false
  1487. let clip = TopAlignedClipView()
  1488. clip.drawsBackground = false
  1489. scroll.contentView = clip
  1490. panel.addSubview(scroll)
  1491. let content = NSView()
  1492. content.translatesAutoresizingMaskIntoConstraints = false
  1493. scroll.documentView = content
  1494. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  1495. card.translatesAutoresizingMaskIntoConstraints = false
  1496. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  1497. content.addSubview(card)
  1498. let stack = NSStackView()
  1499. stack.translatesAutoresizingMaskIntoConstraints = false
  1500. stack.orientation = .vertical
  1501. stack.spacing = 18
  1502. stack.alignment = .leading
  1503. card.addSubview(stack)
  1504. let pageTitle = textLabel("Settings", font: typography.pageTitle, color: palette.textPrimary)
  1505. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: typography.fieldLabel, color: palette.textSecondary)
  1506. stack.addArrangedSubview(pageTitle)
  1507. stack.addArrangedSubview(pageSubtitle)
  1508. stack.setCustomSpacing(24, after: pageSubtitle)
  1509. let appearanceTitle = textLabel("Appearance", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1510. stack.addArrangedSubview(appearanceTitle)
  1511. let darkModeRow = makeSettingsDarkModeRow()
  1512. stack.addArrangedSubview(darkModeRow)
  1513. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1514. stack.setCustomSpacing(24, after: darkModeRow)
  1515. let accountTitle = textLabel("Account", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1516. stack.addArrangedSubview(accountTitle)
  1517. let googleAccountRow = makeSettingsGoogleAccountRow()
  1518. stack.addArrangedSubview(googleAccountRow)
  1519. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1520. stack.setCustomSpacing(24, after: googleAccountRow)
  1521. let appTitle = textLabel("App", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1522. stack.addArrangedSubview(appTitle)
  1523. if shouldShowRateUsInSettings {
  1524. let rateButton = makeSettingsActionButton(icon: "★", title: "Rate Us", action: .rateUs)
  1525. stack.addArrangedSubview(rateButton)
  1526. rateButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1527. }
  1528. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  1529. stack.addArrangedSubview(shareButton)
  1530. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1531. if storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess {
  1532. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  1533. stack.addArrangedSubview(upgradeButton)
  1534. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1535. stack.setCustomSpacing(24, after: upgradeButton)
  1536. } else {
  1537. stack.setCustomSpacing(24, after: shareButton)
  1538. }
  1539. let legalTitle = textLabel("Help & Legal", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1540. stack.addArrangedSubview(legalTitle)
  1541. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  1542. stack.addArrangedSubview(privacyButton)
  1543. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1544. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  1545. stack.addArrangedSubview(supportButton)
  1546. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1547. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  1548. stack.addArrangedSubview(termsButton)
  1549. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1550. NSLayoutConstraint.activate([
  1551. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1552. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1553. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  1554. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  1555. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  1556. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  1557. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  1558. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  1559. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  1560. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1561. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 36),
  1562. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  1563. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  1564. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  1565. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  1566. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  1567. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  1568. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  1569. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  1570. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  1571. ])
  1572. return panel
  1573. }
  1574. private func makeSettingsDarkModeRow() -> NSView {
  1575. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1576. row.translatesAutoresizingMaskIntoConstraints = false
  1577. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  1578. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1579. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: palette.textPrimary)
  1580. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  1581. let toggle = NSSwitch()
  1582. toggle.translatesAutoresizingMaskIntoConstraints = false
  1583. toggle.state = darkModeEnabled ? .on : .off
  1584. toggle.target = self
  1585. toggle.action = #selector(settingsPageDarkModeToggled(_:))
  1586. row.addSubview(icon)
  1587. row.addSubview(title)
  1588. row.addSubview(toggle)
  1589. NSLayoutConstraint.activate([
  1590. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  1591. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  1592. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  1593. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  1594. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  1595. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  1596. ])
  1597. return row
  1598. }
  1599. private func makeSettingsGoogleAccountRow() -> NSView {
  1600. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1601. row.translatesAutoresizingMaskIntoConstraints = false
  1602. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1603. let signedIn = googleOAuth.loadTokens() != nil
  1604. let titleText = signedIn ? (scheduleCurrentProfile?.name ?? "Google account connected") : "Google account not connected"
  1605. let subtitleText = signedIn ? (scheduleCurrentProfile?.email ?? "Signed in") : "Sign in to sync your meetings and calendar."
  1606. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  1607. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: palette.textSecondary)
  1608. subtitle.maximumNumberOfLines = 2
  1609. subtitle.lineBreakMode = .byTruncatingTail
  1610. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  1611. actionButton.translatesAutoresizingMaskIntoConstraints = false
  1612. actionButton.bezelStyle = .rounded
  1613. actionButton.controlSize = .regular
  1614. row.addSubview(title)
  1615. row.addSubview(subtitle)
  1616. row.addSubview(actionButton)
  1617. NSLayoutConstraint.activate([
  1618. row.heightAnchor.constraint(equalToConstant: 78),
  1619. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  1620. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  1621. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1622. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  1623. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  1624. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  1625. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  1626. ])
  1627. return row
  1628. }
  1629. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  1630. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  1631. button.translatesAutoresizingMaskIntoConstraints = false
  1632. button.isBordered = false
  1633. button.wantsLayer = true
  1634. button.layer?.cornerRadius = 10
  1635. button.layer?.backgroundColor = palette.inputBackground.cgColor
  1636. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1637. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  1638. button.tag = action.rawValue
  1639. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: palette.textPrimary)
  1640. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  1641. button.addSubview(iconLabel)
  1642. button.addSubview(titleLabel)
  1643. NSLayoutConstraint.activate([
  1644. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  1645. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1646. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  1647. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  1648. ])
  1649. return button
  1650. }
  1651. @objc private func settingsPageDarkModeToggled(_ sender: NSSwitch) {
  1652. setDarkMode(sender.state == .on)
  1653. }
  1654. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  1655. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  1656. let clickPoint: NSPoint?
  1657. if let event = NSApp.currentEvent {
  1658. let pointInWindow = event.locationInWindow
  1659. clickPoint = sender.convert(pointInWindow, from: nil)
  1660. } else {
  1661. clickPoint = nil
  1662. }
  1663. handleSettingsAction(action, sourceView: sender, clickLocationInSourceView: clickPoint)
  1664. }
  1665. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  1666. if googleOAuth.loadTokens() == nil {
  1667. scheduleConnectClicked()
  1668. } else {
  1669. performGoogleSignOut()
  1670. }
  1671. }
  1672. func makeBrowseWebContent() -> NSView {
  1673. let panel = NSView()
  1674. panel.translatesAutoresizingMaskIntoConstraints = false
  1675. let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
  1676. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1677. let sub = textLabel(
  1678. "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
  1679. font: typography.fieldLabel,
  1680. color: palette.textSecondary
  1681. )
  1682. sub.translatesAutoresizingMaskIntoConstraints = false
  1683. sub.maximumNumberOfLines = 0
  1684. sub.lineBreakMode = .byWordWrapping
  1685. let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  1686. fieldShell.translatesAutoresizingMaskIntoConstraints = false
  1687. fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
  1688. styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1689. let field = NSTextField(string: "")
  1690. field.translatesAutoresizingMaskIntoConstraints = false
  1691. field.isEditable = true
  1692. field.isBordered = false
  1693. field.drawsBackground = false
  1694. field.focusRingType = .none
  1695. field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
  1696. field.textColor = palette.textPrimary
  1697. field.placeholderString = "https://example.com or example.com"
  1698. field.delegate = self
  1699. browseAddressField = field
  1700. fieldShell.addSubview(field)
  1701. let openBtn = meetActionButton(
  1702. title: "Open in app browser",
  1703. color: palette.primaryBlue,
  1704. textColor: .white,
  1705. width: 220,
  1706. action: #selector(browseOpenAddressClicked(_:))
  1707. )
  1708. let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1709. quickTitle.translatesAutoresizingMaskIntoConstraints = false
  1710. let quickRow = NSStackView()
  1711. quickRow.translatesAutoresizingMaskIntoConstraints = false
  1712. quickRow.orientation = .horizontal
  1713. quickRow.spacing = 10
  1714. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
  1715. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
  1716. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
  1717. panel.addSubview(titleLabel)
  1718. panel.addSubview(sub)
  1719. panel.addSubview(fieldShell)
  1720. panel.addSubview(openBtn)
  1721. panel.addSubview(quickTitle)
  1722. panel.addSubview(quickRow)
  1723. NSLayoutConstraint.activate([
  1724. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1725. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  1726. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  1727. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1728. sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  1729. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  1730. fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1731. fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1732. fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
  1733. field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
  1734. field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
  1735. field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
  1736. openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1737. openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
  1738. openBtn.heightAnchor.constraint(equalToConstant: 36),
  1739. quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1740. quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
  1741. quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1742. quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
  1743. ])
  1744. return panel
  1745. }
  1746. private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
  1747. let b = NSButton(title: title, target: self, action: action)
  1748. b.translatesAutoresizingMaskIntoConstraints = false
  1749. b.bezelStyle = .rounded
  1750. b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
  1751. return b
  1752. }
  1753. private func applyWindowTitle(for page: SidebarPage) {
  1754. let title: String
  1755. switch page {
  1756. case .joinMeetings:
  1757. title = "App for Google Meet"
  1758. case .photo:
  1759. title = "Schedule"
  1760. case .video:
  1761. title = "Calendar"
  1762. case .settings:
  1763. title = "Settings"
  1764. }
  1765. view.window?.title = title
  1766. }
  1767. private func configureMainWindowChrome(_ window: NSWindow) {
  1768. window.styleMask.insert(.fullSizeContentView)
  1769. window.titleVisibility = .hidden
  1770. window.titlebarAppearsTransparent = true
  1771. window.isMovableByWindowBackground = true
  1772. }
  1773. private func updateSidebarAppearance() {
  1774. for (page, row) in sidebarRowViews {
  1775. applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page))
  1776. }
  1777. }
  1778. private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
  1779. switch page {
  1780. case .photo: return false
  1781. case .joinMeetings, .video, .settings: return true
  1782. }
  1783. }
  1784. func makeSidebar() -> NSView {
  1785. let sidebar = NSView()
  1786. sidebar.translatesAutoresizingMaskIntoConstraints = false
  1787. sidebar.wantsLayer = true
  1788. sidebar.layer?.backgroundColor = palette.sidebarBackground.cgColor
  1789. sidebar.layer?.borderColor = palette.separator.cgColor
  1790. sidebar.layer?.borderWidth = 1
  1791. sidebar.layer?.shadowColor = NSColor.black.cgColor
  1792. sidebar.layer?.shadowOpacity = 0.18
  1793. sidebar.layer?.shadowOffset = CGSize(width: 2, height: 0)
  1794. sidebar.layer?.shadowRadius = 10
  1795. sidebar.widthAnchor.constraint(equalToConstant: 210).isActive = true
  1796. let sidebarTopInset: CGFloat = 44
  1797. let appIconView = NSImageView()
  1798. if let headerLogo = NSImage(named: "HeaderLogo") {
  1799. headerLogo.isTemplate = false
  1800. appIconView.image = headerLogo
  1801. } else if let appIconImage = NSApplication.shared.applicationIconImage {
  1802. appIconImage.isTemplate = false
  1803. appIconView.image = appIconImage
  1804. }
  1805. appIconView.translatesAutoresizingMaskIntoConstraints = false
  1806. appIconView.imageScaling = NSImageScaling.scaleProportionallyDown
  1807. appIconView.imageAlignment = NSImageAlignment.alignCenter
  1808. appIconView.contentTintColor = nil
  1809. appIconView.widthAnchor.constraint(equalToConstant: 44).isActive = true
  1810. appIconView.heightAnchor.constraint(equalToConstant: 44).isActive = true
  1811. let titleRow = NSStackView(views: [
  1812. appIconView,
  1813. textLabel("Meetings", font: typography.sidebarBrand, color: palette.textPrimary)
  1814. ])
  1815. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1816. titleRow.orientation = NSUserInterfaceLayoutOrientation.horizontal
  1817. titleRow.alignment = NSLayoutConstraint.Attribute.centerY
  1818. titleRow.spacing = 16
  1819. let menuStack = NSStackView()
  1820. menuStack.translatesAutoresizingMaskIntoConstraints = false
  1821. menuStack.orientation = .vertical
  1822. menuStack.alignment = .leading
  1823. menuStack.spacing = 10
  1824. menuStack.addArrangedSubview(sidebarSectionTitle("Meetings"))
  1825. let joinRow = sidebarItem("Join Meetings", icon: "􀉣", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0)
  1826. menuStack.addArrangedSubview(joinRow)
  1827. sidebarRowViews[.joinMeetings] = joinRow
  1828. menuStack.addArrangedSubview(sidebarSectionTitle("Planning"))
  1829. let photoRow = sidebarItem("Schedule", icon: "􀏂", page: .photo, systemSymbolName: "clock.badge.checkmark")
  1830. menuStack.addArrangedSubview(photoRow)
  1831. sidebarRowViews[.photo] = photoRow
  1832. let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
  1833. menuStack.addArrangedSubview(videoRow)
  1834. sidebarRowViews[.video] = videoRow
  1835. menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
  1836. let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
  1837. menuStack.addArrangedSubview(settingsRow)
  1838. sidebarRowViews[.settings] = settingsRow
  1839. let premiumButton = sidebarPremiumButton()
  1840. sidebar.addSubview(titleRow)
  1841. sidebar.addSubview(menuStack)
  1842. sidebar.addSubview(premiumButton)
  1843. NSLayoutConstraint.activate([
  1844. titleRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 16),
  1845. titleRow.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: sidebarTopInset),
  1846. titleRow.trailingAnchor.constraint(lessThanOrEqualTo: sidebar.trailingAnchor, constant: -16),
  1847. menuStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  1848. menuStack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  1849. menuStack.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 20),
  1850. menuStack.bottomAnchor.constraint(lessThanOrEqualTo: premiumButton.topAnchor, constant: -16),
  1851. premiumButton.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  1852. premiumButton.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  1853. premiumButton.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -14)
  1854. ])
  1855. for subview in menuStack.arrangedSubviews {
  1856. subview.widthAnchor.constraint(equalTo: menuStack.widthAnchor).isActive = true
  1857. }
  1858. return sidebar
  1859. }
  1860. func sidebarPremiumButton() -> NSView {
  1861. let button = HoverTrackingView()
  1862. button.translatesAutoresizingMaskIntoConstraints = false
  1863. button.wantsLayer = true
  1864. button.layer?.cornerRadius = 17
  1865. button.layer?.backgroundColor = palette.primaryBlue.cgColor
  1866. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  1867. styleSurface(button, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  1868. let icon = NSImageView()
  1869. icon.translatesAutoresizingMaskIntoConstraints = false
  1870. icon.imageScaling = .scaleProportionallyUpOrDown
  1871. icon.contentTintColor = .white
  1872. icon.image = premiumButtonSymbolImage(named: "star.fill")
  1873. let title = textLabel("Get Premium", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: .white)
  1874. button.addSubview(icon)
  1875. button.addSubview(title)
  1876. NSLayoutConstraint.activate([
  1877. icon.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
  1878. icon.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1879. icon.widthAnchor.constraint(equalToConstant: 14),
  1880. icon.heightAnchor.constraint(equalToConstant: 14),
  1881. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
  1882. title.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1883. title.trailingAnchor.constraint(lessThanOrEqualTo: button.trailingAnchor, constant: -12)
  1884. ])
  1885. button.onHoverChanged = { [weak self, weak button] hovering in
  1886. guard let self, let button else { return }
  1887. let isPremium = self.storeKitCoordinator.hasPremiumAccess
  1888. let baseColor = isPremium
  1889. ? NSColor(calibratedRed: 0.93, green: 0.73, blue: 0.16, alpha: 1.0)
  1890. : self.palette.primaryBlue
  1891. let borderColor = isPremium
  1892. ? NSColor(calibratedRed: 0.78, green: 0.56, blue: 0.10, alpha: 1.0)
  1893. : self.palette.primaryBlueBorder
  1894. let hoverColor: NSColor
  1895. let hoverBorderColor: NSColor
  1896. if isPremium {
  1897. // Darker rich-gold hover for stronger premium feedback.
  1898. hoverColor = NSColor(calibratedRed: 0.84, green: 0.62, blue: 0.11, alpha: 1.0)
  1899. hoverBorderColor = NSColor(calibratedRed: 0.67, green: 0.46, blue: 0.07, alpha: 1.0)
  1900. } else {
  1901. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  1902. hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  1903. hoverBorderColor = borderColor
  1904. }
  1905. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  1906. button.layer?.shadowColor = NSColor.black.cgColor
  1907. button.layer?.shadowOpacity = hovering ? (isPremium ? 0.30 : 0.20) : 0.14
  1908. button.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1909. button.layer?.shadowRadius = hovering ? (isPremium ? 8 : 6) : 4
  1910. self.styleSurface(button, borderColor: (hovering ? hoverBorderColor : borderColor), borderWidth: hovering ? 1.5 : 1, shadow: false)
  1911. }
  1912. button.onHoverChanged?(false)
  1913. sidebarPremiumTitleLabel = title
  1914. sidebarPremiumIconView = icon
  1915. sidebarPremiumButtonView = button
  1916. refreshSidebarPremiumButton()
  1917. let click = NSClickGestureRecognizer(target: self, action: #selector(premiumButtonClicked(_:)))
  1918. button.addGestureRecognizer(click)
  1919. return button
  1920. }
  1921. func makeMainPanel() -> NSView {
  1922. let panel = NSView()
  1923. panel.translatesAutoresizingMaskIntoConstraints = false
  1924. panel.wantsLayer = true
  1925. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  1926. let authBar = scheduleTopAuthRow()
  1927. authBar.translatesAutoresizingMaskIntoConstraints = false
  1928. panel.addSubview(authBar)
  1929. let host = NSView()
  1930. host.translatesAutoresizingMaskIntoConstraints = false
  1931. panel.addSubview(host)
  1932. NSLayoutConstraint.activate([
  1933. authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1934. authBar.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1935. authBar.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  1936. host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1937. host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1938. host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20),
  1939. host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
  1940. ])
  1941. mainContentHost = host
  1942. if googleOAuth.loadTokens() != nil, let profile = scheduleCurrentProfile {
  1943. applyGoogleProfile(profile)
  1944. }
  1945. // Preserve the currently selected page during rebuilds (e.g. dark mode toggle).
  1946. showSidebarPage(selectedSidebarPage)
  1947. return panel
  1948. }
  1949. func makeJoinMeetingsContent() -> NSView {
  1950. let panel = NSView()
  1951. panel.translatesAutoresizingMaskIntoConstraints = false
  1952. let contentStack = NSStackView()
  1953. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1954. contentStack.orientation = .vertical
  1955. contentStack.spacing = 14
  1956. contentStack.alignment = .leading
  1957. let joinActions = meetJoinActionsRow()
  1958. contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
  1959. contentStack.addArrangedSubview(meetJoinSectionRow())
  1960. contentStack.addArrangedSubview(joinActions)
  1961. contentStack.setCustomSpacing(26, after: joinActions)
  1962. let scheduleHeaderView = scheduleHeader()
  1963. contentStack.addArrangedSubview(scheduleHeaderView)
  1964. contentStack.setCustomSpacing(joinPageScheduleHeaderToDateSpacing, after: scheduleHeaderView)
  1965. let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
  1966. scheduleDateHeadingLabel = dateHeading
  1967. contentStack.addArrangedSubview(dateHeading)
  1968. contentStack.setCustomSpacing(joinPageDateToMeetingCardsSpacing, after: dateHeading)
  1969. let cardsRow = scheduleCardsRow(meetings: [])
  1970. contentStack.addArrangedSubview(cardsRow)
  1971. panel.addSubview(contentStack)
  1972. NSLayoutConstraint.activate([
  1973. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1974. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1975. contentStack.topAnchor.constraint(equalTo: panel.topAnchor)
  1976. ])
  1977. Task { [weak self] in
  1978. await self?.loadSchedule()
  1979. }
  1980. return panel
  1981. }
  1982. func makeSchedulePageContent() -> NSView {
  1983. let panel = NSView()
  1984. panel.translatesAutoresizingMaskIntoConstraints = false
  1985. panel.userInterfaceLayoutDirection = .leftToRight
  1986. let contentStack = NSStackView()
  1987. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1988. contentStack.userInterfaceLayoutDirection = .leftToRight
  1989. contentStack.orientation = .vertical
  1990. contentStack.spacing = schedulePageStackSpacing
  1991. contentStack.alignment = .width
  1992. contentStack.distribution = .fill
  1993. let header = schedulePageHeader()
  1994. header.setContentHuggingPriority(.required, for: .vertical)
  1995. header.setContentCompressionResistancePriority(.required, for: .vertical)
  1996. contentStack.addArrangedSubview(header)
  1997. contentStack.setCustomSpacing(schedulePageHeaderToDateSpacing, after: header)
  1998. let heading = textLabel(schedulePageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
  1999. heading.alignment = .left
  2000. heading.setContentHuggingPriority(.required, for: .vertical)
  2001. heading.setContentCompressionResistancePriority(.required, for: .vertical)
  2002. schedulePageDateHeadingLabel = heading
  2003. contentStack.addArrangedSubview(heading)
  2004. let rangeError = textLabel("", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .systemRed)
  2005. rangeError.alignment = .left
  2006. rangeError.isHidden = true
  2007. rangeError.setContentHuggingPriority(.required, for: .vertical)
  2008. rangeError.setContentCompressionResistancePriority(.required, for: .vertical)
  2009. schedulePageRangeErrorLabel = rangeError
  2010. contentStack.addArrangedSubview(rangeError)
  2011. let cardsContainer = makeSchedulePageCardsContainer()
  2012. cardsContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  2013. contentStack.addArrangedSubview(cardsContainer)
  2014. panel.addSubview(contentStack)
  2015. NSLayoutConstraint.activate([
  2016. contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: schedulePageLeadingInset),
  2017. contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -schedulePageTrailingInset),
  2018. contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
  2019. contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
  2020. header.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  2021. heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  2022. rangeError.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  2023. cardsContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
  2024. ])
  2025. Task { [weak self] in
  2026. await self?.loadSchedule()
  2027. }
  2028. return panel
  2029. }
  2030. func makeCalendarPageContent() -> NSView {
  2031. let panel = NSView()
  2032. panel.translatesAutoresizingMaskIntoConstraints = false
  2033. panel.userInterfaceLayoutDirection = .leftToRight
  2034. let contentStack = NSStackView()
  2035. contentStack.translatesAutoresizingMaskIntoConstraints = false
  2036. contentStack.userInterfaceLayoutDirection = .leftToRight
  2037. contentStack.orientation = .vertical
  2038. contentStack.spacing = 14
  2039. contentStack.alignment = .width
  2040. contentStack.distribution = .fill
  2041. let titleRow = NSStackView()
  2042. titleRow.translatesAutoresizingMaskIntoConstraints = false
  2043. titleRow.userInterfaceLayoutDirection = .leftToRight
  2044. titleRow.orientation = .horizontal
  2045. titleRow.alignment = .centerY
  2046. titleRow.distribution = .fill
  2047. titleRow.spacing = 10
  2048. let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary)
  2049. titleLabel.alignment = .left
  2050. titleLabel.maximumNumberOfLines = 1
  2051. titleLabel.lineBreakMode = .byTruncatingTail
  2052. titleLabel.setContentHuggingPriority(.required, for: .horizontal)
  2053. titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  2054. let spacer = NSView()
  2055. spacer.translatesAutoresizingMaskIntoConstraints = false
  2056. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2057. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2058. let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:)))
  2059. prevButton.widthAnchor.constraint(equalToConstant: 46).isActive = true
  2060. let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:)))
  2061. nextButton.widthAnchor.constraint(equalToConstant: 46).isActive = true
  2062. let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary)
  2063. monthLabel.alignment = .right
  2064. monthLabel.maximumNumberOfLines = 1
  2065. monthLabel.lineBreakMode = .byTruncatingTail
  2066. monthLabel.setContentHuggingPriority(.required, for: .horizontal)
  2067. monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  2068. calendarPageMonthLabel = monthLabel
  2069. let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:)))
  2070. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  2071. refreshButton.isBordered = false
  2072. refreshButton.bezelStyle = .regularSquare
  2073. refreshButton.wantsLayer = true
  2074. refreshButton.layer?.cornerRadius = 15
  2075. refreshButton.layer?.masksToBounds = true
  2076. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  2077. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  2078. refreshButton.layer?.borderWidth = 1
  2079. refreshButton.setButtonType(.momentaryChange)
  2080. refreshButton.contentTintColor = palette.textSecondary
  2081. refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar")
  2082. refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  2083. refreshButton.imagePosition = .imageOnly
  2084. refreshButton.imageScaling = .scaleProportionallyDown
  2085. refreshButton.focusRingType = .none
  2086. refreshButton.heightAnchor.constraint(equalToConstant: 32).isActive = true
  2087. refreshButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
  2088. titleRow.addArrangedSubview(titleLabel)
  2089. titleRow.addArrangedSubview(spacer)
  2090. titleRow.addArrangedSubview(prevButton)
  2091. titleRow.addArrangedSubview(nextButton)
  2092. titleRow.addArrangedSubview(monthLabel)
  2093. titleRow.addArrangedSubview(refreshButton)
  2094. let weekdayRow = NSStackView()
  2095. weekdayRow.translatesAutoresizingMaskIntoConstraints = false
  2096. weekdayRow.userInterfaceLayoutDirection = .leftToRight
  2097. weekdayRow.orientation = .horizontal
  2098. weekdayRow.alignment = .centerY
  2099. weekdayRow.distribution = .fillEqually
  2100. weekdayRow.spacing = 12
  2101. for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() {
  2102. let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted)
  2103. label.alignment = .center
  2104. label.maximumNumberOfLines = 1
  2105. label.lineBreakMode = .byClipping
  2106. weekdayRow.addArrangedSubview(label)
  2107. }
  2108. let gridStack = NSStackView()
  2109. gridStack.translatesAutoresizingMaskIntoConstraints = false
  2110. gridStack.userInterfaceLayoutDirection = .leftToRight
  2111. gridStack.orientation = .vertical
  2112. gridStack.alignment = .width
  2113. gridStack.distribution = .fillEqually
  2114. gridStack.spacing = 10
  2115. calendarPageGridStack = gridStack
  2116. let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
  2117. gridCard.translatesAutoresizingMaskIntoConstraints = false
  2118. styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2119. let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 360)
  2120. gridHeightConstraint.isActive = true
  2121. calendarPageGridHeightConstraint = gridHeightConstraint
  2122. gridCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 360).isActive = true
  2123. gridCard.addSubview(gridStack)
  2124. NSLayoutConstraint.activate([
  2125. gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 16),
  2126. gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -16),
  2127. gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 16),
  2128. gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -16)
  2129. ])
  2130. let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary)
  2131. daySummary.alignment = .left
  2132. daySummary.maximumNumberOfLines = 2
  2133. daySummary.lineBreakMode = .byWordWrapping
  2134. calendarPageDaySummaryLabel = daySummary
  2135. contentStack.addArrangedSubview(titleRow)
  2136. contentStack.setCustomSpacing(16, after: titleRow)
  2137. contentStack.addArrangedSubview(weekdayRow)
  2138. contentStack.setCustomSpacing(10, after: weekdayRow)
  2139. contentStack.addArrangedSubview(gridCard)
  2140. contentStack.setCustomSpacing(16, after: gridCard)
  2141. contentStack.addArrangedSubview(daySummary)
  2142. panel.addSubview(contentStack)
  2143. NSLayoutConstraint.activate([
  2144. contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
  2145. contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
  2146. contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
  2147. contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16),
  2148. titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  2149. weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  2150. gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  2151. daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
  2152. ])
  2153. if !storeKitCoordinator.hasPremiumAccess {
  2154. let lockOverlay = NSVisualEffectView()
  2155. lockOverlay.translatesAutoresizingMaskIntoConstraints = false
  2156. lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
  2157. lockOverlay.blendingMode = .withinWindow
  2158. lockOverlay.state = .active
  2159. lockOverlay.wantsLayer = true
  2160. lockOverlay.layer?.cornerRadius = 14
  2161. lockOverlay.layer?.masksToBounds = true
  2162. lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.30 : 0.12).cgColor
  2163. panel.addSubview(lockOverlay)
  2164. let message = textLabel("Premium required. Get Premium now to unlock Calendar.", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: darkModeEnabled ? .white : .black)
  2165. message.alignment = .center
  2166. message.maximumNumberOfLines = 2
  2167. message.lineBreakMode = .byWordWrapping
  2168. lockOverlay.addSubview(message)
  2169. let hit = HoverTrackingView()
  2170. hit.translatesAutoresizingMaskIntoConstraints = false
  2171. hit.wantsLayer = true
  2172. hit.layer?.backgroundColor = NSColor.clear.cgColor
  2173. hit.onClick = { [weak self] in
  2174. self?.showPaywall()
  2175. }
  2176. hit.toolTip = "Premium required. Click to open paywall."
  2177. lockOverlay.addSubview(hit)
  2178. NSLayoutConstraint.activate([
  2179. lockOverlay.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor),
  2180. lockOverlay.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor),
  2181. lockOverlay.topAnchor.constraint(equalTo: contentStack.topAnchor),
  2182. lockOverlay.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor),
  2183. message.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
  2184. message.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
  2185. message.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 22),
  2186. message.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -22),
  2187. hit.leadingAnchor.constraint(equalTo: lockOverlay.leadingAnchor),
  2188. hit.trailingAnchor.constraint(equalTo: lockOverlay.trailingAnchor),
  2189. hit.topAnchor.constraint(equalTo: lockOverlay.topAnchor),
  2190. hit.bottomAnchor.constraint(equalTo: lockOverlay.bottomAnchor)
  2191. ])
  2192. }
  2193. let calendar = Calendar.current
  2194. calendarPageMonthAnchor = calendarStartOfMonth(for: Date())
  2195. calendarPageSelectedDate = calendar.startOfDay(for: Date())
  2196. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  2197. renderCalendarMonthGrid()
  2198. renderCalendarSelectedDay()
  2199. Task { [weak self] in
  2200. await self?.loadSchedule()
  2201. }
  2202. return panel
  2203. }
  2204. func meetJoinSectionRow() -> NSView {
  2205. let row = NSStackView()
  2206. row.translatesAutoresizingMaskIntoConstraints = false
  2207. row.orientation = .horizontal
  2208. row.spacing = 12
  2209. row.alignment = .top
  2210. row.distribution = .fillEqually
  2211. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  2212. row.heightAnchor.constraint(equalToConstant: 140).isActive = true
  2213. let instant = HoverSurfaceView()
  2214. instant.translatesAutoresizingMaskIntoConstraints = false
  2215. instant.wantsLayer = true
  2216. instant.layer?.cornerRadius = 14
  2217. instant.layer?.backgroundColor = palette.sectionCard.cgColor
  2218. styleSurface(instant, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2219. let iconWrap = roundedContainer(cornerRadius: 12, color: NSColor.clear)
  2220. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  2221. iconWrap.widthAnchor.constraint(equalToConstant: 58).isActive = true
  2222. iconWrap.heightAnchor.constraint(equalToConstant: 58).isActive = true
  2223. iconWrap.layer?.borderWidth = 0
  2224. let meetLogoImage = NSImage(named: "MeetLogo") ?? NSImage()
  2225. meetLogoImage.isTemplate = false
  2226. let meetLogo = NSImageView(image: meetLogoImage)
  2227. meetLogo.translatesAutoresizingMaskIntoConstraints = false
  2228. meetLogo.imageScaling = .scaleProportionallyDown
  2229. meetLogo.contentTintColor = nil
  2230. iconWrap.addSubview(meetLogo)
  2231. let instantTitle = textLabel("New Instant Meet", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  2232. let instantSub = textLabel("Start instant Meet in more section with\nGoogle Meet meet.", font: NSFont.systemFont(ofSize: 16 / 2, weight: .medium), color: palette.textSecondary)
  2233. instantSub.maximumNumberOfLines = 2
  2234. instant.addSubview(iconWrap)
  2235. instant.addSubview(instantTitle)
  2236. instant.addSubview(instantSub)
  2237. let codeCard = HoverSurfaceView()
  2238. codeCard.translatesAutoresizingMaskIntoConstraints = false
  2239. codeCard.wantsLayer = true
  2240. codeCard.layer?.cornerRadius = 14
  2241. codeCard.layer?.backgroundColor = palette.sectionCard.cgColor
  2242. styleSurface(codeCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2243. let codeTitle = textLabel("Join with Link", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  2244. let codeInputShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  2245. codeInputShell.translatesAutoresizingMaskIntoConstraints = false
  2246. codeInputShell.heightAnchor.constraint(equalToConstant: 52).isActive = true
  2247. styleSurface(codeInputShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2248. let codeField = NSTextField(string: "")
  2249. codeField.translatesAutoresizingMaskIntoConstraints = false
  2250. codeField.isEditable = true
  2251. codeField.isBordered = false
  2252. codeField.drawsBackground = false
  2253. codeField.focusRingType = .none
  2254. codeField.font = NSFont.systemFont(ofSize: 36 / 2, weight: .regular)
  2255. codeField.textColor = palette.textPrimary
  2256. codeField.placeholderString = "Code or meet.google.com/…"
  2257. codeInputShell.addSubview(codeField)
  2258. meetLinkField = codeField
  2259. codeCard.addSubview(codeTitle)
  2260. codeCard.addSubview(codeInputShell)
  2261. NSLayoutConstraint.activate([
  2262. meetLogo.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  2263. meetLogo.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor),
  2264. meetLogo.widthAnchor.constraint(equalToConstant: 46),
  2265. meetLogo.heightAnchor.constraint(equalToConstant: 46),
  2266. iconWrap.leadingAnchor.constraint(equalTo: instant.leadingAnchor, constant: 18),
  2267. iconWrap.topAnchor.constraint(equalTo: instant.topAnchor, constant: 22),
  2268. instantTitle.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 14),
  2269. instantTitle.topAnchor.constraint(equalTo: instant.topAnchor, constant: 24),
  2270. instantSub.leadingAnchor.constraint(equalTo: instantTitle.leadingAnchor),
  2271. instantSub.topAnchor.constraint(equalTo: instantTitle.bottomAnchor, constant: 6),
  2272. instantSub.trailingAnchor.constraint(lessThanOrEqualTo: instant.trailingAnchor, constant: -16),
  2273. codeTitle.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  2274. codeTitle.topAnchor.constraint(equalTo: codeCard.topAnchor, constant: 22),
  2275. codeInputShell.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  2276. codeInputShell.trailingAnchor.constraint(equalTo: codeCard.trailingAnchor, constant: -18),
  2277. codeInputShell.topAnchor.constraint(equalTo: codeTitle.bottomAnchor, constant: 12),
  2278. codeField.leadingAnchor.constraint(equalTo: codeInputShell.leadingAnchor, constant: 14),
  2279. codeField.trailingAnchor.constraint(equalTo: codeInputShell.trailingAnchor, constant: -14),
  2280. codeField.centerYAnchor.constraint(equalTo: codeInputShell.centerYAnchor)
  2281. ])
  2282. let baseColor = palette.sectionCard
  2283. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2284. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  2285. instant.onHoverChanged = { [weak self] hovering in
  2286. guard let self else { return }
  2287. instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  2288. }
  2289. codeCard.onHoverChanged = { [weak self] hovering in
  2290. guard let self else { return }
  2291. codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  2292. }
  2293. instant.onHoverChanged?(false)
  2294. codeCard.onHoverChanged?(false)
  2295. let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
  2296. instant.addGestureRecognizer(instantClick)
  2297. let joinWithLinkClick = NSClickGestureRecognizer(target: self, action: #selector(joinWithLinkCardClicked(_:)))
  2298. codeCard.addGestureRecognizer(joinWithLinkClick)
  2299. instantMeetCardView = instant
  2300. instantMeetTitleLabel = instantTitle
  2301. instantMeetSubtitleLabel = instantSub
  2302. joinWithLinkCardView = codeCard
  2303. joinWithLinkTitleLabel = codeTitle
  2304. refreshInstantMeetPremiumState()
  2305. row.addArrangedSubview(instant)
  2306. row.addArrangedSubview(codeCard)
  2307. return row
  2308. }
  2309. func meetJoinActionsRow() -> NSView {
  2310. let row = NSStackView()
  2311. row.translatesAutoresizingMaskIntoConstraints = false
  2312. row.orientation = .horizontal
  2313. row.spacing = 12
  2314. row.alignment = .centerY
  2315. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  2316. let spacer = NSView()
  2317. spacer.translatesAutoresizingMaskIntoConstraints = false
  2318. row.addArrangedSubview(spacer)
  2319. row.addArrangedSubview(meetActionButton(
  2320. title: "Cancel",
  2321. color: palette.cancelButton,
  2322. textColor: palette.textSecondary,
  2323. width: 110,
  2324. action: #selector(cancelMeetJoinClicked(_:))
  2325. ))
  2326. let joinButton = meetActionButton(
  2327. title: "Join",
  2328. color: palette.primaryBlue,
  2329. textColor: .white,
  2330. width: 116,
  2331. action: #selector(joinMeetClicked(_:))
  2332. )
  2333. joinMeetPrimaryButton = joinButton
  2334. row.addArrangedSubview(joinButton)
  2335. refreshInstantMeetPremiumState()
  2336. return row
  2337. }
  2338. func meetActionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat, action: Selector) -> NSButton {
  2339. let button = HoverButton(title: title, target: self, action: action)
  2340. button.translatesAutoresizingMaskIntoConstraints = false
  2341. button.isBordered = false
  2342. button.bezelStyle = .regularSquare
  2343. button.wantsLayer = true
  2344. button.layer?.cornerRadius = 9
  2345. let baseBackground = color
  2346. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2347. let hoverBackground = baseBackground.blended(withFraction: 0.10, of: hoverBlend) ?? baseBackground
  2348. let baseBorder = (title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder)
  2349. let hoverBorder = baseBorder.blended(withFraction: 0.18, of: hoverBlend) ?? baseBorder
  2350. button.layer?.backgroundColor = baseBackground.cgColor
  2351. button.layer?.borderColor = baseBorder.cgColor
  2352. button.layer?.borderWidth = 1
  2353. button.font = typography.buttonText
  2354. button.contentTintColor = textColor
  2355. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  2356. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  2357. button.onHoverChanged = { [weak self, weak button] hovering in
  2358. guard let self, let button else { return }
  2359. button.layer?.backgroundColor = (hovering ? hoverBackground : baseBackground).cgColor
  2360. button.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  2361. if title == "Cancel" {
  2362. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : textColor
  2363. }
  2364. }
  2365. button.onHoverChanged?(false)
  2366. return button
  2367. }
  2368. func makePaywallContent() -> NSView {
  2369. paywallPlanViews.removeAll()
  2370. premiumPlanByView.removeAll()
  2371. let panel = NSView()
  2372. panel.translatesAutoresizingMaskIntoConstraints = false
  2373. panel.wantsLayer = true
  2374. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  2375. let contentStack = NSStackView()
  2376. contentStack.translatesAutoresizingMaskIntoConstraints = false
  2377. contentStack.orientation = .vertical
  2378. contentStack.spacing = 12
  2379. contentStack.distribution = .fill
  2380. contentStack.alignment = .centerX
  2381. panel.addSubview(contentStack)
  2382. let paywallLayoutWidth: CGFloat = 980
  2383. let topRow = NSStackView()
  2384. topRow.translatesAutoresizingMaskIntoConstraints = false
  2385. topRow.orientation = .horizontal
  2386. topRow.alignment = .centerY
  2387. topRow.distribution = .fill
  2388. topRow.spacing = 10
  2389. topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 27, weight: .bold), color: palette.textPrimary))
  2390. var closeButton: HoverButton?
  2391. if storeKitCoordinator.hasPremiumAccess {
  2392. let button = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  2393. button.translatesAutoresizingMaskIntoConstraints = false
  2394. button.isBordered = false
  2395. button.bezelStyle = .regularSquare
  2396. button.wantsLayer = true
  2397. button.layer?.cornerRadius = 14
  2398. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2399. button.layer?.borderColor = palette.inputBorder.cgColor
  2400. button.layer?.borderWidth = 1
  2401. button.font = typography.iconButton
  2402. button.contentTintColor = palette.textSecondary
  2403. button.widthAnchor.constraint(equalToConstant: 28).isActive = true
  2404. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  2405. button.onHoverChanged = { [weak button, weak self] hovering in
  2406. guard let button, let self else { return }
  2407. let base = self.palette.inputBackground
  2408. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  2409. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  2410. button.layer?.backgroundColor = (hovering ? hover : base).cgColor
  2411. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  2412. }
  2413. panel.addSubview(button)
  2414. closeButton = button
  2415. }
  2416. contentStack.addArrangedSubview(topRow)
  2417. topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2418. let hero = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  2419. hero.translatesAutoresizingMaskIntoConstraints = false
  2420. styleSurface(hero, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2421. contentStack.addArrangedSubview(hero)
  2422. hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2423. let heroBadge = textLabel("PREMIUM EXPERIENCE", font: NSFont.systemFont(ofSize: 10, weight: .bold), color: NSColor(calibratedRed: 0.42, green: 0.93, blue: 0.70, alpha: 1))
  2424. hero.addSubview(heroBadge)
  2425. let heroTitle = textLabel("Elevate your meetings workflow", font: NSFont.systemFont(ofSize: 22, weight: .bold), color: palette.textPrimary)
  2426. let heroSubtitle = textLabel("Unlock automation, premium meeting tools, and priority support for every session.", font: NSFont.systemFont(ofSize: 13, weight: .medium), color: palette.textSecondary)
  2427. heroTitle.maximumNumberOfLines = 2
  2428. heroSubtitle.maximumNumberOfLines = 3
  2429. hero.addSubview(heroTitle)
  2430. hero.addSubview(heroSubtitle)
  2431. NSLayoutConstraint.activate([
  2432. heroBadge.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
  2433. heroBadge.topAnchor.constraint(equalTo: hero.topAnchor, constant: 14),
  2434. heroTitle.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
  2435. heroTitle.trailingAnchor.constraint(lessThanOrEqualTo: hero.trailingAnchor, constant: -16),
  2436. heroTitle.topAnchor.constraint(equalTo: heroBadge.bottomAnchor, constant: 10),
  2437. heroSubtitle.leadingAnchor.constraint(equalTo: heroTitle.leadingAnchor),
  2438. heroSubtitle.trailingAnchor.constraint(equalTo: hero.trailingAnchor, constant: -16),
  2439. heroSubtitle.topAnchor.constraint(equalTo: heroTitle.bottomAnchor, constant: 8),
  2440. heroSubtitle.bottomAnchor.constraint(equalTo: hero.bottomAnchor, constant: -16)
  2441. ])
  2442. let benefitsRow = NSStackView()
  2443. benefitsRow.translatesAutoresizingMaskIntoConstraints = false
  2444. benefitsRow.orientation = .horizontal
  2445. benefitsRow.spacing = 8
  2446. benefitsRow.distribution = .fillEqually
  2447. benefitsRow.alignment = .centerY
  2448. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  2449. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🧠", text: "Smart scheduling"))
  2450. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
  2451. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
  2452. contentStack.addArrangedSubview(benefitsRow)
  2453. benefitsRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2454. let plansRow = NSStackView()
  2455. plansRow.translatesAutoresizingMaskIntoConstraints = false
  2456. plansRow.orientation = .horizontal
  2457. plansRow.spacing = 10
  2458. plansRow.distribution = .fillEqually
  2459. plansRow.alignment = .centerY
  2460. let weeklyCard = paywallPlanCard(
  2461. title: "Weekly",
  2462. price: "PKR 1,100.00",
  2463. badge: "Basic",
  2464. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  2465. subtitle: nil,
  2466. plan: .weekly,
  2467. strikePrice: nil
  2468. )
  2469. let monthlyCard = paywallPlanCard(
  2470. title: "Monthly",
  2471. price: "PKR 2,500.00",
  2472. badge: "Popular",
  2473. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  2474. subtitle: "3 days free trial",
  2475. plan: .monthly,
  2476. strikePrice: nil
  2477. )
  2478. let yearlyCard = paywallPlanCard(
  2479. title: "Yearly",
  2480. price: "PKR 9,900.00",
  2481. badge: "Best Value",
  2482. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  2483. subtitle: "190.38/week",
  2484. plan: .yearly,
  2485. strikePrice: nil
  2486. )
  2487. let lifetimeCard = paywallPlanCard(
  2488. title: "Lifetime",
  2489. price: "PKR 14,900.00",
  2490. badge: "Save 50%",
  2491. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  2492. subtitle: nil,
  2493. plan: .lifetime,
  2494. strikePrice: "PKR 29,800.00"
  2495. )
  2496. plansRow.addArrangedSubview(weeklyCard)
  2497. plansRow.addArrangedSubview(monthlyCard)
  2498. plansRow.addArrangedSubview(yearlyCard)
  2499. plansRow.addArrangedSubview(lifetimeCard)
  2500. contentStack.addArrangedSubview(plansRow)
  2501. plansRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2502. updatePaywallPlanSelection()
  2503. let trustRow = NSStackView()
  2504. trustRow.translatesAutoresizingMaskIntoConstraints = false
  2505. trustRow.orientation = .horizontal
  2506. trustRow.spacing = 8
  2507. trustRow.distribution = .fillEqually
  2508. trustRow.alignment = .centerY
  2509. trustRow.addArrangedSubview(paywallMetaItem(title: "Cancel anytime", subtitle: "No lock-in"))
  2510. trustRow.addArrangedSubview(paywallMetaItem(title: "Instant access", subtitle: "Unlock all tools"))
  2511. trustRow.addArrangedSubview(paywallMetaItem(title: "Secure billing", subtitle: "Handled by Apple"))
  2512. contentStack.addArrangedSubview(trustRow)
  2513. trustRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2514. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
  2515. offer.alignment = .center
  2516. paywallOfferLabel = offer
  2517. let offerWrap = NSView()
  2518. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  2519. offerWrap.addSubview(offer)
  2520. contentStack.addArrangedSubview(offerWrap)
  2521. offerWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2522. NSLayoutConstraint.activate([
  2523. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  2524. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 4),
  2525. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  2526. ])
  2527. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  2528. continueButton.translatesAutoresizingMaskIntoConstraints = false
  2529. continueButton.isBordered = false
  2530. continueButton.bezelStyle = .regularSquare
  2531. continueButton.wantsLayer = true
  2532. continueButton.layer?.cornerRadius = 12
  2533. continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
  2534. continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
  2535. styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
  2536. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 14, weight: .bold), color: .white)
  2537. continueButton.addSubview(continueLabel)
  2538. NSLayoutConstraint.activate([
  2539. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  2540. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  2541. ])
  2542. let baseBlue = palette.primaryBlue
  2543. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2544. let hoverBlue = baseBlue.blended(withFraction: 0.10, of: hoverBlend) ?? baseBlue
  2545. continueButton.onHoverChanged = { hovering in
  2546. continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
  2547. }
  2548. continueButton.onHoverChanged?(false)
  2549. paywallContinueButton = continueButton
  2550. paywallContinueLabel = continueLabel
  2551. contentStack.addArrangedSubview(continueButton)
  2552. continueButton.widthAnchor.constraint(equalToConstant: 360).isActive = true
  2553. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
  2554. secure.alignment = .center
  2555. let secureWrap = NSView()
  2556. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  2557. secureWrap.addSubview(secure)
  2558. contentStack.addArrangedSubview(secureWrap)
  2559. secureWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2560. NSLayoutConstraint.activate([
  2561. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  2562. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 2),
  2563. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -4)
  2564. ])
  2565. let footer = paywallFooterLinks()
  2566. contentStack.addArrangedSubview(footer)
  2567. footer.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2568. var panelConstraints: [NSLayoutConstraint] = [
  2569. contentStack.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
  2570. contentStack.widthAnchor.constraint(equalToConstant: paywallLayoutWidth),
  2571. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 80),
  2572. contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12)
  2573. ]
  2574. if let closeButton {
  2575. panelConstraints.append(closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 30))
  2576. panelConstraints.append(closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -30))
  2577. }
  2578. NSLayoutConstraint.activate(panelConstraints)
  2579. refreshPaywallStoreUI()
  2580. return panel
  2581. }
  2582. func paywallPlanCard(
  2583. title: String,
  2584. price: String,
  2585. badge: String,
  2586. badgeColor: NSColor,
  2587. subtitle: String?,
  2588. plan: PremiumPlan,
  2589. strikePrice: String?
  2590. ) -> NSView {
  2591. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  2592. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2593. wrapper.isBordered = false
  2594. wrapper.bezelStyle = .regularSquare
  2595. wrapper.wantsLayer = true
  2596. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  2597. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
  2598. wrapper.heightAnchor.constraint(equalToConstant: 162).isActive = true
  2599. wrapper.tag = plan.rawValue
  2600. let card = HoverTrackingView()
  2601. card.translatesAutoresizingMaskIntoConstraints = false
  2602. card.wantsLayer = true
  2603. card.layer?.cornerRadius = 16
  2604. card.layer?.backgroundColor = palette.sectionCard.cgColor
  2605. card.heightAnchor.constraint(equalToConstant: 150).isActive = true
  2606. wrapper.addSubview(card)
  2607. NSLayoutConstraint.activate([
  2608. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2609. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2610. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
  2611. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  2612. ])
  2613. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2614. let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
  2615. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  2616. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  2617. badgeWrap.wantsLayer = true
  2618. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  2619. badgeWrap.layer?.borderWidth = 1
  2620. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  2621. badgeWrap.layer?.shadowOpacity = 0.20
  2622. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2623. badgeWrap.layer?.shadowRadius = 3
  2624. badgeWrap.addSubview(badgeLabel)
  2625. NSLayoutConstraint.activate([
  2626. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  2627. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  2628. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  2629. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  2630. ])
  2631. wrapper.addSubview(badgeWrap)
  2632. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue)
  2633. card.addSubview(titleLabel)
  2634. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
  2635. card.addSubview(priceLabel)
  2636. paywallPriceLabels[plan] = priceLabel
  2637. NSLayoutConstraint.activate([
  2638. badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
  2639. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  2640. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
  2641. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
  2642. priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  2643. priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  2644. priceLabel.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  2645. ])
  2646. if let subtitle {
  2647. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
  2648. card.addSubview(sub)
  2649. paywallSubtitleLabels[plan] = sub
  2650. NSLayoutConstraint.activate([
  2651. sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  2652. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
  2653. sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  2654. ])
  2655. }
  2656. if let strikePrice {
  2657. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  2658. card.addSubview(strike)
  2659. NSLayoutConstraint.activate([
  2660. strike.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  2661. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4),
  2662. strike.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  2663. ])
  2664. }
  2665. paywallPlanViews[plan] = card
  2666. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  2667. guard let self, let card else { return }
  2668. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  2669. }
  2670. wrapper.onHoverChanged?(false)
  2671. return wrapper
  2672. }
  2673. func paywallFooterLinks() -> NSView {
  2674. let wrap = NSView()
  2675. wrap.translatesAutoresizingMaskIntoConstraints = false
  2676. wrap.heightAnchor.constraint(equalToConstant: 40).isActive = true
  2677. paywallFooterActionByView.removeAll()
  2678. let row = NSStackView()
  2679. row.translatesAutoresizingMaskIntoConstraints = false
  2680. row.orientation = .horizontal
  2681. row.distribution = .fillEqually
  2682. row.alignment = .centerY
  2683. row.spacing = 0
  2684. wrap.addSubview(row)
  2685. if storeKitCoordinator.hasPremiumAccess {
  2686. row.addArrangedSubview(footerLink("Manage Subscription", action: .manageSubscription))
  2687. row.addArrangedSubview(footerLink("Restore Purchase", action: .restorePurchase))
  2688. } else {
  2689. row.addArrangedSubview(footerLink("Continue with free plan", action: .continueWithFreePlan))
  2690. }
  2691. row.addArrangedSubview(footerLink("Privacy Policy", action: .privacyPolicy))
  2692. row.addArrangedSubview(footerLink("Support", action: .support))
  2693. row.addArrangedSubview(footerLink("Terms of Services", action: .termsOfServices))
  2694. NSLayoutConstraint.activate([
  2695. row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
  2696. row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
  2697. row.topAnchor.constraint(equalTo: wrap.topAnchor),
  2698. row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
  2699. ])
  2700. return wrap
  2701. }
  2702. private func footerLink(_ title: String, action: PaywallFooterAction) -> NSView {
  2703. let button = HoverButton(title: title, target: self, action: #selector(paywallFooterButtonPressed(_:)))
  2704. button.translatesAutoresizingMaskIntoConstraints = false
  2705. button.isBordered = false
  2706. button.bezelStyle = .regularSquare
  2707. button.focusRingType = .none
  2708. button.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  2709. button.alignment = .center
  2710. button.setButtonType(.momentaryChange)
  2711. button.contentTintColor = palette.textSecondary
  2712. paywallFooterActionByView[ObjectIdentifier(button)] = action
  2713. button.onHoverChanged = { [weak self, weak button] hovering in
  2714. guard let self, let button else { return }
  2715. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  2716. }
  2717. button.onHoverChanged?(false)
  2718. return button
  2719. }
  2720. func paywallMetaItem(title: String, subtitle: String) -> NSView {
  2721. let card = HoverTrackingView()
  2722. card.translatesAutoresizingMaskIntoConstraints = false
  2723. card.wantsLayer = true
  2724. card.layer?.cornerRadius = 10
  2725. card.layer?.backgroundColor = palette.inputBackground.cgColor
  2726. card.heightAnchor.constraint(equalToConstant: 44).isActive = true
  2727. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2728. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
  2729. let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .medium), color: palette.textSecondary)
  2730. card.addSubview(titleLabel)
  2731. card.addSubview(subtitleLabel)
  2732. NSLayoutConstraint.activate([
  2733. titleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  2734. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 7),
  2735. subtitleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  2736. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 1),
  2737. subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -6)
  2738. ])
  2739. let base = palette.inputBackground
  2740. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2741. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  2742. card.onHoverChanged = { [weak card] hovering in
  2743. card?.layer?.backgroundColor = (hovering ? hover : base).cgColor
  2744. }
  2745. card.onHoverChanged?(false)
  2746. return card
  2747. }
  2748. func paywallBenefitsSection() -> NSView {
  2749. let stack = NSStackView()
  2750. stack.translatesAutoresizingMaskIntoConstraints = false
  2751. stack.orientation = .vertical
  2752. stack.spacing = 8
  2753. stack.alignment = .leading
  2754. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  2755. let rowOne = NSStackView()
  2756. rowOne.translatesAutoresizingMaskIntoConstraints = false
  2757. rowOne.orientation = .horizontal
  2758. rowOne.spacing = 10
  2759. rowOne.distribution = .fillEqually
  2760. rowOne.alignment = .centerY
  2761. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  2762. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  2763. let rowTwo = NSStackView()
  2764. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  2765. rowTwo.orientation = .horizontal
  2766. rowTwo.spacing = 10
  2767. rowTwo.distribution = .fillEqually
  2768. rowTwo.alignment = .centerY
  2769. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  2770. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  2771. stack.addArrangedSubview(rowOne)
  2772. stack.addArrangedSubview(rowTwo)
  2773. return stack
  2774. }
  2775. func paywallBenefitItem(icon: String, text: String) -> NSView {
  2776. let card = HoverTrackingView()
  2777. card.translatesAutoresizingMaskIntoConstraints = false
  2778. card.wantsLayer = true
  2779. card.layer?.cornerRadius = 10
  2780. card.layer?.backgroundColor = palette.inputBackground.cgColor
  2781. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  2782. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2783. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  2784. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  2785. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  2786. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  2787. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2788. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue)
  2789. iconWrap.addSubview(iconLabel)
  2790. NSLayoutConstraint.activate([
  2791. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  2792. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  2793. ])
  2794. let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary)
  2795. card.addSubview(iconWrap)
  2796. card.addSubview(title)
  2797. NSLayoutConstraint.activate([
  2798. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  2799. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  2800. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  2801. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  2802. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  2803. ])
  2804. let base = palette.inputBackground
  2805. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2806. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  2807. let hoverBorder = palette.primaryBlueBorder.withAlphaComponent(0.55)
  2808. card.onHoverChanged = { [weak card, weak iconWrap] hovering in
  2809. guard let card else { return }
  2810. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  2811. card.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  2812. iconWrap?.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  2813. }
  2814. card.onHoverChanged?(false)
  2815. return card
  2816. }
  2817. func zoomJoinModeTabs() -> NSView {
  2818. let row = NSStackView()
  2819. row.translatesAutoresizingMaskIntoConstraints = false
  2820. row.orientation = .horizontal
  2821. row.alignment = .centerY
  2822. row.spacing = 28
  2823. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  2824. let idTab = joinModeTab("Join with ID", mode: .id)
  2825. let urlTab = joinModeTab("Join with URL", mode: .url)
  2826. row.addArrangedSubview(idTab)
  2827. row.addArrangedSubview(urlTab)
  2828. let spacer = NSView()
  2829. spacer.translatesAutoresizingMaskIntoConstraints = false
  2830. row.addArrangedSubview(spacer)
  2831. zoomJoinModeViews[.id] = idTab
  2832. zoomJoinModeViews[.url] = urlTab
  2833. updateZoomJoinModeAppearance()
  2834. return row
  2835. }
  2836. func joinModeTab(_ title: String, mode: ZoomJoinMode) -> NSView {
  2837. let tab = HoverTrackingView()
  2838. tab.translatesAutoresizingMaskIntoConstraints = false
  2839. tab.wantsLayer = true
  2840. tab.layer?.cornerRadius = 6
  2841. tab.layer?.backgroundColor = NSColor.clear.cgColor
  2842. tab.heightAnchor.constraint(equalToConstant: 30).isActive = true
  2843. zoomJoinModeByView[ObjectIdentifier(tab)] = mode
  2844. let label = textLabel(title, font: NSFont.systemFont(ofSize: 33 / 2, weight: .medium), color: palette.textPrimary)
  2845. tab.addSubview(label)
  2846. NSLayoutConstraint.activate([
  2847. label.leadingAnchor.constraint(equalTo: tab.leadingAnchor, constant: 4),
  2848. label.trailingAnchor.constraint(equalTo: tab.trailingAnchor, constant: -4),
  2849. label.topAnchor.constraint(equalTo: tab.topAnchor, constant: 4),
  2850. label.bottomAnchor.constraint(equalTo: tab.bottomAnchor, constant: -6)
  2851. ])
  2852. let click = NSClickGestureRecognizer(target: self, action: #selector(zoomJoinModeClicked(_:)))
  2853. tab.addGestureRecognizer(click)
  2854. return tab
  2855. }
  2856. func updateZoomJoinModeAppearance() {
  2857. for (mode, tab) in zoomJoinModeViews {
  2858. let selected = (mode == selectedZoomJoinMode)
  2859. let textColor = selected ? palette.textPrimary : palette.textSecondary
  2860. let label = tab.subviews.first { $0 is NSTextField } as? NSTextField
  2861. label?.textColor = textColor
  2862. // Keep the active tab visually underlined like the reference.
  2863. if selected {
  2864. if tab.subviews.contains(where: { $0.identifier?.rawValue == "modeUnderline" }) == false {
  2865. let underline = NSView()
  2866. underline.identifier = NSUserInterfaceItemIdentifier("modeUnderline")
  2867. underline.translatesAutoresizingMaskIntoConstraints = false
  2868. underline.wantsLayer = true
  2869. underline.layer?.backgroundColor = palette.primaryBlue.cgColor
  2870. tab.addSubview(underline)
  2871. NSLayoutConstraint.activate([
  2872. underline.leadingAnchor.constraint(equalTo: tab.leadingAnchor),
  2873. underline.trailingAnchor.constraint(equalTo: tab.trailingAnchor),
  2874. underline.bottomAnchor.constraint(equalTo: tab.bottomAnchor),
  2875. underline.heightAnchor.constraint(equalToConstant: 2)
  2876. ])
  2877. }
  2878. } else {
  2879. tab.subviews
  2880. .filter { $0.identifier?.rawValue == "modeUnderline" }
  2881. .forEach { $0.removeFromSuperview() }
  2882. }
  2883. }
  2884. }
  2885. func joinWithIDHeading() -> NSView {
  2886. let container = NSView()
  2887. container.translatesAutoresizingMaskIntoConstraints = false
  2888. let title = textLabel("Join with ID", font: typography.joinWithURLTitle, color: palette.textPrimary)
  2889. title.alignment = .left
  2890. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  2891. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  2892. let bar = NSView()
  2893. bar.translatesAutoresizingMaskIntoConstraints = false
  2894. bar.wantsLayer = true
  2895. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  2896. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  2897. container.addSubview(title)
  2898. container.addSubview(bar)
  2899. NSLayoutConstraint.activate([
  2900. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  2901. title.topAnchor.constraint(equalTo: container.topAnchor),
  2902. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2903. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  2904. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  2905. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  2906. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  2907. ])
  2908. return container
  2909. }
  2910. func zoomMeetingIDSection() -> NSView {
  2911. let wrapper = NSView()
  2912. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2913. let fieldsRow = NSStackView()
  2914. fieldsRow.translatesAutoresizingMaskIntoConstraints = false
  2915. fieldsRow.orientation = .horizontal
  2916. fieldsRow.alignment = .top
  2917. fieldsRow.distribution = .fillEqually
  2918. fieldsRow.spacing = 12
  2919. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting ID", placeholder: "Enter meeting ID..."))
  2920. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting Passcode", placeholder: "Enter meeting passcode..."))
  2921. let actions = NSStackView()
  2922. actions.orientation = .horizontal
  2923. actions.spacing = 10
  2924. actions.translatesAutoresizingMaskIntoConstraints = false
  2925. actions.alignment = .centerY
  2926. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  2927. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  2928. wrapper.addSubview(fieldsRow)
  2929. wrapper.addSubview(actions)
  2930. NSLayoutConstraint.activate([
  2931. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  2932. fieldsRow.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2933. fieldsRow.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2934. fieldsRow.topAnchor.constraint(equalTo: wrapper.topAnchor),
  2935. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2936. actions.topAnchor.constraint(equalTo: fieldsRow.bottomAnchor, constant: 14),
  2937. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  2938. ])
  2939. return wrapper
  2940. }
  2941. func zoomInputField(title: String, placeholder: String) -> NSView {
  2942. let wrapper = NSView()
  2943. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2944. let heading = textLabel(title, font: typography.fieldLabel, color: palette.textPrimary)
  2945. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  2946. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  2947. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  2948. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2949. let field = NSTextField(string: "")
  2950. field.translatesAutoresizingMaskIntoConstraints = false
  2951. field.isEditable = true
  2952. field.isSelectable = true
  2953. field.isBordered = false
  2954. field.drawsBackground = false
  2955. field.placeholderString = placeholder
  2956. field.font = typography.inputPlaceholder
  2957. field.textColor = palette.textPrimary
  2958. field.focusRingType = .none
  2959. textFieldContainer.addSubview(field)
  2960. wrapper.addSubview(heading)
  2961. wrapper.addSubview(textFieldContainer)
  2962. NSLayoutConstraint.activate([
  2963. heading.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2964. heading.topAnchor.constraint(equalTo: wrapper.topAnchor),
  2965. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2966. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2967. textFieldContainer.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 10),
  2968. textFieldContainer.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
  2969. field.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  2970. field.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  2971. field.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor)
  2972. ])
  2973. return wrapper
  2974. }
  2975. func joinWithURLHeading() -> NSView {
  2976. let container = NSView()
  2977. container.translatesAutoresizingMaskIntoConstraints = false
  2978. let title = textLabel("Join with URL", font: typography.joinWithURLTitle, color: palette.textPrimary)
  2979. title.alignment = .left
  2980. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  2981. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  2982. let bar = NSView()
  2983. bar.translatesAutoresizingMaskIntoConstraints = false
  2984. bar.wantsLayer = true
  2985. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  2986. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  2987. container.addSubview(title)
  2988. container.addSubview(bar)
  2989. NSLayoutConstraint.activate([
  2990. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  2991. title.topAnchor.constraint(equalTo: container.topAnchor),
  2992. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2993. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  2994. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  2995. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  2996. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  2997. ])
  2998. return container
  2999. }
  3000. func meetingUrlSection() -> NSView {
  3001. let wrapper = NSView()
  3002. wrapper.translatesAutoresizingMaskIntoConstraints = false
  3003. let title = textLabel("Meeting URL", font: typography.fieldLabel, color: palette.textSecondary)
  3004. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  3005. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  3006. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  3007. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3008. let urlField = NSTextField(string: "")
  3009. urlField.translatesAutoresizingMaskIntoConstraints = false
  3010. urlField.isEditable = true
  3011. urlField.isSelectable = true
  3012. urlField.isBordered = false
  3013. urlField.drawsBackground = false
  3014. urlField.placeholderString = "Enter meeting URL..."
  3015. urlField.font = typography.inputPlaceholder
  3016. urlField.textColor = palette.textPrimary
  3017. urlField.focusRingType = .none
  3018. textFieldContainer.addSubview(urlField)
  3019. let actions = NSStackView()
  3020. actions.orientation = .horizontal
  3021. actions.spacing = 10
  3022. actions.translatesAutoresizingMaskIntoConstraints = false
  3023. actions.alignment = .centerY
  3024. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  3025. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  3026. wrapper.addSubview(title)
  3027. wrapper.addSubview(textFieldContainer)
  3028. wrapper.addSubview(actions)
  3029. NSLayoutConstraint.activate([
  3030. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  3031. title.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  3032. title.topAnchor.constraint(equalTo: wrapper.topAnchor),
  3033. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  3034. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  3035. textFieldContainer.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10),
  3036. urlField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  3037. urlField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  3038. urlField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor),
  3039. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  3040. actions.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 14),
  3041. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  3042. ])
  3043. return wrapper
  3044. }
  3045. func scheduleHeader() -> NSView {
  3046. let row = NSStackView()
  3047. row.translatesAutoresizingMaskIntoConstraints = false
  3048. row.orientation = .horizontal
  3049. row.alignment = .centerY
  3050. row.distribution = .fill
  3051. row.spacing = 12
  3052. row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
  3053. let spacer = NSView()
  3054. spacer.translatesAutoresizingMaskIntoConstraints = false
  3055. row.addArrangedSubview(spacer)
  3056. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3057. row.addArrangedSubview(makeScheduleRefreshButton())
  3058. row.addArrangedSubview(makeScheduleFilterDropdown())
  3059. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  3060. return row
  3061. }
  3062. private func schedulePageHeader() -> NSView {
  3063. let container = NSStackView()
  3064. container.translatesAutoresizingMaskIntoConstraints = false
  3065. container.userInterfaceLayoutDirection = .leftToRight
  3066. container.orientation = .vertical
  3067. container.spacing = 8
  3068. container.alignment = .width
  3069. let titleRow = NSStackView()
  3070. titleRow.translatesAutoresizingMaskIntoConstraints = false
  3071. titleRow.userInterfaceLayoutDirection = .leftToRight
  3072. titleRow.orientation = .horizontal
  3073. titleRow.alignment = .centerY
  3074. titleRow.distribution = .fill
  3075. titleRow.spacing = 0
  3076. let titleLabel = textLabel("Schedule", font: typography.pageTitle, color: palette.textPrimary)
  3077. titleLabel.alignment = .left
  3078. titleLabel.userInterfaceLayoutDirection = .leftToRight
  3079. titleLabel.maximumNumberOfLines = 1
  3080. titleLabel.lineBreakMode = .byTruncatingTail
  3081. titleLabel.setContentHuggingPriority(.required, for: .horizontal)
  3082. titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  3083. let titleRowSpacer = NSView()
  3084. titleRowSpacer.translatesAutoresizingMaskIntoConstraints = false
  3085. titleRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3086. titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3087. titleRow.addArrangedSubview(titleLabel)
  3088. if googleOAuth.loadTokens() != nil && storeKitCoordinator.hasPremiumAccess {
  3089. titleRow.addArrangedSubview(makeSchedulePageAddButton())
  3090. titleRow.setCustomSpacing(12, after: titleLabel)
  3091. }
  3092. titleRow.addArrangedSubview(titleRowSpacer)
  3093. container.addArrangedSubview(titleRow)
  3094. let filterRow = NSStackView()
  3095. filterRow.translatesAutoresizingMaskIntoConstraints = false
  3096. filterRow.userInterfaceLayoutDirection = .leftToRight
  3097. filterRow.orientation = .horizontal
  3098. filterRow.alignment = .centerY
  3099. filterRow.spacing = 18
  3100. filterRow.distribution = .fill
  3101. let filterDropdown = makeSchedulePageFilterDropdown()
  3102. schedulePageFilterDropdown = filterDropdown
  3103. filterRow.addArrangedSubview(filterDropdown)
  3104. filterRow.setCustomSpacing(20, after: filterDropdown)
  3105. let (fromShell, fromPicker) = makeScheduleDatePicker(date: schedulePageFromDate)
  3106. schedulePageFromDatePicker = fromPicker
  3107. filterRow.addArrangedSubview(fromShell)
  3108. filterRow.setCustomSpacing(16, after: fromShell)
  3109. let (toShell, toPicker) = makeScheduleDatePicker(date: schedulePageToDate)
  3110. schedulePageToDatePicker = toPicker
  3111. filterRow.addArrangedSubview(toShell)
  3112. NSLayoutConstraint.activate([
  3113. fromShell.widthAnchor.constraint(equalTo: toShell.widthAnchor),
  3114. fromShell.widthAnchor.constraint(greaterThanOrEqualToConstant: 152)
  3115. ])
  3116. let filterRowSpacer = NSView()
  3117. filterRowSpacer.translatesAutoresizingMaskIntoConstraints = false
  3118. filterRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3119. filterRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3120. filterRow.addArrangedSubview(filterRowSpacer)
  3121. let applyButton = makeSchedulePagePillButton(title: "Apply", action: #selector(schedulePageApplyDateRangePressed(_:)))
  3122. filterRow.addArrangedSubview(applyButton)
  3123. filterRow.setCustomSpacing(22, after: applyButton)
  3124. let resetButton = makeSchedulePagePillButton(title: "Reset", action: #selector(schedulePageResetFiltersPressed(_:)))
  3125. filterRow.addArrangedSubview(resetButton)
  3126. filterRow.setCustomSpacing(22, after: resetButton)
  3127. filterRow.addArrangedSubview(makeScheduleRefreshButton())
  3128. container.addArrangedSubview(filterRow)
  3129. NSLayoutConstraint.activate([
  3130. titleRow.widthAnchor.constraint(equalTo: container.widthAnchor),
  3131. filterRow.widthAnchor.constraint(equalTo: container.widthAnchor)
  3132. ])
  3133. refreshSchedulePageDateFilterUI()
  3134. return container
  3135. }
  3136. private func makeSchedulePageFilterDropdown() -> NSPopUpButton {
  3137. let button = HoverPopUpButton(frame: .zero, pullsDown: false)
  3138. button.translatesAutoresizingMaskIntoConstraints = false
  3139. button.autoenablesItems = false
  3140. button.isBordered = false
  3141. button.bezelStyle = .regularSquare
  3142. button.wantsLayer = true
  3143. button.layer?.cornerRadius = 8
  3144. button.layer?.masksToBounds = true
  3145. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3146. button.layer?.borderColor = palette.inputBorder.cgColor
  3147. button.layer?.borderWidth = 1
  3148. button.font = typography.filterText
  3149. button.contentTintColor = palette.textSecondary
  3150. button.target = self
  3151. button.action = #selector(schedulePageFilterDropdownChanged(_:))
  3152. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  3153. button.widthAnchor.constraint(equalToConstant: 228).isActive = true
  3154. button.removeAllItems()
  3155. button.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
  3156. button.selectItem(at: schedulePageFilter.rawValue)
  3157. if let menu = button.menu {
  3158. for (index, item) in menu.items.enumerated() {
  3159. item.tag = index
  3160. }
  3161. }
  3162. let baseColor = palette.inputBackground
  3163. let baseBorder = palette.inputBorder
  3164. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3165. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  3166. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  3167. button.onHoverChanged = { [weak button] hovering in
  3168. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  3169. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  3170. }
  3171. button.onHoverChanged?(false)
  3172. return button
  3173. }
  3174. /// Rounded shell matching `makeSchedulePageFilterDropdown` (34pt, 8pt corner, border + hover). Both pickers use the same field+stepper style inside the shell.
  3175. private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) {
  3176. let shell = HoverSurfaceView()
  3177. shell.translatesAutoresizingMaskIntoConstraints = false
  3178. shell.wantsLayer = true
  3179. shell.layer?.cornerRadius = 8
  3180. shell.layer?.masksToBounds = true
  3181. shell.layer?.borderWidth = 1
  3182. let baseColor = palette.inputBackground
  3183. let baseBorder = palette.inputBorder
  3184. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3185. let hoverBackground = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  3186. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  3187. func applyShellIdleAppearance() {
  3188. shell.layer?.backgroundColor = baseColor.cgColor
  3189. shell.layer?.borderColor = baseBorder.cgColor
  3190. }
  3191. applyShellIdleAppearance()
  3192. shell.onHoverChanged = { [weak shell] hovering in
  3193. guard let shell else { return }
  3194. shell.layer?.backgroundColor = (hovering ? hoverBackground : baseColor).cgColor
  3195. shell.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  3196. }
  3197. let picker = NSDatePicker()
  3198. picker.translatesAutoresizingMaskIntoConstraints = false
  3199. picker.isBordered = false
  3200. picker.drawsBackground = false
  3201. picker.focusRingType = .none
  3202. picker.datePickerStyle = .textFieldAndStepper
  3203. picker.datePickerElements = [.yearMonthDay]
  3204. picker.dateValue = date
  3205. picker.font = typography.filterText
  3206. picker.textColor = palette.textSecondary
  3207. picker.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3208. picker.target = self
  3209. picker.action = #selector(schedulePageDatePickerChanged(_:))
  3210. shell.addSubview(picker)
  3211. NSLayoutConstraint.activate([
  3212. shell.heightAnchor.constraint(equalToConstant: 34),
  3213. picker.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: 8),
  3214. picker.trailingAnchor.constraint(equalTo: shell.trailingAnchor, constant: -8),
  3215. picker.centerYAnchor.constraint(equalTo: shell.centerYAnchor)
  3216. ])
  3217. return (shell, picker)
  3218. }
  3219. private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton {
  3220. let button = makeSchedulePillButton(title: title)
  3221. button.target = self
  3222. button.action = action
  3223. button.widthAnchor.constraint(equalToConstant: 100).isActive = true
  3224. return button
  3225. }
  3226. private func makeSchedulePageCardsContainer() -> NSView {
  3227. if let observer = schedulePageScrollObservation {
  3228. NotificationCenter.default.removeObserver(observer)
  3229. }
  3230. schedulePageScrollObservation = nil
  3231. let wrapper = NSView()
  3232. wrapper.translatesAutoresizingMaskIntoConstraints = false
  3233. wrapper.userInterfaceLayoutDirection = .leftToRight
  3234. let scroll = NSScrollView()
  3235. scroll.translatesAutoresizingMaskIntoConstraints = false
  3236. scroll.userInterfaceLayoutDirection = .leftToRight
  3237. scroll.drawsBackground = false
  3238. scroll.hasHorizontalScroller = false
  3239. scroll.hasVerticalScroller = true
  3240. scroll.autohidesScrollers = true
  3241. scroll.borderType = .noBorder
  3242. scroll.scrollerStyle = .overlay
  3243. scroll.automaticallyAdjustsContentInsets = false
  3244. let clip = TopAlignedClipView()
  3245. clip.drawsBackground = false
  3246. clip.postsBoundsChangedNotifications = true
  3247. scroll.contentView = clip
  3248. schedulePageCardsScrollView = scroll
  3249. wrapper.addSubview(scroll)
  3250. let stack = NSStackView()
  3251. stack.translatesAutoresizingMaskIntoConstraints = false
  3252. stack.userInterfaceLayoutDirection = .leftToRight
  3253. stack.orientation = .vertical
  3254. stack.spacing = schedulePageCardSpacing
  3255. stack.alignment = .width
  3256. schedulePageCardsStack = stack
  3257. scroll.documentView = stack
  3258. NSLayoutConstraint.activate([
  3259. scroll.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  3260. scroll.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  3261. scroll.topAnchor.constraint(equalTo: wrapper.topAnchor),
  3262. scroll.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
  3263. scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420),
  3264. stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  3265. stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  3266. stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  3267. stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
  3268. ])
  3269. scroll.contentView.postsBoundsChangedNotifications = true
  3270. schedulePageScrollObservation = NotificationCenter.default.addObserver(
  3271. forName: NSView.boundsDidChangeNotification,
  3272. object: scroll.contentView,
  3273. queue: .main
  3274. ) { [weak self] _ in
  3275. self?.schedulePageScrolled()
  3276. }
  3277. renderSchedulePageCards()
  3278. return wrapper
  3279. }
  3280. private func scheduleTopAuthRow() -> NSView {
  3281. let row = NSStackView()
  3282. row.translatesAutoresizingMaskIntoConstraints = false
  3283. row.orientation = .horizontal
  3284. row.alignment = .centerY
  3285. row.spacing = 10
  3286. let spacer = NSView()
  3287. spacer.translatesAutoresizingMaskIntoConstraints = false
  3288. row.addArrangedSubview(spacer)
  3289. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3290. let host = GoogleProfileAuthHostView()
  3291. host.translatesAutoresizingMaskIntoConstraints = false
  3292. let authButton = makeGoogleAuthButton()
  3293. host.authButton = authButton
  3294. scheduleGoogleAuthHostView = host
  3295. scheduleGoogleAuthButton = authButton
  3296. host.addSubview(authButton)
  3297. NSLayoutConstraint.activate([
  3298. authButton.centerXAnchor.constraint(equalTo: host.centerXAnchor),
  3299. authButton.centerYAnchor.constraint(equalTo: host.centerYAnchor)
  3300. ])
  3301. let hostPadW = host.widthAnchor.constraint(equalTo: authButton.widthAnchor, constant: 0)
  3302. let hostPadH = host.heightAnchor.constraint(equalTo: authButton.heightAnchor, constant: 0)
  3303. hostPadW.isActive = true
  3304. hostPadH.isActive = true
  3305. scheduleGoogleAuthHostPadWidthConstraint = hostPadW
  3306. scheduleGoogleAuthHostPadHeightConstraint = hostPadH
  3307. updateGoogleAuthButtonTitle()
  3308. row.addArrangedSubview(host)
  3309. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  3310. return row
  3311. }
  3312. private func makeScheduleFilterDropdown() -> NSPopUpButton {
  3313. let button = HoverPopUpButton(frame: .zero, pullsDown: false)
  3314. button.translatesAutoresizingMaskIntoConstraints = false
  3315. button.autoenablesItems = false
  3316. button.isBordered = false
  3317. button.bezelStyle = .regularSquare
  3318. button.wantsLayer = true
  3319. button.layer?.cornerRadius = 8
  3320. button.layer?.masksToBounds = true
  3321. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3322. button.layer?.borderColor = palette.inputBorder.cgColor
  3323. button.layer?.borderWidth = 1
  3324. button.font = typography.filterText
  3325. button.contentTintColor = palette.textSecondary
  3326. button.target = self
  3327. button.action = #selector(scheduleFilterDropdownChanged(_:))
  3328. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  3329. button.widthAnchor.constraint(equalToConstant: 156).isActive = true
  3330. button.removeAllItems()
  3331. button.addItems(withTitles: ["All", "Today", "This week"])
  3332. button.selectItem(at: scheduleFilter.rawValue)
  3333. if let menu = button.menu {
  3334. for (index, item) in menu.items.enumerated() {
  3335. item.tag = index
  3336. }
  3337. }
  3338. let baseColor = palette.inputBackground
  3339. let baseBorder = palette.inputBorder
  3340. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3341. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  3342. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  3343. button.onHoverChanged = { [weak button] hovering in
  3344. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  3345. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  3346. }
  3347. button.onHoverChanged?(false)
  3348. scheduleFilterDropdown = button
  3349. return button
  3350. }
  3351. private func makeSchedulePillButton(title: String) -> NSButton {
  3352. let button = NSButton(title: title, target: nil, action: nil)
  3353. button.translatesAutoresizingMaskIntoConstraints = false
  3354. button.isBordered = false
  3355. button.bezelStyle = .regularSquare
  3356. button.wantsLayer = true
  3357. button.layer?.cornerRadius = 8
  3358. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3359. button.layer?.borderColor = palette.inputBorder.cgColor
  3360. button.layer?.borderWidth = 1
  3361. button.font = typography.filterText
  3362. button.contentTintColor = palette.textSecondary
  3363. button.setButtonType(.momentaryChange)
  3364. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  3365. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
  3366. return button
  3367. }
  3368. private func makeGoogleAuthButton() -> NSButton {
  3369. let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
  3370. button.translatesAutoresizingMaskIntoConstraints = false
  3371. button.isBordered = false
  3372. button.bezelStyle = .regularSquare
  3373. button.wantsLayer = true
  3374. button.layer?.cornerRadius = 16
  3375. button.layer?.borderWidth = 1
  3376. button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
  3377. button.imagePosition = .imageLeading
  3378. button.alignment = .center
  3379. button.imageHugsTitle = true
  3380. button.lineBreakMode = .byTruncatingTail
  3381. button.contentTintColor = palette.textPrimary
  3382. button.imageScaling = .scaleNone
  3383. button.layer?.masksToBounds = true
  3384. let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42)
  3385. heightConstraint.isActive = true
  3386. scheduleGoogleAuthButtonHeightConstraint = heightConstraint
  3387. let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
  3388. widthConstraint.isActive = true
  3389. scheduleGoogleAuthButtonWidthConstraint = widthConstraint
  3390. button.onHoverChanged = { [weak self] hovering in
  3391. self?.scheduleGoogleAuthHovering = hovering
  3392. self?.scheduleGoogleAuthHostView?.setProfileHoverActive(hovering)
  3393. self?.applyGoogleAuthButtonSurface()
  3394. }
  3395. button.onHoverChanged?(false)
  3396. return button
  3397. }
  3398. private func makeSchedulePageAddButton() -> NSButton {
  3399. let diameter: CGFloat = 30
  3400. let button = HoverButton(title: "", target: self, action: #selector(schedulePageAddMeetingPressed(_:)))
  3401. button.translatesAutoresizingMaskIntoConstraints = false
  3402. button.isBordered = false
  3403. button.bezelStyle = .regularSquare
  3404. button.wantsLayer = true
  3405. button.layer?.cornerRadius = diameter / 2
  3406. button.layer?.masksToBounds = true
  3407. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3408. button.layer?.borderColor = palette.inputBorder.cgColor
  3409. button.layer?.borderWidth = 1
  3410. button.setButtonType(.momentaryChange)
  3411. button.contentTintColor = palette.textSecondary
  3412. button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Schedule meeting")
  3413. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  3414. button.imagePosition = .imageOnly
  3415. button.imageScaling = .scaleProportionallyDown
  3416. button.focusRingType = .none
  3417. button.heightAnchor.constraint(equalToConstant: diameter).isActive = true
  3418. button.widthAnchor.constraint(equalToConstant: diameter).isActive = true
  3419. let baseColor = palette.inputBackground
  3420. let baseBorder = palette.inputBorder
  3421. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3422. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  3423. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  3424. button.onHoverChanged = { [weak button] hovering in
  3425. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  3426. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  3427. }
  3428. button.onHoverChanged?(false)
  3429. return button
  3430. }
  3431. private func makeScheduleRefreshButton() -> NSButton {
  3432. let diameter: CGFloat = 30
  3433. let button = HoverButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
  3434. button.translatesAutoresizingMaskIntoConstraints = false
  3435. button.isBordered = false
  3436. button.bezelStyle = .regularSquare
  3437. button.wantsLayer = true
  3438. button.layer?.cornerRadius = diameter / 2
  3439. button.layer?.masksToBounds = true
  3440. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3441. button.layer?.borderColor = palette.inputBorder.cgColor
  3442. button.layer?.borderWidth = 1
  3443. button.setButtonType(.momentaryChange)
  3444. button.contentTintColor = palette.textSecondary
  3445. button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
  3446. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  3447. button.imagePosition = .imageOnly
  3448. button.imageScaling = .scaleProportionallyDown
  3449. button.focusRingType = .none
  3450. button.heightAnchor.constraint(equalToConstant: diameter).isActive = true
  3451. button.widthAnchor.constraint(equalToConstant: diameter).isActive = true
  3452. let baseColor = palette.inputBackground
  3453. let baseBorder = palette.inputBorder
  3454. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  3455. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  3456. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  3457. button.onHoverChanged = { [weak button] hovering in
  3458. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  3459. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  3460. }
  3461. button.onHoverChanged?(false)
  3462. return button
  3463. }
  3464. func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
  3465. let cardWidth: CGFloat = 240
  3466. let cardsPerViewport: CGFloat = 3
  3467. let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
  3468. let wrapper = NSStackView()
  3469. wrapper.translatesAutoresizingMaskIntoConstraints = false
  3470. wrapper.orientation = .horizontal
  3471. wrapper.alignment = .centerY
  3472. wrapper.spacing = 10
  3473. let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
  3474. scheduleScrollLeftButton = leftButton
  3475. wrapper.addArrangedSubview(leftButton)
  3476. let scroll = NSScrollView()
  3477. scheduleCardsScrollView = scroll
  3478. scroll.translatesAutoresizingMaskIntoConstraints = false
  3479. scroll.drawsBackground = false
  3480. scroll.hasHorizontalScroller = false
  3481. scroll.hasVerticalScroller = false
  3482. scroll.horizontalScrollElasticity = .allowed
  3483. scroll.verticalScrollElasticity = .none
  3484. scroll.autohidesScrollers = false
  3485. scroll.borderType = .noBorder
  3486. scroll.automaticallyAdjustsContentInsets = false
  3487. scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
  3488. scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
  3489. let row = NSStackView()
  3490. row.translatesAutoresizingMaskIntoConstraints = false
  3491. row.orientation = .horizontal
  3492. row.spacing = 12
  3493. row.alignment = .top
  3494. row.distribution = .gravityAreas
  3495. row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  3496. row.heightAnchor.constraint(equalToConstant: 150).isActive = true
  3497. scheduleCardsStack = row
  3498. scroll.documentView = row
  3499. scroll.contentView.postsBoundsChangedNotifications = true
  3500. // Pin top/leading/trailing only; avoid bottom == clip so the horizontal stack is not stretched vertically inside the clip (same as Schedule cards scroll).
  3501. NSLayoutConstraint.activate([
  3502. row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  3503. row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
  3504. row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  3505. row.heightAnchor.constraint(equalToConstant: 150)
  3506. ])
  3507. renderScheduleCards(into: row, meetings: meetings)
  3508. wrapper.addArrangedSubview(scroll)
  3509. let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
  3510. scheduleScrollRightButton = rightButton
  3511. wrapper.addArrangedSubview(rightButton)
  3512. scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3513. scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3514. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  3515. return wrapper
  3516. }
  3517. func scheduleCard(meeting: ScheduledMeeting, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView {
  3518. let cardWidth: CGFloat = 240
  3519. let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
  3520. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  3521. card.translatesAutoresizingMaskIntoConstraints = false
  3522. card.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true
  3523. if useFlexibleWidth {
  3524. card.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3525. card.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3526. } else {
  3527. card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  3528. card.setContentHuggingPriority(.required, for: .horizontal)
  3529. card.setContentCompressionResistancePriority(.required, for: .horizontal)
  3530. }
  3531. let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
  3532. icon.translatesAutoresizingMaskIntoConstraints = false
  3533. icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
  3534. icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
  3535. let iconView = NSImageView()
  3536. iconView.translatesAutoresizingMaskIntoConstraints = false
  3537. iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
  3538. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  3539. iconView.contentTintColor = .white
  3540. icon.addSubview(iconView)
  3541. NSLayoutConstraint.activate([
  3542. iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
  3543. iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
  3544. ])
  3545. let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
  3546. title.lineBreakMode = .byTruncatingTail
  3547. title.maximumNumberOfLines = 1
  3548. title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3549. let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
  3550. let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
  3551. let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
  3552. let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
  3553. dayChip.translatesAutoresizingMaskIntoConstraints = false
  3554. dayChip.layer?.borderWidth = 1
  3555. dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
  3556. let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
  3557. dayText.translatesAutoresizingMaskIntoConstraints = false
  3558. dayChip.addSubview(dayText)
  3559. NSLayoutConstraint.activate([
  3560. dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
  3561. dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
  3562. dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
  3563. dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
  3564. ])
  3565. card.addSubview(icon)
  3566. card.addSubview(dayChip)
  3567. card.addSubview(title)
  3568. card.addSubview(subtitle)
  3569. card.addSubview(time)
  3570. card.addSubview(duration)
  3571. var titleConstraints: [NSLayoutConstraint] = [
  3572. icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  3573. icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
  3574. dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
  3575. dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  3576. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
  3577. title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  3578. title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8)
  3579. ]
  3580. if !useFlexibleWidth {
  3581. titleConstraints.append(title.widthAnchor.constraint(lessThanOrEqualToConstant: 130))
  3582. }
  3583. NSLayoutConstraint.activate(titleConstraints + [
  3584. subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  3585. subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
  3586. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  3587. time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  3588. time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
  3589. time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  3590. duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  3591. duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
  3592. duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
  3593. ])
  3594. let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
  3595. hit.translatesAutoresizingMaskIntoConstraints = false
  3596. hit.isBordered = false
  3597. hit.bezelStyle = .regularSquare
  3598. hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
  3599. hit.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true
  3600. if useFlexibleWidth {
  3601. hit.setContentHuggingPriority(.defaultLow, for: .horizontal)
  3602. hit.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  3603. } else {
  3604. hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  3605. hit.setContentHuggingPriority(.required, for: .horizontal)
  3606. hit.setContentCompressionResistancePriority(.required, for: .horizontal)
  3607. }
  3608. hit.addSubview(card)
  3609. NSLayoutConstraint.activate([
  3610. card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
  3611. card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
  3612. card.topAnchor.constraint(equalTo: hit.topAnchor),
  3613. card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
  3614. ])
  3615. hit.onHoverChanged = { [weak self] hovering in
  3616. guard let self else { return }
  3617. let base = self.palette.sectionCard
  3618. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  3619. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  3620. if self.storeKitCoordinator.hasPremiumAccess {
  3621. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  3622. } else {
  3623. card.layer?.backgroundColor = base.cgColor
  3624. }
  3625. }
  3626. hit.onHoverChanged?(false)
  3627. if !storeKitCoordinator.hasPremiumAccess {
  3628. let lockOverlay = NSVisualEffectView()
  3629. lockOverlay.translatesAutoresizingMaskIntoConstraints = false
  3630. lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
  3631. lockOverlay.blendingMode = .withinWindow
  3632. lockOverlay.state = .active
  3633. lockOverlay.wantsLayer = true
  3634. lockOverlay.layer?.cornerRadius = 12
  3635. lockOverlay.layer?.masksToBounds = true
  3636. lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.28 : 0.12).cgColor
  3637. let lockLabel = textLabel("Get Premium to see events", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: darkModeEnabled ? .white : .black)
  3638. lockLabel.alignment = .center
  3639. card.addSubview(lockOverlay)
  3640. lockOverlay.addSubview(lockLabel)
  3641. NSLayoutConstraint.activate([
  3642. lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor),
  3643. lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor),
  3644. lockOverlay.topAnchor.constraint(equalTo: card.topAnchor),
  3645. lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor),
  3646. lockLabel.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
  3647. lockLabel.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
  3648. lockLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 10),
  3649. lockLabel.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -10)
  3650. ])
  3651. hit.toolTip = "Premium required. Click to open paywall."
  3652. }
  3653. return hit
  3654. }
  3655. private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
  3656. let button = NSButton(title: "", target: self, action: action)
  3657. button.translatesAutoresizingMaskIntoConstraints = false
  3658. button.isBordered = false
  3659. button.bezelStyle = .regularSquare
  3660. button.wantsLayer = true
  3661. button.layer?.cornerRadius = 16
  3662. button.layer?.backgroundColor = palette.inputBackground.cgColor
  3663. button.layer?.borderColor = palette.inputBorder.cgColor
  3664. button.layer?.borderWidth = 1
  3665. button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
  3666. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  3667. button.imagePosition = .imageOnly
  3668. button.imageScaling = .scaleProportionallyDown
  3669. button.contentTintColor = palette.textSecondary
  3670. button.focusRingType = .none
  3671. button.heightAnchor.constraint(equalToConstant: 32).isActive = true
  3672. button.widthAnchor.constraint(equalToConstant: 32).isActive = true
  3673. return button
  3674. }
  3675. }
  3676. private extension PremiumPlan {
  3677. var displayName: String {
  3678. switch self {
  3679. case .weekly: return "Weekly"
  3680. case .monthly: return "Monthly"
  3681. case .yearly: return "Yearly"
  3682. case .lifetime: return "Lifetime"
  3683. }
  3684. }
  3685. }
  3686. extension ViewController: NSTextFieldDelegate {
  3687. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  3688. if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  3689. browseOpenAddressClicked(nil)
  3690. return true
  3691. }
  3692. return false
  3693. }
  3694. }
  3695. extension ViewController: NSWindowDelegate {
  3696. func windowWillClose(_ notification: Notification) {
  3697. guard let closingWindow = notification.object as? NSWindow else { return }
  3698. if closingWindow === paywallWindow {
  3699. paywallWindow = nil
  3700. paywallUpgradeFlowEnabled = false
  3701. }
  3702. }
  3703. }
  3704. /// 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.
  3705. private final class TopAlignedClipView: NSClipView {
  3706. override var isFlipped: Bool { true }
  3707. }
  3708. /// Wraps the Google auth control and draws a circular accent ring with a light pulse while the signed-in avatar is hovered.
  3709. private final class GoogleProfileAuthHostView: NSView {
  3710. weak var authButton: NSButton? {
  3711. didSet { needsLayout = true }
  3712. }
  3713. private let ringLayer = CAShapeLayer()
  3714. private var avatarRingMode = false
  3715. private static let ringLineWidth: CGFloat = 2.25
  3716. override init(frame frameRect: NSRect) {
  3717. super.init(frame: frameRect)
  3718. wantsLayer = true
  3719. layer?.masksToBounds = false
  3720. ringLayer.fillColor = nil
  3721. ringLayer.strokeColor = NSColor.clear.cgColor
  3722. ringLayer.lineWidth = Self.ringLineWidth
  3723. ringLayer.lineCap = .round
  3724. ringLayer.opacity = 0
  3725. ringLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
  3726. layer?.insertSublayer(ringLayer, at: 0)
  3727. }
  3728. @available(*, unavailable)
  3729. required init?(coder: NSCoder) {
  3730. nil
  3731. }
  3732. func setAvatarRingMode(_ enabled: Bool) {
  3733. avatarRingMode = enabled
  3734. if enabled == false {
  3735. ringLayer.removeAllAnimations()
  3736. ringLayer.opacity = 0
  3737. ringLayer.lineWidth = Self.ringLineWidth
  3738. }
  3739. needsLayout = true
  3740. }
  3741. func updateRingAppearance(isDark: Bool, accent: NSColor) {
  3742. let stroke = isDark
  3743. ? accent.blended(withFraction: 0.22, of: NSColor.white) ?? accent
  3744. : accent
  3745. CATransaction.begin()
  3746. CATransaction.setDisableActions(true)
  3747. ringLayer.strokeColor = stroke.withAlphaComponent(0.95).cgColor
  3748. CATransaction.commit()
  3749. }
  3750. func setProfileHoverActive(_ active: Bool) {
  3751. guard avatarRingMode else { return }
  3752. ringLayer.removeAnimation(forKey: "pulse")
  3753. if active {
  3754. layoutRingPathIfNeeded()
  3755. CATransaction.begin()
  3756. CATransaction.setAnimationDuration(0.22)
  3757. ringLayer.opacity = 1
  3758. CATransaction.commit()
  3759. let pulse = CABasicAnimation(keyPath: "lineWidth")
  3760. pulse.fromValue = Self.ringLineWidth * 0.88
  3761. pulse.toValue = Self.ringLineWidth * 1.45
  3762. pulse.duration = 0.72
  3763. pulse.autoreverses = true
  3764. pulse.repeatCount = .infinity
  3765. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  3766. ringLayer.add(pulse, forKey: "pulse")
  3767. } else {
  3768. CATransaction.begin()
  3769. CATransaction.setAnimationDuration(0.18)
  3770. ringLayer.opacity = 0
  3771. CATransaction.commit()
  3772. ringLayer.lineWidth = Self.ringLineWidth
  3773. }
  3774. }
  3775. private func layoutRingPathIfNeeded() {
  3776. guard avatarRingMode, let btn = authButton else { return }
  3777. let f = btn.frame
  3778. guard f.width > 1, f.height > 1 else { return }
  3779. let center = CGPoint(x: f.midX, y: f.midY)
  3780. let avatarR = min(f.width, f.height) / 2
  3781. let gap: CGFloat = 3.5
  3782. let ringRadius = avatarR + gap
  3783. let d = ringRadius * 2
  3784. CATransaction.begin()
  3785. CATransaction.setDisableActions(true)
  3786. ringLayer.bounds = CGRect(x: 0, y: 0, width: d, height: d)
  3787. ringLayer.position = center
  3788. ringLayer.path = CGPath(ellipseIn: CGRect(origin: .zero, size: CGSize(width: d, height: d)), transform: nil)
  3789. CATransaction.commit()
  3790. }
  3791. override func layout() {
  3792. super.layout()
  3793. layoutRingPathIfNeeded()
  3794. }
  3795. }
  3796. /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
  3797. private class RowHitTestView: NSView {
  3798. override func hitTest(_ point: NSPoint) -> NSView? {
  3799. return bounds.contains(point) ? self : nil
  3800. }
  3801. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  3802. true
  3803. }
  3804. }
  3805. private final class HoverTrackingView: RowHitTestView {
  3806. var onHoverChanged: ((Bool) -> Void)?
  3807. var onClick: (() -> Void)?
  3808. var showsHandCursor = true
  3809. private var trackingAreaRef: NSTrackingArea?
  3810. private var isHovering = false {
  3811. didSet {
  3812. guard isHovering != oldValue else { return }
  3813. onHoverChanged?(isHovering)
  3814. }
  3815. }
  3816. override func updateTrackingAreas() {
  3817. super.updateTrackingAreas()
  3818. if let trackingAreaRef {
  3819. removeTrackingArea(trackingAreaRef)
  3820. }
  3821. let options: NSTrackingArea.Options = [
  3822. .activeInKeyWindow,
  3823. .inVisibleRect,
  3824. .mouseEnteredAndExited
  3825. ]
  3826. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  3827. addTrackingArea(area)
  3828. trackingAreaRef = area
  3829. }
  3830. override func mouseEntered(with event: NSEvent) {
  3831. super.mouseEntered(with: event)
  3832. isHovering = true
  3833. }
  3834. override func mouseExited(with event: NSEvent) {
  3835. super.mouseExited(with: event)
  3836. isHovering = false
  3837. }
  3838. override func resetCursorRects() {
  3839. super.resetCursorRects()
  3840. guard showsHandCursor else { return }
  3841. addCursorRect(bounds, cursor: .pointingHand)
  3842. }
  3843. override func mouseUp(with event: NSEvent) {
  3844. super.mouseUp(with: event)
  3845. guard event.type == .leftMouseUp else { return }
  3846. onClick?()
  3847. }
  3848. }
  3849. /// Hover tracking without overriding hit-testing; keeps controls like text fields interactive.
  3850. private final class HoverSurfaceView: NSView {
  3851. var onHoverChanged: ((Bool) -> Void)?
  3852. private var trackingAreaRef: NSTrackingArea?
  3853. private var isHovering = false {
  3854. didSet {
  3855. guard isHovering != oldValue else { return }
  3856. onHoverChanged?(isHovering)
  3857. }
  3858. }
  3859. override func updateTrackingAreas() {
  3860. super.updateTrackingAreas()
  3861. if let trackingAreaRef {
  3862. removeTrackingArea(trackingAreaRef)
  3863. }
  3864. let options: NSTrackingArea.Options = [
  3865. .activeInKeyWindow,
  3866. .inVisibleRect,
  3867. .mouseEnteredAndExited
  3868. ]
  3869. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  3870. addTrackingArea(area)
  3871. trackingAreaRef = area
  3872. }
  3873. override func mouseEntered(with event: NSEvent) {
  3874. super.mouseEntered(with: event)
  3875. isHovering = true
  3876. }
  3877. override func mouseExited(with event: NSEvent) {
  3878. super.mouseExited(with: event)
  3879. isHovering = false
  3880. }
  3881. }
  3882. private final class HoverButton: NSButton {
  3883. var onHoverChanged: ((Bool) -> Void)?
  3884. var showsHandCursor = true
  3885. private var trackingAreaRef: NSTrackingArea?
  3886. private var isHovering = false {
  3887. didSet {
  3888. guard isHovering != oldValue else { return }
  3889. onHoverChanged?(isHovering)
  3890. }
  3891. }
  3892. override func updateTrackingAreas() {
  3893. super.updateTrackingAreas()
  3894. if let trackingAreaRef {
  3895. removeTrackingArea(trackingAreaRef)
  3896. }
  3897. let options: NSTrackingArea.Options = [
  3898. .activeInKeyWindow,
  3899. .inVisibleRect,
  3900. .mouseEnteredAndExited
  3901. ]
  3902. let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  3903. addTrackingArea(tracking)
  3904. trackingAreaRef = tracking
  3905. }
  3906. override func mouseEntered(with event: NSEvent) {
  3907. super.mouseEntered(with: event)
  3908. if showsHandCursor {
  3909. NSCursor.pointingHand.set()
  3910. }
  3911. isHovering = true
  3912. }
  3913. override func mouseExited(with event: NSEvent) {
  3914. super.mouseExited(with: event)
  3915. isHovering = false
  3916. }
  3917. }
  3918. private final class HoverPopUpButton: NSPopUpButton {
  3919. var onHoverChanged: ((Bool) -> Void)?
  3920. private var trackingAreaRef: NSTrackingArea?
  3921. private var isHovering = false {
  3922. didSet {
  3923. guard isHovering != oldValue else { return }
  3924. onHoverChanged?(isHovering)
  3925. }
  3926. }
  3927. override func updateTrackingAreas() {
  3928. super.updateTrackingAreas()
  3929. if let trackingAreaRef {
  3930. removeTrackingArea(trackingAreaRef)
  3931. }
  3932. let options: NSTrackingArea.Options = [
  3933. .activeInKeyWindow,
  3934. .inVisibleRect,
  3935. .mouseEnteredAndExited
  3936. ]
  3937. let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  3938. addTrackingArea(tracking)
  3939. trackingAreaRef = tracking
  3940. }
  3941. override func mouseEntered(with event: NSEvent) {
  3942. super.mouseEntered(with: event)
  3943. isHovering = true
  3944. }
  3945. override func mouseExited(with event: NSEvent) {
  3946. super.mouseExited(with: event)
  3947. isHovering = false
  3948. }
  3949. }
  3950. private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  3951. let size = NSSize(width: diameter, height: diameter)
  3952. let result = NSImage(size: size)
  3953. result.lockFocus()
  3954. if let ctx = NSGraphicsContext.current {
  3955. ctx.imageInterpolation = .high
  3956. }
  3957. let rect = NSRect(origin: .zero, size: size)
  3958. let path = NSBezierPath(ovalIn: rect)
  3959. path.addClip()
  3960. let src = image.size.width > 0 && image.size.height > 0
  3961. ? NSRect(origin: .zero, size: image.size)
  3962. : NSRect(origin: .zero, size: size)
  3963. image.draw(in: rect, from: src, operation: .copy, fraction: 1.0)
  3964. result.unlockFocus()
  3965. result.isTemplate = false
  3966. return result
  3967. }
  3968. private final class GoogleAccountMenuViewController: NSViewController {
  3969. private let palette: Palette
  3970. private let darkModeEnabled: Bool
  3971. private let displayName: String
  3972. private let email: String
  3973. private let avatar: NSImage?
  3974. private let onSignOut: () -> Void
  3975. init(
  3976. palette: Palette,
  3977. darkModeEnabled: Bool,
  3978. displayName: String,
  3979. email: String,
  3980. avatar: NSImage?,
  3981. onSignOut: @escaping () -> Void
  3982. ) {
  3983. self.palette = palette
  3984. self.darkModeEnabled = darkModeEnabled
  3985. self.displayName = displayName
  3986. self.email = email
  3987. self.avatar = avatar
  3988. self.onSignOut = onSignOut
  3989. super.init(nibName: nil, bundle: nil)
  3990. view = makeContentView()
  3991. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  3992. preferredContentSize = NSSize(width: 300, height: 158)
  3993. }
  3994. @available(*, unavailable)
  3995. required init?(coder: NSCoder) {
  3996. nil
  3997. }
  3998. private func makeContentView() -> NSView {
  3999. let root = NSView()
  4000. root.translatesAutoresizingMaskIntoConstraints = false
  4001. let card = NSView()
  4002. card.translatesAutoresizingMaskIntoConstraints = false
  4003. card.wantsLayer = true
  4004. card.layer?.cornerRadius = 14
  4005. card.layer?.backgroundColor = palette.sectionCard.cgColor
  4006. card.layer?.borderColor = palette.inputBorder.cgColor
  4007. card.layer?.borderWidth = 1
  4008. card.layer?.shadowColor = NSColor.black.cgColor
  4009. card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2
  4010. card.layer?.shadowOffset = CGSize(width: 0, height: 6)
  4011. card.layer?.shadowRadius = 18
  4012. card.layer?.masksToBounds = false
  4013. root.addSubview(card)
  4014. NSLayoutConstraint.activate([
  4015. card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  4016. card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  4017. card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  4018. card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  4019. root.widthAnchor.constraint(equalToConstant: 300)
  4020. ])
  4021. let inner = NSStackView()
  4022. inner.translatesAutoresizingMaskIntoConstraints = false
  4023. inner.orientation = .vertical
  4024. inner.spacing = 0
  4025. inner.alignment = .leading
  4026. card.addSubview(inner)
  4027. NSLayoutConstraint.activate([
  4028. inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  4029. inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  4030. inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18),
  4031. inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
  4032. ])
  4033. let headerRow = NSView()
  4034. headerRow.translatesAutoresizingMaskIntoConstraints = false
  4035. let avatarBox = NSView()
  4036. avatarBox.translatesAutoresizingMaskIntoConstraints = false
  4037. avatarBox.wantsLayer = true
  4038. avatarBox.layer?.cornerRadius = 24
  4039. avatarBox.layer?.masksToBounds = true
  4040. avatarBox.layer?.borderColor = palette.inputBorder.cgColor
  4041. avatarBox.layer?.borderWidth = 1
  4042. let avatarView = NSImageView()
  4043. avatarView.translatesAutoresizingMaskIntoConstraints = false
  4044. avatarView.imageScaling = .scaleAxesIndependently
  4045. avatarView.image = resolvedAvatarImage()
  4046. avatarBox.addSubview(avatarView)
  4047. NSLayoutConstraint.activate([
  4048. avatarBox.widthAnchor.constraint(equalToConstant: 48),
  4049. avatarBox.heightAnchor.constraint(equalToConstant: 48),
  4050. avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor),
  4051. avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor),
  4052. avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor),
  4053. avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor)
  4054. ])
  4055. let textColumn = NSStackView()
  4056. textColumn.translatesAutoresizingMaskIntoConstraints = false
  4057. textColumn.orientation = .vertical
  4058. textColumn.spacing = 3
  4059. textColumn.alignment = .leading
  4060. let nameField = NSTextField(labelWithString: displayName)
  4061. nameField.translatesAutoresizingMaskIntoConstraints = false
  4062. nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  4063. nameField.textColor = palette.textPrimary
  4064. nameField.lineBreakMode = .byTruncatingTail
  4065. nameField.maximumNumberOfLines = 1
  4066. nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  4067. let emailField = NSTextField(labelWithString: email)
  4068. emailField.translatesAutoresizingMaskIntoConstraints = false
  4069. emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular)
  4070. emailField.textColor = palette.textTertiary
  4071. emailField.lineBreakMode = .byTruncatingTail
  4072. emailField.maximumNumberOfLines = 1
  4073. emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  4074. textColumn.addArrangedSubview(nameField)
  4075. textColumn.addArrangedSubview(emailField)
  4076. headerRow.addSubview(avatarBox)
  4077. headerRow.addSubview(textColumn)
  4078. NSLayoutConstraint.activate([
  4079. avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor),
  4080. avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor),
  4081. avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor),
  4082. textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14),
  4083. textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor),
  4084. textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor),
  4085. textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor),
  4086. textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor)
  4087. ])
  4088. inner.addArrangedSubview(headerRow)
  4089. headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  4090. inner.setCustomSpacing(14, after: headerRow)
  4091. let separator = NSView()
  4092. separator.translatesAutoresizingMaskIntoConstraints = false
  4093. separator.wantsLayer = true
  4094. separator.layer?.backgroundColor = palette.separator.cgColor
  4095. separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
  4096. inner.addArrangedSubview(separator)
  4097. separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  4098. inner.setCustomSpacing(6, after: separator)
  4099. let signOutRow = HoverTrackingView()
  4100. signOutRow.translatesAutoresizingMaskIntoConstraints = false
  4101. signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true
  4102. signOutRow.wantsLayer = true
  4103. signOutRow.layer?.cornerRadius = 10
  4104. let signOutIcon = NSImageView()
  4105. signOutIcon.translatesAutoresizingMaskIntoConstraints = false
  4106. signOutIcon.imageScaling = .scaleProportionallyDown
  4107. if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) {
  4108. signOutIcon.image = sym
  4109. signOutIcon.contentTintColor = palette.textSecondary
  4110. signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  4111. }
  4112. let signOutLabel = NSTextField(labelWithString: "Log out")
  4113. signOutLabel.translatesAutoresizingMaskIntoConstraints = false
  4114. signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
  4115. signOutLabel.textColor = palette.textPrimary
  4116. signOutRow.addSubview(signOutIcon)
  4117. signOutRow.addSubview(signOutLabel)
  4118. NSLayoutConstraint.activate([
  4119. signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10),
  4120. signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  4121. signOutIcon.widthAnchor.constraint(equalToConstant: 20),
  4122. signOutIcon.heightAnchor.constraint(equalToConstant: 20),
  4123. signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10),
  4124. signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  4125. signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10)
  4126. ])
  4127. let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked))
  4128. signOutRow.addGestureRecognizer(signOutClick)
  4129. signOutRow.onHoverChanged = { [weak self] hovering in
  4130. guard let self else { return }
  4131. signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  4132. }
  4133. signOutRow.onHoverChanged?(false)
  4134. inner.addArrangedSubview(signOutRow)
  4135. signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  4136. return root
  4137. }
  4138. private func resolvedAvatarImage() -> NSImage {
  4139. if let a = avatar {
  4140. return circularNSImage(a, diameter: 48)
  4141. }
  4142. return initialLetterAvatar()
  4143. }
  4144. private func initialLetterAvatar() -> NSImage {
  4145. let d: CGFloat = 48
  4146. let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?"
  4147. let img = NSImage(size: NSSize(width: d, height: d))
  4148. img.lockFocus()
  4149. palette.primaryBlue.setFill()
  4150. NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
  4151. let attrs: [NSAttributedString.Key: Any] = [
  4152. .font: NSFont.systemFont(ofSize: 20, weight: .semibold),
  4153. .foregroundColor: NSColor.white
  4154. ]
  4155. let sz = (letter as NSString).size(withAttributes: attrs)
  4156. let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2)
  4157. (letter as NSString).draw(at: origin, withAttributes: attrs)
  4158. img.unlockFocus()
  4159. img.isTemplate = false
  4160. return img
  4161. }
  4162. @objc private func signOutClicked() {
  4163. onSignOut()
  4164. }
  4165. }
  4166. private final class SettingsMenuViewController: NSViewController {
  4167. private let palette: Palette
  4168. private let typography: Typography
  4169. private let onToggleDarkMode: (Bool) -> Void
  4170. private let onAction: (SettingsAction, NSView?, NSPoint?) -> Void
  4171. private var darkToggle: NSSwitch?
  4172. init(
  4173. palette: Palette,
  4174. typography: Typography,
  4175. darkModeEnabled: Bool,
  4176. showRateUsInSettings: Bool,
  4177. showUpgradeInSettings: Bool,
  4178. onToggleDarkMode: @escaping (Bool) -> Void,
  4179. onAction: @escaping (SettingsAction, NSView?, NSPoint?) -> Void
  4180. ) {
  4181. self.palette = palette
  4182. self.typography = typography
  4183. self.onToggleDarkMode = onToggleDarkMode
  4184. self.onAction = onAction
  4185. super.init(nibName: nil, bundle: nil)
  4186. self.view = makeView(
  4187. darkModeEnabled: darkModeEnabled,
  4188. showRateUsInSettings: showRateUsInSettings,
  4189. showUpgradeInSettings: showUpgradeInSettings
  4190. )
  4191. }
  4192. @available(*, unavailable)
  4193. required init?(coder: NSCoder) {
  4194. nil
  4195. }
  4196. func setDarkModeEnabled(_ enabled: Bool) {
  4197. darkToggle?.state = enabled ? .on : .off
  4198. }
  4199. private func makeView(
  4200. darkModeEnabled: Bool,
  4201. showRateUsInSettings: Bool,
  4202. showUpgradeInSettings: Bool
  4203. ) -> NSView {
  4204. let root = NSView()
  4205. root.translatesAutoresizingMaskIntoConstraints = false
  4206. let card = roundedCard()
  4207. root.addSubview(card)
  4208. NSLayoutConstraint.activate([
  4209. card.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  4210. card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  4211. card.topAnchor.constraint(equalTo: root.topAnchor),
  4212. card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  4213. root.widthAnchor.constraint(equalToConstant: 260)
  4214. ])
  4215. let stack = NSStackView()
  4216. stack.translatesAutoresizingMaskIntoConstraints = false
  4217. stack.orientation = .vertical
  4218. stack.spacing = 6
  4219. stack.alignment = .leading
  4220. card.addSubview(stack)
  4221. NSLayoutConstraint.activate([
  4222. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  4223. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  4224. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  4225. stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14)
  4226. ])
  4227. stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
  4228. if showRateUsInSettings {
  4229. stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
  4230. }
  4231. stack.addArrangedSubview(settingsActionRow(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy))
  4232. stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
  4233. stack.addArrangedSubview(settingsActionRow(icon: "📄", title: "Terms of Services", action: .termsOfServices))
  4234. stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
  4235. if showUpgradeInSettings {
  4236. stack.addArrangedSubview(settingsActionRow(icon: "⬆︎", title: "Upgrade", action: .upgrade))
  4237. }
  4238. for v in stack.arrangedSubviews {
  4239. v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4240. }
  4241. return root
  4242. }
  4243. private func roundedCard() -> NSView {
  4244. let view = NSView()
  4245. view.translatesAutoresizingMaskIntoConstraints = false
  4246. view.wantsLayer = true
  4247. view.layer?.cornerRadius = 12
  4248. view.layer?.backgroundColor = palette.sectionCard.cgColor
  4249. view.layer?.borderColor = palette.inputBorder.cgColor
  4250. view.layer?.borderWidth = 1
  4251. view.layer?.shadowColor = NSColor.black.cgColor
  4252. view.layer?.shadowOpacity = 0.28
  4253. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  4254. view.layer?.shadowRadius = 10
  4255. return view
  4256. }
  4257. private func settingsDarkModeRow(enabled: Bool) -> NSView {
  4258. let row = NSView()
  4259. row.translatesAutoresizingMaskIntoConstraints = false
  4260. row.heightAnchor.constraint(equalToConstant: 44).isActive = true
  4261. row.wantsLayer = true
  4262. row.layer?.cornerRadius = 10
  4263. let icon = NSTextField(labelWithString: "◐")
  4264. icon.translatesAutoresizingMaskIntoConstraints = false
  4265. icon.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  4266. icon.textColor = palette.textPrimary
  4267. let title = NSTextField(labelWithString: "Dark Mode")
  4268. title.translatesAutoresizingMaskIntoConstraints = false
  4269. title.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  4270. title.textColor = palette.textPrimary
  4271. let toggle = NSSwitch()
  4272. toggle.translatesAutoresizingMaskIntoConstraints = false
  4273. toggle.state = enabled ? .on : .off
  4274. toggle.target = self
  4275. toggle.action = #selector(darkModeToggled(_:))
  4276. darkToggle = toggle
  4277. row.addSubview(icon)
  4278. row.addSubview(title)
  4279. row.addSubview(toggle)
  4280. row.layer?.backgroundColor = NSColor.clear.cgColor
  4281. NSLayoutConstraint.activate([
  4282. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  4283. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4284. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  4285. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4286. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
  4287. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4288. ])
  4289. return row
  4290. }
  4291. private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView {
  4292. let row = HoverButton(title: "", target: self, action: #selector(settingsActionButtonPressed(_:)))
  4293. row.tag = action.rawValue
  4294. row.isBordered = false
  4295. row.translatesAutoresizingMaskIntoConstraints = false
  4296. row.heightAnchor.constraint(equalToConstant: 42).isActive = true
  4297. row.wantsLayer = true
  4298. row.layer?.cornerRadius = 10
  4299. row.layer?.backgroundColor = NSColor.clear.cgColor
  4300. let iconLabel = NSTextField(labelWithString: icon)
  4301. iconLabel.translatesAutoresizingMaskIntoConstraints = false
  4302. iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  4303. iconLabel.textColor = palette.textPrimary
  4304. let titleLabel = NSTextField(labelWithString: title)
  4305. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  4306. titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  4307. titleLabel.textColor = palette.textPrimary
  4308. row.addSubview(iconLabel)
  4309. row.addSubview(titleLabel)
  4310. NSLayoutConstraint.activate([
  4311. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  4312. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4313. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4314. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4315. ])
  4316. row.onHoverChanged = { hovering in
  4317. row.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  4318. }
  4319. row.onHoverChanged?(false)
  4320. return row
  4321. }
  4322. @objc private func darkModeToggled(_ sender: NSSwitch) {
  4323. onToggleDarkMode(sender.state == .on)
  4324. }
  4325. @objc private func settingsActionButtonPressed(_ sender: NSButton) {
  4326. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  4327. let clickPoint: NSPoint?
  4328. if let event = NSApp.currentEvent {
  4329. let pointInWindow = event.locationInWindow
  4330. clickPoint = sender.convert(pointInWindow, from: nil)
  4331. } else {
  4332. clickPoint = nil
  4333. }
  4334. onAction(action, sender, clickPoint)
  4335. }
  4336. }
  4337. private extension ViewController {
  4338. private func requireGoogleLoginForCalendarScheduling() -> Bool {
  4339. guard googleOAuth.loadTokens() != nil else {
  4340. showSimpleAlert(
  4341. title: "Connect Google",
  4342. message: "Sign in with Google first to schedule a meeting from Calendar."
  4343. )
  4344. return false
  4345. }
  4346. return true
  4347. }
  4348. func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  4349. let view = NSView()
  4350. view.wantsLayer = true
  4351. view.layer?.backgroundColor = color.cgColor
  4352. view.layer?.cornerRadius = cornerRadius
  4353. return view
  4354. }
  4355. func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  4356. view.layer?.borderColor = borderColor.cgColor
  4357. view.layer?.borderWidth = borderWidth
  4358. if shadow {
  4359. view.layer?.shadowColor = NSColor.black.cgColor
  4360. view.layer?.shadowOpacity = 0.18
  4361. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  4362. view.layer?.shadowRadius = 5
  4363. }
  4364. }
  4365. func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  4366. let label = NSTextField(labelWithString: text)
  4367. label.translatesAutoresizingMaskIntoConstraints = false
  4368. label.textColor = color
  4369. label.font = font
  4370. return label
  4371. }
  4372. func iconLabel(_ text: String, size: CGFloat) -> NSTextField {
  4373. let label = NSTextField(labelWithString: text)
  4374. label.translatesAutoresizingMaskIntoConstraints = false
  4375. label.font = NSFont.systemFont(ofSize: size)
  4376. return label
  4377. }
  4378. func sidebarSectionTitle(_ text: String) -> NSTextField {
  4379. let field = textLabel(text, font: typography.sidebarSection, color: palette.textMuted)
  4380. field.alignment = .left
  4381. return field
  4382. }
  4383. 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 {
  4384. let item = HoverButton(title: "", target: self, action: #selector(sidebarButtonClicked(_:)))
  4385. item.tag = page.rawValue
  4386. item.isBordered = false
  4387. item.wantsLayer = true
  4388. item.layer?.cornerRadius = 10
  4389. item.layer?.backgroundColor = NSColor.clear.cgColor
  4390. item.translatesAutoresizingMaskIntoConstraints = false
  4391. item.heightAnchor.constraint(equalToConstant: 36).isActive = true
  4392. item.layer?.borderWidth = 0
  4393. sidebarPageByView[ObjectIdentifier(item)] = page
  4394. let leadingView: NSView
  4395. if let name = logoImageName, let logo = NSImage(named: name) {
  4396. logo.isTemplate = true
  4397. let imageView = NSImageView(image: logo)
  4398. imageView.translatesAutoresizingMaskIntoConstraints = false
  4399. imageView.imageScaling = .scaleProportionallyDown
  4400. imageView.imageAlignment = .alignCenter
  4401. imageView.isEditable = false
  4402. leadingView = imageView
  4403. } else if let symbolName = systemSymbolName, let symbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: text) {
  4404. symbol.isTemplate = true
  4405. let imageView = NSImageView(image: symbol)
  4406. imageView.translatesAutoresizingMaskIntoConstraints = false
  4407. imageView.imageScaling = .scaleProportionallyDown
  4408. imageView.imageAlignment = .alignCenter
  4409. imageView.isEditable = false
  4410. leadingView = imageView
  4411. } else {
  4412. leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary)
  4413. }
  4414. let titleLabel = textLabel(text, font: typography.sidebarItem, color: palette.textSecondary)
  4415. titleLabel.alignment = .left
  4416. item.addSubview(leadingView)
  4417. item.addSubview(titleLabel)
  4418. var constraints: [NSLayoutConstraint] = [
  4419. leadingView.leadingAnchor.constraint(equalTo: item.leadingAnchor, constant: 12),
  4420. leadingView.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  4421. titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8),
  4422. titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor)
  4423. ]
  4424. if showsDisclosure {
  4425. let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: palette.textSecondary)
  4426. chevron.translatesAutoresizingMaskIntoConstraints = false
  4427. chevron.alignment = .right
  4428. item.addSubview(chevron)
  4429. constraints.append(contentsOf: [
  4430. chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10),
  4431. chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  4432. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8)
  4433. ])
  4434. } else {
  4435. constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10))
  4436. }
  4437. if logoImageName != nil || systemSymbolName != nil {
  4438. let h = logoIconWidth * logoHeightMultiplier
  4439. constraints.append(contentsOf: [
  4440. leadingView.widthAnchor.constraint(equalToConstant: logoIconWidth),
  4441. leadingView.heightAnchor.constraint(equalToConstant: h)
  4442. ])
  4443. }
  4444. NSLayoutConstraint.activate(constraints)
  4445. applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate)
  4446. item.onHoverChanged = { [weak self, weak item] hovering in
  4447. guard let self, let item else { return }
  4448. self.applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate, hovering: hovering)
  4449. }
  4450. return item
  4451. }
  4452. func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool, hovering: Bool = false) {
  4453. let selected = (page == selectedSidebarPage)
  4454. let hoverColor = darkModeEnabled ? NSColor(calibratedWhite: 1, alpha: 0.07) : NSColor(calibratedWhite: 0, alpha: 0.08)
  4455. item.layer?.backgroundColor = (selected ? palette.primaryBlue : (hovering ? hoverColor : NSColor.clear)).cgColor
  4456. let tint = selected ? NSColor.white : palette.textSecondary
  4457. let sidebarIconTint = darkModeEnabled ? tint : NSColor.black
  4458. guard item.subviews.count >= 2 else { return }
  4459. let leading = item.subviews[0]
  4460. let title = item.subviews.first { $0 is NSTextField } as? NSTextField
  4461. title?.textColor = tint
  4462. // Optional disclosure chevron (if present) is the last text field.
  4463. if let chevron = item.subviews.last as? NSTextField, chevron !== title {
  4464. chevron.textColor = sidebarIconTint
  4465. }
  4466. if let imageView = leading as? NSImageView {
  4467. if logoTemplate {
  4468. imageView.contentTintColor = sidebarIconTint
  4469. }
  4470. } else if let iconField = leading as? NSTextField {
  4471. iconField.textColor = sidebarIconTint
  4472. }
  4473. }
  4474. func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView {
  4475. let button = HoverTrackingView()
  4476. button.wantsLayer = true
  4477. button.layer?.cornerRadius = 9
  4478. button.layer?.backgroundColor = color.cgColor
  4479. button.translatesAutoresizingMaskIntoConstraints = false
  4480. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  4481. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  4482. styleSurface(button, borderColor: title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  4483. if title == "Cancel" {
  4484. button.layer?.backgroundColor = palette.cancelButton.cgColor
  4485. }
  4486. let label = textLabel(title, font: typography.buttonText, color: textColor)
  4487. button.addSubview(label)
  4488. NSLayoutConstraint.activate([
  4489. label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  4490. label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  4491. ])
  4492. let baseColor = (title == "Cancel") ? palette.cancelButton : color
  4493. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  4494. let hoverColor = baseColor.blended(withFraction: 0.12, of: hoverBlend) ?? baseColor
  4495. button.onHoverChanged = { hovering in
  4496. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  4497. }
  4498. button.onHoverChanged?(false)
  4499. return button
  4500. }
  4501. func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
  4502. let button = HoverTrackingView()
  4503. button.wantsLayer = true
  4504. button.layer?.cornerRadius = size / 2
  4505. button.layer?.backgroundColor = palette.inputBackground.cgColor
  4506. button.translatesAutoresizingMaskIntoConstraints = false
  4507. button.widthAnchor.constraint(equalToConstant: size).isActive = true
  4508. button.heightAnchor.constraint(equalToConstant: size).isActive = true
  4509. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4510. let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
  4511. let iconView = NSImageView()
  4512. iconView.translatesAutoresizingMaskIntoConstraints = false
  4513. iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
  4514. iconView.symbolConfiguration = symbolConfig
  4515. iconView.contentTintColor = palette.textSecondary
  4516. button.addSubview(iconView)
  4517. NSLayoutConstraint.activate([
  4518. iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  4519. iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  4520. ])
  4521. let baseColor = palette.inputBackground
  4522. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  4523. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  4524. button.onHoverChanged = { hovering in
  4525. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  4526. }
  4527. button.onHoverChanged?(false)
  4528. button.onClick = onClick
  4529. return button
  4530. }
  4531. }
  4532. private let calendarDayKeyFormatter: DateFormatter = {
  4533. let f = DateFormatter()
  4534. f.calendar = Calendar(identifier: .gregorian)
  4535. f.locale = Locale(identifier: "en_US_POSIX")
  4536. f.timeZone = TimeZone.current
  4537. f.dateFormat = "yyyy-MM-dd"
  4538. return f
  4539. }()
  4540. // MARK: - Calendar page actions + rendering
  4541. private extension ViewController {
  4542. private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton {
  4543. let button = makeSchedulePillButton(title: title)
  4544. button.target = self
  4545. button.action = action
  4546. button.heightAnchor.constraint(equalToConstant: 30).isActive = true
  4547. return button
  4548. }
  4549. private func calendarStartOfMonth(for date: Date) -> Date {
  4550. let calendar = Calendar.current
  4551. let comps = calendar.dateComponents([.year, .month], from: date)
  4552. return calendar.date(from: comps) ?? calendar.startOfDay(for: date)
  4553. }
  4554. private func calendarMonthTitleText(for monthAnchor: Date) -> String {
  4555. let f = DateFormatter()
  4556. f.locale = Locale.current
  4557. f.timeZone = TimeZone.current
  4558. f.dateFormat = "MMMM yyyy"
  4559. return f.string(from: monthAnchor)
  4560. }
  4561. private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] {
  4562. // Align weekday header to Calendar.current.firstWeekday
  4563. let calendar = Calendar.current
  4564. var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"]
  4565. // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday.
  4566. let first = max(1, min(7, calendar.firstWeekday)) // 1..7
  4567. let shift = (first - 1) % 7
  4568. if shift == 0 { return symbols }
  4569. let head = Array(symbols[shift...])
  4570. let tail = Array(symbols[..<shift])
  4571. symbols = head + tail
  4572. return symbols
  4573. }
  4574. @objc func calendarPrevMonthPressed(_ sender: NSButton) {
  4575. let calendar = Calendar.current
  4576. calendarPageMonthAnchor = calendar.date(byAdding: .month, value: -1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
  4577. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  4578. renderCalendarMonthGrid()
  4579. }
  4580. @objc func calendarNextMonthPressed(_ sender: NSButton) {
  4581. let calendar = Calendar.current
  4582. calendarPageMonthAnchor = calendar.date(byAdding: .month, value: 1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
  4583. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  4584. renderCalendarMonthGrid()
  4585. }
  4586. @objc func calendarRefreshPressed(_ sender: NSButton) {
  4587. Task { [weak self] in
  4588. await self?.loadSchedule()
  4589. }
  4590. }
  4591. @objc func calendarDayCellPressed(_ sender: NSButton) {
  4592. guard requireGoogleLoginForCalendarScheduling() else { return }
  4593. guard let raw = sender.identifier?.rawValue,
  4594. let date = calendarDayKeyFormatter.date(from: raw) else { return }
  4595. calendarPageSelectedDate = Calendar.current.startOfDay(for: date)
  4596. renderCalendarMonthGrid()
  4597. renderCalendarSelectedDay()
  4598. if let refreshedButton = calendarButton(forDateKey: raw) {
  4599. showCalendarDayActionPopover(relativeTo: refreshedButton)
  4600. }
  4601. }
  4602. private func showCalendarDayActionPopover(relativeTo anchor: NSView) {
  4603. guard anchor.window != nil else { return }
  4604. calendarPageActionPopover?.performClose(nil)
  4605. let popover = NSPopover()
  4606. popover.behavior = .transient
  4607. popover.animates = true
  4608. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  4609. popover.contentViewController = CalendarDayActionMenuViewController(
  4610. palette: palette,
  4611. onSchedule: { [weak self] in
  4612. self?.calendarPageActionPopover?.performClose(nil)
  4613. self?.calendarPageActionPopover = nil
  4614. self?.presentCreateMeetingPopover(relativeTo: anchor)
  4615. }
  4616. )
  4617. calendarPageActionPopover = popover
  4618. popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
  4619. }
  4620. private func presentCreateMeetingPopover(relativeTo anchor: NSView) {
  4621. guard anchor.window != nil else { return }
  4622. calendarPageCreatePopover?.performClose(nil)
  4623. let popover = NSPopover()
  4624. popover.behavior = .transient
  4625. popover.animates = true
  4626. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  4627. let selectedDate = Calendar.current.startOfDay(for: calendarPageSelectedDate)
  4628. let vc = CreateMeetingPopoverViewController(
  4629. palette: palette,
  4630. typography: typography,
  4631. selectedDate: selectedDate,
  4632. onCancel: { [weak self] in
  4633. self?.calendarPageCreatePopover?.performClose(nil)
  4634. self?.calendarPageCreatePopover = nil
  4635. },
  4636. onSave: { [weak self] draft in
  4637. guard let self else { return }
  4638. self.calendarPageCreatePopover?.performClose(nil)
  4639. self.calendarPageCreatePopover = nil
  4640. self.calendarCreateMeeting(title: draft.title, notes: draft.notes, start: draft.startDate, end: draft.endDate)
  4641. }
  4642. )
  4643. popover.contentViewController = vc
  4644. calendarPageCreatePopover = popover
  4645. popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
  4646. }
  4647. private func calendarCreateMeeting(title: String, notes: String?, start: Date, end: Date) {
  4648. Task { [weak self] in
  4649. guard let self else { return }
  4650. do {
  4651. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  4652. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  4653. _ = try await self.calendarClient.createEvent(
  4654. accessToken: token,
  4655. title: title,
  4656. description: notes,
  4657. start: start,
  4658. end: end,
  4659. timeZone: .current
  4660. )
  4661. await self.loadSchedule()
  4662. await MainActor.run {
  4663. let calendar = Calendar.current
  4664. self.calendarPageSelectedDate = calendar.startOfDay(for: start)
  4665. self.calendarPageMonthAnchor = self.calendarStartOfMonth(for: start)
  4666. self.calendarPageMonthLabel?.stringValue = self.calendarMonthTitleText(for: self.calendarPageMonthAnchor)
  4667. self.renderCalendarMonthGrid()
  4668. self.renderCalendarSelectedDay()
  4669. self.showTopToast(message: "Meeting added successfully.", isError: false)
  4670. }
  4671. } catch {
  4672. await MainActor.run {
  4673. self.showTopToast(message: "An issue occurred. Please try again.", isError: true)
  4674. }
  4675. }
  4676. }
  4677. }
  4678. private func renderCalendarMonthGrid() {
  4679. guard let gridStack = calendarPageGridStack else { return }
  4680. gridStack.arrangedSubviews.forEach { v in
  4681. gridStack.removeArrangedSubview(v)
  4682. v.removeFromSuperview()
  4683. }
  4684. let calendar = Calendar.current
  4685. let monthStart = calendarStartOfMonth(for: calendarPageMonthAnchor)
  4686. guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart),
  4687. let monthEnd = calendar.date(byAdding: DateComponents(month: 1, day: 0), to: monthStart) else { return }
  4688. let firstWeekday = calendar.component(.weekday, from: monthStart) // 1..7
  4689. let leadingEmpty = (firstWeekday - calendar.firstWeekday + 7) % 7
  4690. let totalDays = dayRange.count
  4691. let totalCells = leadingEmpty + totalDays
  4692. let rowCount = Int(ceil(Double(totalCells) / 7.0))
  4693. let rowHeight: CGFloat = 56
  4694. let rowSpacing: CGFloat = 12
  4695. let verticalPadding: CGFloat = 32
  4696. calendarPageGridHeightConstraint?.constant = verticalPadding + (CGFloat(rowCount) * rowHeight) + (CGFloat(max(0, rowCount - 1)) * rowSpacing)
  4697. let meetingCounts = calendarMeetingCountsByDay(from: scheduleCachedMeetings, monthStart: monthStart, monthEnd: monthEnd)
  4698. var day = 1
  4699. for _ in 0..<rowCount {
  4700. let row = NSStackView()
  4701. row.translatesAutoresizingMaskIntoConstraints = false
  4702. row.userInterfaceLayoutDirection = .leftToRight
  4703. row.orientation = .horizontal
  4704. row.alignment = .top
  4705. row.distribution = .fillEqually
  4706. row.spacing = rowSpacing
  4707. row.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true
  4708. for col in 0..<7 {
  4709. let cellIndex = (gridStack.arrangedSubviews.count * 7) + col
  4710. if cellIndex < leadingEmpty || day > totalDays {
  4711. row.addArrangedSubview(calendarEmptyDayCell())
  4712. continue
  4713. }
  4714. guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else {
  4715. row.addArrangedSubview(calendarEmptyDayCell())
  4716. continue
  4717. }
  4718. let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate)
  4719. let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date))
  4720. let count = meetingCounts[key] ?? 0
  4721. row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected))
  4722. day += 1
  4723. }
  4724. gridStack.addArrangedSubview(row)
  4725. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  4726. }
  4727. }
  4728. private func calendarButton(forDateKey key: String) -> NSButton? {
  4729. guard let gridStack = calendarPageGridStack else { return nil }
  4730. for rowView in gridStack.arrangedSubviews {
  4731. guard let row = rowView as? NSStackView else { continue }
  4732. for cell in row.arrangedSubviews {
  4733. if let button = cell as? NSButton, button.identifier?.rawValue == key {
  4734. return button
  4735. }
  4736. }
  4737. }
  4738. return nil
  4739. }
  4740. private func renderCalendarSelectedDay() {
  4741. let calendar = Calendar.current
  4742. let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate)
  4743. let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400)
  4744. let meetings = scheduleCachedMeetings
  4745. .filter { $0.startDate >= selectedDay && $0.startDate < nextDay }
  4746. .sorted(by: { $0.startDate < $1.startDate })
  4747. let f = DateFormatter()
  4748. f.locale = Locale.current
  4749. f.timeZone = TimeZone.current
  4750. f.dateFormat = "EEE, d MMM"
  4751. if meetings.isEmpty {
  4752. calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
  4753. ? "Connect Google to see meetings"
  4754. : "No meetings on \(f.string(from: selectedDay))"
  4755. } else if meetings.count == 1 {
  4756. calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))"
  4757. } else {
  4758. calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))"
  4759. }
  4760. }
  4761. private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] {
  4762. let calendar = Calendar.current
  4763. var counts: [String: Int] = [:]
  4764. for meeting in meetings {
  4765. guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue }
  4766. let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate))
  4767. counts[key, default: 0] += 1
  4768. }
  4769. return counts
  4770. }
  4771. private func calendarEmptyDayCell() -> NSView {
  4772. let v = NSView()
  4773. v.translatesAutoresizingMaskIntoConstraints = false
  4774. v.heightAnchor.constraint(equalToConstant: 56).isActive = true
  4775. return v
  4776. }
  4777. private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton {
  4778. let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:)))
  4779. button.translatesAutoresizingMaskIntoConstraints = false
  4780. button.isBordered = false
  4781. button.bezelStyle = .regularSquare
  4782. button.wantsLayer = true
  4783. button.layer?.cornerRadius = 12
  4784. button.layer?.masksToBounds = true
  4785. button.identifier = NSUserInterfaceItemIdentifier(dateKey)
  4786. button.heightAnchor.constraint(equalToConstant: 56).isActive = true
  4787. button.setContentHuggingPriority(.required, for: .vertical)
  4788. button.setContentCompressionResistancePriority(.required, for: .vertical)
  4789. button.alignment = .left
  4790. button.imagePosition = .noImage
  4791. let base = palette.inputBackground
  4792. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  4793. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  4794. let selectedBackground = darkModeEnabled
  4795. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  4796. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  4797. let borderIdle = palette.inputBorder
  4798. let borderSelected = palette.primaryBlueBorder
  4799. func applyAppearance(hovering: Bool) {
  4800. button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor
  4801. button.layer?.borderWidth = isSelected ? 1.5 : 1
  4802. button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor
  4803. }
  4804. applyAppearance(hovering: false)
  4805. button.onHoverChanged = { hovering in
  4806. applyAppearance(hovering: hovering)
  4807. }
  4808. button.attributedTitle = NSAttributedString(
  4809. string: " \(dayNumber)",
  4810. attributes: [
  4811. .font: NSFont.systemFont(ofSize: 14, weight: .bold),
  4812. .foregroundColor: palette.textPrimary
  4813. ]
  4814. )
  4815. if meetingCount > 0 {
  4816. let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge)
  4817. dot.translatesAutoresizingMaskIntoConstraints = false
  4818. dot.layer?.borderWidth = 0
  4819. button.addSubview(dot)
  4820. NSLayoutConstraint.activate([
  4821. dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10),
  4822. dot.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  4823. dot.widthAnchor.constraint(equalToConstant: 8),
  4824. dot.heightAnchor.constraint(equalToConstant: 8)
  4825. ])
  4826. }
  4827. return button
  4828. }
  4829. }
  4830. private final class CalendarDayActionMenuViewController: NSViewController {
  4831. private let palette: Palette
  4832. private let onSchedule: () -> Void
  4833. init(palette: Palette, onSchedule: @escaping () -> Void) {
  4834. self.palette = palette
  4835. self.onSchedule = onSchedule
  4836. super.init(nibName: nil, bundle: nil)
  4837. }
  4838. required init?(coder: NSCoder) { nil }
  4839. override func loadView() {
  4840. let root = NSView()
  4841. root.translatesAutoresizingMaskIntoConstraints = false
  4842. let stack = NSStackView()
  4843. stack.translatesAutoresizingMaskIntoConstraints = false
  4844. stack.orientation = .vertical
  4845. stack.alignment = .leading
  4846. stack.spacing = 10
  4847. let title = NSTextField(labelWithString: "Actions")
  4848. title.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  4849. title.textColor = palette.textMuted
  4850. let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:)))
  4851. schedule.bezelStyle = .rounded
  4852. schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium)
  4853. stack.addArrangedSubview(title)
  4854. stack.addArrangedSubview(schedule)
  4855. root.addSubview(stack)
  4856. NSLayoutConstraint.activate([
  4857. root.widthAnchor.constraint(equalToConstant: 220),
  4858. root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86),
  4859. stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
  4860. stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
  4861. stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  4862. stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12)
  4863. ])
  4864. view = root
  4865. }
  4866. @objc private func schedulePressed(_ sender: NSButton) {
  4867. onSchedule()
  4868. }
  4869. }
  4870. private final class CreateMeetingPopoverViewController: NSViewController, NSTextViewDelegate {
  4871. struct Draft {
  4872. let title: String
  4873. let notes: String?
  4874. let startDate: Date
  4875. let endDate: Date
  4876. }
  4877. private let palette: Palette
  4878. private let typography: Typography
  4879. private let selectedDate: Date
  4880. private let onCancel: () -> Void
  4881. private let onSave: (Draft) -> Void
  4882. private var titleField: NSTextField?
  4883. private var timePicker: NSDatePicker?
  4884. private var durationField: NSTextField?
  4885. private var notesView: NSTextView?
  4886. private var notesScrollView: NSScrollView?
  4887. private var errorLabel: NSTextField?
  4888. private var notesBorderIdle = NSColor.clear
  4889. private var notesBorderHover = NSColor.clear
  4890. private var notesBorderFocused = NSColor.clear
  4891. private var notesIsHovered = false
  4892. private var notesIsFocused = false
  4893. init(palette: Palette,
  4894. typography: Typography,
  4895. selectedDate: Date,
  4896. onCancel: @escaping () -> Void,
  4897. onSave: @escaping (Draft) -> Void) {
  4898. self.palette = palette
  4899. self.typography = typography
  4900. self.selectedDate = selectedDate
  4901. self.onCancel = onCancel
  4902. self.onSave = onSave
  4903. super.init(nibName: nil, bundle: nil)
  4904. }
  4905. required init?(coder: NSCoder) { nil }
  4906. override func loadView() {
  4907. let root = NSView()
  4908. root.translatesAutoresizingMaskIntoConstraints = false
  4909. root.userInterfaceLayoutDirection = .leftToRight
  4910. root.wantsLayer = true
  4911. root.layer?.cornerRadius = 14
  4912. root.layer?.masksToBounds = true
  4913. root.layer?.backgroundColor = palette.sectionCard.cgColor
  4914. root.layer?.borderWidth = 1
  4915. root.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.9).cgColor
  4916. let stack = NSStackView()
  4917. stack.translatesAutoresizingMaskIntoConstraints = false
  4918. stack.orientation = .vertical
  4919. stack.alignment = .leading
  4920. stack.spacing = 14
  4921. stack.userInterfaceLayoutDirection = .leftToRight
  4922. let inputSurface = palette.inputBackground.blended(withFraction: 0.18, of: palette.sectionCard) ?? palette.inputBackground
  4923. let fieldBorder = palette.textSecondary.withAlphaComponent(0.4)
  4924. notesBorderIdle = fieldBorder
  4925. notesBorderHover = palette.textSecondary.withAlphaComponent(0.72)
  4926. notesBorderFocused = palette.primaryBlueBorder
  4927. let header = NSTextField(labelWithString: "Schedule meeting")
  4928. header.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  4929. header.textColor = palette.textPrimary
  4930. header.alignment = .left
  4931. header.userInterfaceLayoutDirection = .leftToRight
  4932. header.baseWritingDirection = .leftToRight
  4933. let titleLabel = NSTextField(labelWithString: "Title")
  4934. titleLabel.font = typography.fieldLabel
  4935. titleLabel.textColor = palette.textSecondary
  4936. titleLabel.alignment = .left
  4937. titleLabel.userInterfaceLayoutDirection = .leftToRight
  4938. titleLabel.baseWritingDirection = .leftToRight
  4939. let titleShell = NSView()
  4940. titleShell.translatesAutoresizingMaskIntoConstraints = false
  4941. titleShell.wantsLayer = true
  4942. titleShell.layer?.cornerRadius = 8
  4943. titleShell.layer?.backgroundColor = inputSurface.cgColor
  4944. titleShell.layer?.borderColor = fieldBorder.cgColor
  4945. titleShell.layer?.borderWidth = 1.2
  4946. titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true
  4947. let titleField = NSTextField(string: "")
  4948. titleField.translatesAutoresizingMaskIntoConstraints = false
  4949. titleField.isBordered = false
  4950. titleField.drawsBackground = false
  4951. titleField.focusRingType = .none
  4952. titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular)
  4953. titleField.textColor = palette.textPrimary
  4954. titleField.placeholderString = "Team sync"
  4955. titleShell.addSubview(titleField)
  4956. NSLayoutConstraint.activate([
  4957. titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10),
  4958. titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10),
  4959. titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor)
  4960. ])
  4961. self.titleField = titleField
  4962. let timeRow = NSStackView()
  4963. timeRow.translatesAutoresizingMaskIntoConstraints = false
  4964. timeRow.orientation = .horizontal
  4965. timeRow.alignment = .centerY
  4966. timeRow.spacing = 10
  4967. timeRow.distribution = .fill
  4968. let startLabel = NSTextField(labelWithString: "Start")
  4969. startLabel.font = typography.fieldLabel
  4970. startLabel.textColor = palette.textSecondary
  4971. startLabel.alignment = .left
  4972. startLabel.userInterfaceLayoutDirection = .leftToRight
  4973. startLabel.baseWritingDirection = .leftToRight
  4974. let pickerShell = NSView()
  4975. pickerShell.translatesAutoresizingMaskIntoConstraints = false
  4976. pickerShell.wantsLayer = true
  4977. pickerShell.layer?.cornerRadius = 8
  4978. pickerShell.layer?.backgroundColor = inputSurface.cgColor
  4979. pickerShell.layer?.borderColor = fieldBorder.cgColor
  4980. pickerShell.layer?.borderWidth = 1.2
  4981. pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
  4982. let timePicker = NSDatePicker()
  4983. timePicker.translatesAutoresizingMaskIntoConstraints = false
  4984. timePicker.isBordered = false
  4985. timePicker.drawsBackground = false
  4986. timePicker.focusRingType = .none
  4987. timePicker.datePickerStyle = .textFieldAndStepper
  4988. timePicker.datePickerElements = [.hourMinute]
  4989. timePicker.font = typography.filterText
  4990. timePicker.textColor = palette.textSecondary
  4991. timePicker.dateValue = Date()
  4992. pickerShell.addSubview(timePicker)
  4993. NSLayoutConstraint.activate([
  4994. timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8),
  4995. timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8),
  4996. timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor)
  4997. ])
  4998. self.timePicker = timePicker
  4999. let durationLabel = NSTextField(labelWithString: "Duration (min)")
  5000. durationLabel.font = typography.fieldLabel
  5001. durationLabel.textColor = palette.textSecondary
  5002. durationLabel.alignment = .left
  5003. durationLabel.userInterfaceLayoutDirection = .leftToRight
  5004. durationLabel.baseWritingDirection = .leftToRight
  5005. let durationShell = NSView()
  5006. durationShell.translatesAutoresizingMaskIntoConstraints = false
  5007. durationShell.wantsLayer = true
  5008. durationShell.layer?.cornerRadius = 8
  5009. durationShell.layer?.backgroundColor = inputSurface.cgColor
  5010. durationShell.layer?.borderColor = fieldBorder.cgColor
  5011. durationShell.layer?.borderWidth = 1.2
  5012. durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
  5013. let durationField = NSTextField(string: "30")
  5014. durationField.translatesAutoresizingMaskIntoConstraints = false
  5015. durationField.isBordered = false
  5016. durationField.drawsBackground = false
  5017. durationField.focusRingType = .none
  5018. durationField.font = typography.filterText
  5019. durationField.textColor = palette.textSecondary
  5020. durationField.formatter = NumberFormatter()
  5021. durationShell.addSubview(durationField)
  5022. NSLayoutConstraint.activate([
  5023. durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8),
  5024. durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8),
  5025. durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor)
  5026. ])
  5027. self.durationField = durationField
  5028. let startGroup = NSStackView(views: [startLabel, pickerShell])
  5029. startGroup.translatesAutoresizingMaskIntoConstraints = false
  5030. startGroup.orientation = .vertical
  5031. startGroup.alignment = .leading
  5032. startGroup.spacing = 6
  5033. let durationGroup = NSStackView(views: [durationLabel, durationShell])
  5034. durationGroup.translatesAutoresizingMaskIntoConstraints = false
  5035. durationGroup.orientation = .vertical
  5036. durationGroup.alignment = .leading
  5037. durationGroup.spacing = 6
  5038. timeRow.addArrangedSubview(startGroup)
  5039. timeRow.addArrangedSubview(durationGroup)
  5040. startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true
  5041. let notesLabel = NSTextField(labelWithString: "Notes")
  5042. notesLabel.font = typography.fieldLabel
  5043. notesLabel.textColor = palette.textSecondary
  5044. notesLabel.alignment = .left
  5045. notesLabel.userInterfaceLayoutDirection = .leftToRight
  5046. notesLabel.baseWritingDirection = .leftToRight
  5047. let notesScroll = HoverFocusScrollView()
  5048. notesScroll.translatesAutoresizingMaskIntoConstraints = false
  5049. notesScroll.drawsBackground = true
  5050. notesScroll.backgroundColor = inputSurface
  5051. notesScroll.hasVerticalScroller = true
  5052. notesScroll.hasHorizontalScroller = false
  5053. notesScroll.borderType = .noBorder
  5054. notesScroll.wantsLayer = true
  5055. notesScroll.layer?.cornerRadius = 8
  5056. notesScroll.layer?.masksToBounds = true
  5057. notesScroll.layer?.borderWidth = 1.2
  5058. notesScroll.layer?.borderColor = notesBorderIdle.cgColor
  5059. notesScroll.heightAnchor.constraint(equalToConstant: 100).isActive = true
  5060. notesScroll.onHoverChanged = { [weak self] hovering in
  5061. guard let self else { return }
  5062. self.notesIsHovered = hovering
  5063. self.updateNotesBorderAppearance()
  5064. }
  5065. notesScroll.onMouseDown = { [weak self] in
  5066. guard let self, let notesView = self.notesView as? ImmediateFocusTextView else { return }
  5067. self.view.window?.makeFirstResponder(notesView)
  5068. notesView.ensureCaretVisibleImmediately()
  5069. self.notesIsFocused = true
  5070. self.updateNotesBorderAppearance()
  5071. }
  5072. let notesView = ImmediateFocusTextView(frame: .zero)
  5073. notesView.drawsBackground = false
  5074. notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  5075. notesView.textColor = palette.textPrimary
  5076. notesView.insertionPointColor = palette.textPrimary
  5077. notesView.isEditable = true
  5078. notesView.isSelectable = true
  5079. notesView.isRichText = false
  5080. notesView.importsGraphics = false
  5081. notesView.isHorizontallyResizable = false
  5082. notesView.isVerticallyResizable = true
  5083. notesView.autoresizingMask = [.width]
  5084. notesView.textContainerInset = NSSize(width: 6, height: 6)
  5085. notesView.textContainer?.widthTracksTextView = true
  5086. notesView.textContainer?.containerSize = NSSize(
  5087. width: notesScroll.contentSize.width,
  5088. height: CGFloat.greatestFiniteMagnitude
  5089. )
  5090. notesView.delegate = self
  5091. notesScroll.documentView = notesView
  5092. self.notesView = notesView
  5093. self.notesScrollView = notesScroll
  5094. let error = NSTextField(labelWithString: "")
  5095. error.translatesAutoresizingMaskIntoConstraints = false
  5096. error.textColor = NSColor.systemRed
  5097. error.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  5098. error.isHidden = true
  5099. self.errorLabel = error
  5100. let actions = NSStackView()
  5101. actions.translatesAutoresizingMaskIntoConstraints = false
  5102. actions.orientation = .horizontal
  5103. actions.alignment = .centerY
  5104. actions.spacing = 10
  5105. let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:)))
  5106. cancel.bezelStyle = .rounded
  5107. let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:)))
  5108. save.bezelStyle = .rounded
  5109. save.keyEquivalent = "\r"
  5110. let actionsSpacer = NSView()
  5111. actionsSpacer.translatesAutoresizingMaskIntoConstraints = false
  5112. actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  5113. actions.addArrangedSubview(cancel)
  5114. actions.addArrangedSubview(actionsSpacer)
  5115. actions.addArrangedSubview(save)
  5116. stack.addArrangedSubview(header)
  5117. stack.addArrangedSubview(titleLabel)
  5118. stack.addArrangedSubview(titleShell)
  5119. stack.addArrangedSubview(timeRow)
  5120. stack.addArrangedSubview(notesLabel)
  5121. stack.addArrangedSubview(notesScroll)
  5122. stack.addArrangedSubview(error)
  5123. stack.addArrangedSubview(actions)
  5124. root.addSubview(stack)
  5125. NSLayoutConstraint.activate([
  5126. root.widthAnchor.constraint(equalToConstant: 372),
  5127. stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  5128. stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  5129. stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 16),
  5130. stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16),
  5131. titleShell.widthAnchor.constraint(equalTo: stack.widthAnchor),
  5132. timeRow.widthAnchor.constraint(equalTo: stack.widthAnchor),
  5133. notesScroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
  5134. error.widthAnchor.constraint(equalTo: stack.widthAnchor),
  5135. actions.widthAnchor.constraint(equalTo: stack.widthAnchor)
  5136. ])
  5137. view = root
  5138. }
  5139. @objc private func cancelPressed(_ sender: NSButton) {
  5140. onCancel()
  5141. }
  5142. @objc private func savePressed(_ sender: NSButton) {
  5143. setError(nil)
  5144. let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  5145. if title.isEmpty {
  5146. setError("Please enter a title.")
  5147. return
  5148. }
  5149. let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  5150. let durationMinutes = Int(durationText) ?? 0
  5151. if durationMinutes <= 0 {
  5152. setError("Duration must be a positive number of minutes.")
  5153. return
  5154. }
  5155. let time = timePicker?.dateValue ?? Date()
  5156. let calendar = Calendar.current
  5157. let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate)
  5158. let timeParts = calendar.dateComponents([.hour, .minute], from: time)
  5159. var merged = DateComponents()
  5160. merged.year = dateParts.year
  5161. merged.month = dateParts.month
  5162. merged.day = dateParts.day
  5163. merged.hour = timeParts.hour
  5164. merged.minute = timeParts.minute
  5165. guard let start = calendar.date(from: merged) else {
  5166. setError("Invalid start time.")
  5167. return
  5168. }
  5169. let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60))
  5170. let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines)
  5171. let cleanedNotes = (notes?.isEmpty == false) ? notes : nil
  5172. onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end))
  5173. }
  5174. private func updateNotesBorderAppearance() {
  5175. let color: NSColor
  5176. let width: CGFloat
  5177. if notesIsFocused {
  5178. color = notesBorderFocused
  5179. width = 1.6
  5180. } else if notesIsHovered {
  5181. color = notesBorderHover
  5182. width = 1.4
  5183. } else {
  5184. color = notesBorderIdle
  5185. width = 1.2
  5186. }
  5187. notesScrollView?.layer?.borderColor = color.cgColor
  5188. notesScrollView?.layer?.borderWidth = width
  5189. }
  5190. private func setError(_ message: String?) {
  5191. guard let errorLabel else { return }
  5192. errorLabel.stringValue = message ?? ""
  5193. errorLabel.isHidden = message == nil
  5194. }
  5195. func textDidBeginEditing(_ notification: Notification) {
  5196. guard let current = notification.object as? NSTextView, current === notesView else { return }
  5197. notesIsFocused = true
  5198. updateNotesBorderAppearance()
  5199. }
  5200. func textDidEndEditing(_ notification: Notification) {
  5201. guard let current = notification.object as? NSTextView, current === notesView else { return }
  5202. notesIsFocused = false
  5203. updateNotesBorderAppearance()
  5204. }
  5205. }
  5206. private final class HoverFocusScrollView: NSScrollView {
  5207. var onHoverChanged: ((Bool) -> Void)?
  5208. var onMouseDown: (() -> Void)?
  5209. private var hoverTrackingArea: NSTrackingArea?
  5210. override func updateTrackingAreas() {
  5211. super.updateTrackingAreas()
  5212. if let hoverTrackingArea {
  5213. removeTrackingArea(hoverTrackingArea)
  5214. }
  5215. let area = NSTrackingArea(
  5216. rect: bounds,
  5217. options: [.activeInActiveApp, .inVisibleRect, .mouseEnteredAndExited],
  5218. owner: self,
  5219. userInfo: nil
  5220. )
  5221. addTrackingArea(area)
  5222. hoverTrackingArea = area
  5223. }
  5224. override func mouseEntered(with event: NSEvent) {
  5225. super.mouseEntered(with: event)
  5226. onHoverChanged?(true)
  5227. }
  5228. override func mouseExited(with event: NSEvent) {
  5229. super.mouseExited(with: event)
  5230. onHoverChanged?(false)
  5231. }
  5232. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  5233. true
  5234. }
  5235. override func mouseDown(with event: NSEvent) {
  5236. onMouseDown?()
  5237. if let window, !window.isKeyWindow {
  5238. window.makeKeyAndOrderFront(nil)
  5239. return
  5240. }
  5241. // Forward the click straight to the text view so caret placement/blink starts immediately.
  5242. if let textView = documentView as? NSTextView {
  5243. window?.makeFirstResponder(textView)
  5244. textView.mouseDown(with: event)
  5245. return
  5246. }
  5247. super.mouseDown(with: event)
  5248. }
  5249. }
  5250. private final class ImmediateFocusTextView: NSTextView {
  5251. override var acceptsFirstResponder: Bool { true }
  5252. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  5253. true
  5254. }
  5255. @discardableResult
  5256. override func becomeFirstResponder() -> Bool {
  5257. let accepted = super.becomeFirstResponder()
  5258. if accepted {
  5259. ensureCaretVisibleImmediately()
  5260. }
  5261. return accepted
  5262. }
  5263. override func mouseDown(with event: NSEvent) {
  5264. window?.makeFirstResponder(self)
  5265. super.mouseDown(with: event)
  5266. ensureCaretVisibleImmediately()
  5267. }
  5268. func ensureCaretVisibleImmediately() {
  5269. var range = selectedRange()
  5270. if range.location == NSNotFound {
  5271. range = NSRange(location: string.utf16.count, length: 0)
  5272. }
  5273. setSelectedRange(range)
  5274. scrollRangeToVisible(range)
  5275. needsDisplay = true
  5276. displayIfNeeded()
  5277. }
  5278. }
  5279. // MARK: - Schedule actions (OAuth entry)
  5280. private extension ViewController {
  5281. @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
  5282. scheduleReloadClicked()
  5283. }
  5284. @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
  5285. scrollScheduleCards(direction: -1)
  5286. }
  5287. @objc func scheduleScrollRightPressed(_ sender: NSButton) {
  5288. scrollScheduleCards(direction: 1)
  5289. }
  5290. @objc func scheduleCardButtonPressed(_ sender: NSButton) {
  5291. guard storeKitCoordinator.hasPremiumAccess else {
  5292. showPaywall()
  5293. return
  5294. }
  5295. guard let raw = sender.identifier?.rawValue,
  5296. let url = URL(string: raw) else { return }
  5297. openMeetingURL(url)
  5298. }
  5299. @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
  5300. scheduleConnectClicked()
  5301. }
  5302. @objc func schedulePageLoadMorePressed(_ sender: NSButton) {
  5303. appendSchedulePageBatchIfNeeded()
  5304. }
  5305. private func scheduleInitialHeadingText() -> String {
  5306. googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
  5307. }
  5308. private func schedulePageInitialHeadingText() -> String {
  5309. googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading schedule…"
  5310. }
  5311. @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
  5312. guard let selectedItem = sender.selectedItem,
  5313. let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
  5314. applyScheduleFilter(filter)
  5315. }
  5316. private func applyScheduleFilter(_ filter: ScheduleFilter) {
  5317. scheduleFilter = filter
  5318. scheduleFilterDropdown?.selectItem(at: filter.rawValue)
  5319. Task { [weak self] in
  5320. await self?.loadSchedule()
  5321. }
  5322. }
  5323. @objc func schedulePageFilterDropdownChanged(_ sender: NSPopUpButton) {
  5324. guard let selectedItem = sender.selectedItem,
  5325. let filter = SchedulePageFilter(rawValue: selectedItem.tag) else { return }
  5326. schedulePageFilter = filter
  5327. refreshSchedulePageDateFilterUI()
  5328. applySchedulePageFiltersAndRender()
  5329. }
  5330. @objc func schedulePageDatePickerChanged(_ sender: NSDatePicker) {
  5331. schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
  5332. schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
  5333. }
  5334. @objc func schedulePageApplyDateRangePressed(_ sender: NSButton) {
  5335. schedulePageFilter = .customRange
  5336. schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.customRange.rawValue)
  5337. refreshSchedulePageDateFilterUI()
  5338. applySchedulePageFiltersAndRender()
  5339. }
  5340. @objc func schedulePageResetFiltersPressed(_ sender: NSButton) {
  5341. schedulePageFilter = .all
  5342. schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.all.rawValue)
  5343. let today = Calendar.current.startOfDay(for: Date())
  5344. schedulePageFromDate = today
  5345. schedulePageToDate = today
  5346. schedulePageFromDatePicker?.dateValue = today
  5347. schedulePageToDatePicker?.dateValue = today
  5348. refreshSchedulePageDateFilterUI()
  5349. applySchedulePageFiltersAndRender()
  5350. }
  5351. @objc func schedulePageAddMeetingPressed(_ sender: NSButton) {
  5352. guard requireGoogleLoginForCalendarScheduling() else { return }
  5353. let accessory = NSView(frame: NSRect(x: 0, y: 0, width: 230, height: 28))
  5354. let datePicker = NSDatePicker(frame: accessory.bounds)
  5355. datePicker.autoresizingMask = [.width, .height]
  5356. datePicker.datePickerMode = .single
  5357. datePicker.datePickerStyle = .textFieldAndStepper
  5358. datePicker.datePickerElements = [.yearMonthDay]
  5359. datePicker.dateValue = Calendar.current.startOfDay(for: Date())
  5360. accessory.addSubview(datePicker)
  5361. let alert = NSAlert()
  5362. alert.messageText = "Select date"
  5363. alert.informativeText = "Choose a date to schedule your meeting."
  5364. alert.alertStyle = .informational
  5365. alert.accessoryView = accessory
  5366. alert.addButton(withTitle: "Continue")
  5367. alert.addButton(withTitle: "Cancel")
  5368. guard alert.runModal() == .alertFirstButtonReturn else { return }
  5369. calendarPageSelectedDate = Calendar.current.startOfDay(for: datePicker.dateValue)
  5370. presentCreateMeetingPopover(relativeTo: sender)
  5371. }
  5372. private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
  5373. if meeting.isAllDay { return "All day" }
  5374. let f = DateFormatter()
  5375. f.locale = Locale.current
  5376. f.timeZone = TimeZone.current
  5377. f.dateStyle = .none
  5378. f.timeStyle = .short
  5379. return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
  5380. }
  5381. private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
  5382. let f = DateFormatter()
  5383. f.locale = Locale.current
  5384. f.timeZone = TimeZone.current
  5385. f.dateFormat = "EEE, d MMM"
  5386. return f.string(from: meeting.startDate)
  5387. }
  5388. private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
  5389. if meeting.isAllDay { return "Duration: all day" }
  5390. let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
  5391. let totalMinutes = Int(duration / 60)
  5392. let hours = totalMinutes / 60
  5393. let minutes = totalMinutes % 60
  5394. if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
  5395. if hours > 0 { return "Duration: \(hours)h" }
  5396. return "Duration: \(minutes)m"
  5397. }
  5398. private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
  5399. guard let first = meetings.first else {
  5400. return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
  5401. }
  5402. let day = Calendar.current.startOfDay(for: first.startDate)
  5403. let f = DateFormatter()
  5404. f.locale = Locale.current
  5405. f.timeZone = TimeZone.current
  5406. f.dateFormat = "EEEE, d MMM"
  5407. return f.string(from: day)
  5408. }
  5409. private func openMeetingURL(_ url: URL) {
  5410. NSWorkspace.shared.open(url)
  5411. }
  5412. private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
  5413. displayedScheduleMeetings = meetings
  5414. let shouldShowScrollControls = meetings.count > 3
  5415. scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
  5416. scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
  5417. scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
  5418. if let scroll = scheduleCardsScrollView {
  5419. scroll.reflectScrolledClipView(scroll.contentView)
  5420. }
  5421. stack.arrangedSubviews.forEach { v in
  5422. stack.removeArrangedSubview(v)
  5423. v.removeFromSuperview()
  5424. }
  5425. if meetings.isEmpty {
  5426. let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
  5427. empty.translatesAutoresizingMaskIntoConstraints = false
  5428. empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
  5429. empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
  5430. styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5431. let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
  5432. label.translatesAutoresizingMaskIntoConstraints = false
  5433. empty.addSubview(label)
  5434. NSLayoutConstraint.activate([
  5435. label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
  5436. label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
  5437. ])
  5438. stack.addArrangedSubview(empty)
  5439. return
  5440. }
  5441. for meeting in meetings {
  5442. stack.addArrangedSubview(scheduleCard(meeting: meeting))
  5443. }
  5444. }
  5445. private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
  5446. switch scheduleFilter {
  5447. case .all:
  5448. return meetings
  5449. case .today:
  5450. let start = Calendar.current.startOfDay(for: Date())
  5451. let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
  5452. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  5453. case .week:
  5454. let now = Date()
  5455. let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
  5456. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  5457. }
  5458. }
  5459. private func filteredMeetingsForSchedulePage(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
  5460. let calendar = Calendar.current
  5461. switch schedulePageFilter {
  5462. case .all:
  5463. return meetings
  5464. case .today:
  5465. let start = calendar.startOfDay(for: Date())
  5466. let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
  5467. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  5468. case .week:
  5469. let now = Date()
  5470. let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
  5471. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  5472. case .month:
  5473. let now = Date()
  5474. let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400)
  5475. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  5476. case .customRange:
  5477. let start = calendar.startOfDay(for: schedulePageFromDate)
  5478. let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate)
  5479. guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else {
  5480. return meetings
  5481. }
  5482. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  5483. }
  5484. }
  5485. private func refreshSchedulePageDateFilterUI() {
  5486. let isCustom = schedulePageFilter == .customRange
  5487. schedulePageFromDatePicker?.isEnabled = isCustom
  5488. schedulePageToDatePicker?.isEnabled = isCustom
  5489. let dim: CGFloat = isCustom ? 1.0 : 0.65
  5490. schedulePageFromDatePicker?.alphaValue = 1
  5491. schedulePageToDatePicker?.alphaValue = 1
  5492. schedulePageFromDatePicker?.superview?.alphaValue = dim
  5493. schedulePageToDatePicker?.superview?.alphaValue = dim
  5494. }
  5495. private func schedulePageHasValidCustomRange() -> Bool {
  5496. let start = Calendar.current.startOfDay(for: schedulePageFromDate)
  5497. let end = Calendar.current.startOfDay(for: schedulePageToDate)
  5498. return start <= end
  5499. }
  5500. private func setSchedulePageRangeError(_ message: String?) {
  5501. guard let label = schedulePageRangeErrorLabel else { return }
  5502. label.stringValue = message ?? ""
  5503. label.isHidden = message == nil
  5504. }
  5505. private func applySchedulePageFiltersAndRender() {
  5506. schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
  5507. schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
  5508. if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() {
  5509. setSchedulePageRangeError("Start date must be on or before end date.")
  5510. schedulePageFilteredMeetings = []
  5511. schedulePageVisibleCount = 0
  5512. renderSchedulePageCards()
  5513. schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range"
  5514. return
  5515. }
  5516. setSchedulePageRangeError(nil)
  5517. schedulePageFilteredMeetings = filteredMeetingsForSchedulePage(scheduleCachedMeetings)
  5518. schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredMeetings.count)
  5519. renderSchedulePageCards()
  5520. schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredMeetings)
  5521. }
  5522. private func appendSchedulePageBatchIfNeeded() {
  5523. guard schedulePageVisibleCount < schedulePageFilteredMeetings.count else { return }
  5524. let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredMeetings.count)
  5525. guard nextCount > schedulePageVisibleCount else { return }
  5526. schedulePageVisibleCount = nextCount
  5527. renderSchedulePageCards()
  5528. // If we're still near the bottom after adding a batch (large viewport),
  5529. // immediately evaluate again so pagination keeps advancing in chunks of 6.
  5530. DispatchQueue.main.async { [weak self] in
  5531. self?.schedulePageScrolled()
  5532. }
  5533. }
  5534. private func schedulePageScrolled() {
  5535. guard let scroll = schedulePageCardsScrollView else { return }
  5536. let contentBounds = scroll.contentView.bounds
  5537. let contentHeight = scroll.documentView?.bounds.height ?? 0
  5538. guard contentHeight > 0 else { return }
  5539. let remaining = contentHeight - (contentBounds.origin.y + contentBounds.height)
  5540. if remaining <= 200 {
  5541. appendSchedulePageBatchIfNeeded()
  5542. }
  5543. }
  5544. private func renderSchedulePageCards() {
  5545. guard let stack = schedulePageCardsStack else { return }
  5546. stack.arrangedSubviews.forEach { v in
  5547. stack.removeArrangedSubview(v)
  5548. v.removeFromSuperview()
  5549. }
  5550. let visibleMeetings = Array(schedulePageFilteredMeetings.prefix(schedulePageVisibleCount))
  5551. if visibleMeetings.isEmpty {
  5552. let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
  5553. empty.translatesAutoresizingMaskIntoConstraints = false
  5554. empty.heightAnchor.constraint(equalToConstant: 140).isActive = true
  5555. styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5556. let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
  5557. label.translatesAutoresizingMaskIntoConstraints = false
  5558. empty.addSubview(label)
  5559. NSLayoutConstraint.activate([
  5560. label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
  5561. label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
  5562. ])
  5563. stack.addArrangedSubview(empty)
  5564. empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  5565. return
  5566. }
  5567. var index = 0
  5568. while index < visibleMeetings.count {
  5569. let row = NSStackView()
  5570. row.translatesAutoresizingMaskIntoConstraints = false
  5571. row.userInterfaceLayoutDirection = .leftToRight
  5572. row.orientation = .horizontal
  5573. row.alignment = .top
  5574. row.spacing = schedulePageCardSpacing
  5575. row.distribution = .fillEqually
  5576. let rowEnd = min(index + schedulePageCardsPerRow, visibleMeetings.count)
  5577. for meeting in visibleMeetings[index..<rowEnd] {
  5578. row.addArrangedSubview(scheduleCard(meeting: meeting, useFlexibleWidth: true, contentHeight: schedulePageCardHeight))
  5579. }
  5580. stack.addArrangedSubview(row)
  5581. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  5582. index = rowEnd
  5583. }
  5584. if schedulePageVisibleCount < schedulePageFilteredMeetings.count {
  5585. let pagination = NSStackView()
  5586. pagination.translatesAutoresizingMaskIntoConstraints = false
  5587. pagination.orientation = .horizontal
  5588. pagination.alignment = .centerY
  5589. pagination.spacing = 10
  5590. let moreLabel = textLabel(
  5591. "Showing \(schedulePageVisibleCount) of \(schedulePageFilteredMeetings.count)",
  5592. font: NSFont.systemFont(ofSize: 12, weight: .medium),
  5593. color: palette.textMuted
  5594. )
  5595. pagination.addArrangedSubview(moreLabel)
  5596. let spacer = NSView()
  5597. spacer.translatesAutoresizingMaskIntoConstraints = false
  5598. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  5599. pagination.addArrangedSubview(spacer)
  5600. stack.addArrangedSubview(pagination)
  5601. pagination.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  5602. }
  5603. }
  5604. private func scrollScheduleCards(direction: Int) {
  5605. guard let scroll = scheduleCardsScrollView else { return }
  5606. let contentBounds = scroll.contentView.bounds
  5607. let step = max(220, contentBounds.width * 0.7)
  5608. let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
  5609. let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
  5610. let nextX = min(max(0, proposedX), maxX)
  5611. scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
  5612. scroll.reflectScrolledClipView(scroll.contentView)
  5613. }
  5614. private func loadSchedule() async {
  5615. do {
  5616. if googleOAuth.loadTokens() == nil {
  5617. await MainActor.run {
  5618. updateGoogleAuthButtonTitle()
  5619. applyGoogleProfile(nil)
  5620. scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  5621. schedulePageDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  5622. if let stack = scheduleCardsStack {
  5623. renderScheduleCards(into: stack, meetings: [])
  5624. }
  5625. scheduleCachedMeetings = []
  5626. applySchedulePageFiltersAndRender()
  5627. if calendarPageGridStack != nil {
  5628. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  5629. renderCalendarMonthGrid()
  5630. renderCalendarSelectedDay()
  5631. }
  5632. }
  5633. return
  5634. }
  5635. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  5636. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  5637. let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
  5638. let filtered = filteredMeetings(meetings)
  5639. await MainActor.run {
  5640. updateGoogleAuthButtonTitle()
  5641. applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  5642. scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
  5643. if let stack = scheduleCardsStack {
  5644. renderScheduleCards(into: stack, meetings: filtered)
  5645. }
  5646. scheduleCachedMeetings = meetings
  5647. applySchedulePageFiltersAndRender()
  5648. if calendarPageGridStack != nil {
  5649. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  5650. renderCalendarMonthGrid()
  5651. renderCalendarSelectedDay()
  5652. }
  5653. }
  5654. } catch {
  5655. await MainActor.run {
  5656. updateGoogleAuthButtonTitle()
  5657. if googleOAuth.loadTokens() == nil {
  5658. applyGoogleProfile(nil)
  5659. }
  5660. scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
  5661. schedulePageDateHeadingLabel?.stringValue = "Couldn’t load schedule"
  5662. if let stack = scheduleCardsStack {
  5663. renderScheduleCards(into: stack, meetings: [])
  5664. }
  5665. scheduleCachedMeetings = []
  5666. applySchedulePageFiltersAndRender()
  5667. if calendarPageGridStack != nil {
  5668. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  5669. renderCalendarMonthGrid()
  5670. renderCalendarSelectedDay()
  5671. }
  5672. showSimpleError("Couldn’t load schedule.", error: error)
  5673. }
  5674. }
  5675. }
  5676. func showScheduleHelp() {
  5677. let alert = NSAlert()
  5678. alert.messageText = "Google schedule"
  5679. 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."
  5680. alert.addButton(withTitle: "OK")
  5681. alert.runModal()
  5682. }
  5683. func scheduleReloadClicked() {
  5684. Task { [weak self] in
  5685. guard let self else { return }
  5686. do {
  5687. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  5688. _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  5689. await MainActor.run {
  5690. self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
  5691. self.pageCache[.joinMeetings] = nil
  5692. self.pageCache[.photo] = nil
  5693. self.showSidebarPage(self.selectedSidebarPage)
  5694. }
  5695. await self.loadSchedule()
  5696. } catch {
  5697. await MainActor.run {
  5698. self.showSimpleError("Couldn’t refresh schedule.", error: error)
  5699. }
  5700. }
  5701. }
  5702. }
  5703. func scheduleConnectClicked() {
  5704. Task { [weak self] in
  5705. guard let self else { return }
  5706. do {
  5707. if self.googleOAuth.loadTokens() != nil {
  5708. await MainActor.run { self.showGoogleAccountMenu() }
  5709. return
  5710. }
  5711. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  5712. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  5713. let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
  5714. await MainActor.run {
  5715. self.updateGoogleAuthButtonTitle()
  5716. self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  5717. self.pageCache[.joinMeetings] = nil
  5718. self.pageCache[.photo] = nil
  5719. self.pageCache[.video] = nil
  5720. self.pageCache[.settings] = nil
  5721. self.showSidebarPage(self.selectedSidebarPage)
  5722. }
  5723. } catch {
  5724. self.showSimpleError("Couldn’t connect Google account.", error: error)
  5725. }
  5726. }
  5727. }
  5728. private func showGoogleAccountMenu() {
  5729. guard let button = scheduleGoogleAuthButton else { return }
  5730. if googleAccountPopover?.isShown == true {
  5731. googleAccountPopover?.performClose(nil)
  5732. googleAccountPopover = nil
  5733. return
  5734. }
  5735. let popover = NSPopover()
  5736. popover.behavior = .transient
  5737. popover.animates = true
  5738. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  5739. let name = scheduleCurrentProfile?.name ?? "Google account"
  5740. let email = scheduleCurrentProfile?.email ?? "Signed in"
  5741. let avatar = scheduleProfileMenuAvatar
  5742. popover.contentViewController = GoogleAccountMenuViewController(
  5743. palette: palette,
  5744. darkModeEnabled: darkModeEnabled,
  5745. displayName: name,
  5746. email: email,
  5747. avatar: avatar,
  5748. onSignOut: { [weak self] in
  5749. self?.googleAccountPopover?.performClose(nil)
  5750. self?.googleAccountPopover = nil
  5751. self?.performGoogleSignOut()
  5752. }
  5753. )
  5754. googleAccountPopover = popover
  5755. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
  5756. }
  5757. private func performGoogleSignOut() {
  5758. do {
  5759. try googleOAuth.signOut()
  5760. applyGoogleProfile(nil)
  5761. updateGoogleAuthButtonTitle()
  5762. pageCache[.joinMeetings] = nil
  5763. pageCache[.photo] = nil
  5764. pageCache[.video] = nil
  5765. pageCache[.settings] = nil
  5766. showSidebarPage(selectedSidebarPage)
  5767. Task { [weak self] in
  5768. await self?.loadSchedule()
  5769. }
  5770. } catch {
  5771. showSimpleError("Couldn’t logout Google account.", error: error)
  5772. }
  5773. }
  5774. private func updateGoogleAuthButtonTitle() {
  5775. let signedIn = (googleOAuth.loadTokens() != nil)
  5776. guard let button = scheduleGoogleAuthButton else { return }
  5777. let profileName = scheduleCurrentProfile?.name ?? "Google account"
  5778. let ringHostInset: CGFloat = signedIn ? 14 : 0
  5779. scheduleGoogleAuthHostPadWidthConstraint?.constant = ringHostInset
  5780. scheduleGoogleAuthHostPadHeightConstraint?.constant = ringHostInset
  5781. scheduleGoogleAuthHostView?.setAvatarRingMode(signedIn)
  5782. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: darkModeEnabled, accent: palette.primaryBlue)
  5783. if signedIn == false {
  5784. scheduleGoogleAuthHostView?.setProfileHoverActive(false)
  5785. }
  5786. if signedIn {
  5787. button.setAccessibilityLabel("\(profileName), Google account")
  5788. button.attributedTitle = NSAttributedString(string: "")
  5789. button.imagePosition = .imageOnly
  5790. button.imageScaling = .scaleProportionallyDown
  5791. button.symbolConfiguration = nil
  5792. scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize
  5793. scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize
  5794. button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2
  5795. let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
  5796. if let symbol {
  5797. let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize))
  5798. button.image = sized
  5799. button.contentTintColor = palette.textSecondary
  5800. } else {
  5801. button.image = nil
  5802. button.contentTintColor = nil
  5803. }
  5804. scheduleProfileMenuAvatar = button.image
  5805. } else {
  5806. button.setAccessibilityLabel("Sign in with Google")
  5807. let title = "Sign in with Google"
  5808. let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
  5809. let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
  5810. button.attributedTitle = NSAttributedString(string: title, attributes: [
  5811. .font: titleFont,
  5812. .foregroundColor: titleColor
  5813. ])
  5814. let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
  5815. let idealWidth = ceil(textWidth + 80)
  5816. scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
  5817. scheduleGoogleAuthButtonHeightConstraint?.constant = 42
  5818. button.layer?.cornerRadius = 21
  5819. button.imagePosition = .imageLeading
  5820. button.imageScaling = .scaleNone
  5821. if let g = NSImage(named: "GoogleGLogo") {
  5822. button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
  5823. } else {
  5824. button.image = nil
  5825. }
  5826. button.contentTintColor = nil
  5827. }
  5828. applyGoogleAuthButtonSurface()
  5829. }
  5830. private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
  5831. let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
  5832. let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  5833. return GoogleProfileDisplay(
  5834. name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
  5835. email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
  5836. pictureURL: profile.picture.flatMap(URL.init(string:))
  5837. )
  5838. }
  5839. private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
  5840. scheduleProfileImageTask?.cancel()
  5841. scheduleProfileImageTask = nil
  5842. if profile == nil {
  5843. scheduleProfileMenuAvatar = nil
  5844. }
  5845. scheduleCurrentProfile = profile
  5846. updateGoogleAuthButtonTitle()
  5847. guard let profile, let pictureURL = profile.pictureURL else { return }
  5848. let avatarDiameter = scheduleGoogleSignedInAvatarSize
  5849. scheduleProfileImageTask = Task { [weak self] in
  5850. do {
  5851. let (data, _) = try await URLSession.shared.data(from: pictureURL)
  5852. if Task.isCancelled { return }
  5853. guard let image = NSImage(data: data) else { return }
  5854. await MainActor.run { [weak self] in
  5855. guard let self else { return }
  5856. let rounded = self.circularProfileImage(image, diameter: avatarDiameter)
  5857. self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48)
  5858. self.scheduleGoogleAuthButton?.image = rounded
  5859. self.scheduleGoogleAuthButton?.contentTintColor = nil
  5860. }
  5861. } catch {
  5862. // Keep placeholder avatar if image fetch fails.
  5863. }
  5864. }
  5865. }
  5866. private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
  5867. let result = NSImage(size: size)
  5868. result.lockFocus()
  5869. image.draw(in: NSRect(origin: .zero, size: size),
  5870. from: NSRect(origin: .zero, size: image.size),
  5871. operation: .copy,
  5872. fraction: 1.0)
  5873. result.unlockFocus()
  5874. result.isTemplate = false
  5875. return result
  5876. }
  5877. /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square).
  5878. private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  5879. circularNSImage(image, diameter: diameter)
  5880. }
  5881. private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
  5882. let base = resizedImage(image, to: iconSize)
  5883. let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
  5884. let result = NSImage(size: canvas)
  5885. result.lockFocus()
  5886. base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
  5887. from: NSRect(origin: .zero, size: base.size),
  5888. operation: .copy,
  5889. fraction: 1.0)
  5890. result.unlockFocus()
  5891. result.isTemplate = false
  5892. return result
  5893. }
  5894. private func applyGoogleAuthButtonSurface() {
  5895. guard let button = scheduleGoogleAuthButton else { return }
  5896. let signedIn = (googleOAuth.loadTokens() != nil)
  5897. let isDark = darkModeEnabled
  5898. if signedIn {
  5899. button.layer?.backgroundColor = NSColor.clear.cgColor
  5900. button.layer?.borderWidth = 0
  5901. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: isDark, accent: palette.primaryBlue)
  5902. return
  5903. }
  5904. let baseBackground = isDark
  5905. ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  5906. : NSColor.white
  5907. let hoverBlend = isDark ? NSColor.white : NSColor.black
  5908. let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
  5909. let baseBorder = isDark
  5910. ? NSColor(calibratedWhite: 0.50, alpha: 1)
  5911. : NSColor(calibratedWhite: 0.72, alpha: 1)
  5912. let hoverBorder = isDark
  5913. ? NSColor(calibratedWhite: 0.62, alpha: 1)
  5914. : NSColor(calibratedWhite: 0.56, alpha: 1)
  5915. button.layer?.borderWidth = 1
  5916. button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
  5917. button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
  5918. }
  5919. @MainActor
  5920. func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
  5921. if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
  5922. let alert = NSAlert()
  5923. alert.messageText = "Enter Google OAuth credentials"
  5924. alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
  5925. let accessory = NSStackView()
  5926. accessory.orientation = .vertical
  5927. accessory.spacing = 8
  5928. accessory.alignment = .leading
  5929. let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
  5930. idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
  5931. idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
  5932. let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
  5933. secretField.placeholderString = "Client Secret (GOCSPX-...)"
  5934. secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
  5935. accessory.addArrangedSubview(idField)
  5936. accessory.addArrangedSubview(secretField)
  5937. alert.accessoryView = accessory
  5938. alert.addButton(withTitle: "Save")
  5939. alert.addButton(withTitle: "Cancel")
  5940. // Keep this synchronous to avoid additional sheet state handling.
  5941. let response = alert.runModal()
  5942. if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
  5943. let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  5944. let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  5945. if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
  5946. if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
  5947. googleOAuth.setClientIdForTesting(idValue)
  5948. googleOAuth.setClientSecretForTesting(secretValue)
  5949. }
  5950. func showSimpleError(_ title: String, error: Error) {
  5951. DispatchQueue.main.async {
  5952. let alert = NSAlert()
  5953. alert.alertStyle = .warning
  5954. alert.messageText = title
  5955. alert.informativeText = error.localizedDescription
  5956. alert.addButton(withTitle: "OK")
  5957. alert.runModal()
  5958. }
  5959. }
  5960. }
  5961. private struct Palette {
  5962. let pageBackground: NSColor
  5963. let sidebarBackground: NSColor
  5964. let sectionCard: NSColor
  5965. let tabBarBackground: NSColor
  5966. let tabIdleBackground: NSColor
  5967. let inputBackground: NSColor
  5968. let inputBorder: NSColor
  5969. let primaryBlue: NSColor
  5970. let primaryBlueBorder: NSColor
  5971. let cancelButton: NSColor
  5972. let meetingBadge: NSColor
  5973. let separator: NSColor
  5974. let textPrimary: NSColor
  5975. let textSecondary: NSColor
  5976. let textTertiary: NSColor
  5977. let textMuted: NSColor
  5978. init(isDarkMode: Bool) {
  5979. if isDarkMode {
  5980. pageBackground = NSColor(calibratedRed: 10.0 / 255.0, green: 11.0 / 255.0, blue: 12.0 / 255.0, alpha: 1)
  5981. sidebarBackground = NSColor(calibratedRed: 16.0 / 255.0, green: 17.0 / 255.0, blue: 19.0 / 255.0, alpha: 1)
  5982. sectionCard = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  5983. tabBarBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  5984. tabIdleBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  5985. inputBackground = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  5986. inputBorder = NSColor(calibratedRed: 38.0 / 255.0, green: 40.0 / 255.0, blue: 44.0 / 255.0, alpha: 1)
  5987. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  5988. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  5989. cancelButton = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  5990. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  5991. separator = NSColor(calibratedRed: 26.0 / 255.0, green: 27.0 / 255.0, blue: 30.0 / 255.0, alpha: 1)
  5992. textPrimary = NSColor(calibratedWhite: 0.98, alpha: 1)
  5993. textSecondary = NSColor(calibratedWhite: 0.78, alpha: 1)
  5994. textTertiary = NSColor(calibratedWhite: 0.66, alpha: 1)
  5995. textMuted = NSColor(calibratedWhite: 0.44, alpha: 1)
  5996. } else {
  5997. pageBackground = NSColor(calibratedRed: 244.0 / 255.0, green: 246.0 / 255.0, blue: 249.0 / 255.0, alpha: 1)
  5998. sidebarBackground = NSColor(calibratedRed: 232.0 / 255.0, green: 236.0 / 255.0, blue: 242.0 / 255.0, alpha: 1)
  5999. sectionCard = NSColor.white
  6000. tabBarBackground = NSColor.white
  6001. tabIdleBackground = NSColor.white
  6002. inputBackground = NSColor(calibratedRed: 247.0 / 255.0, green: 249.0 / 255.0, blue: 252.0 / 255.0, alpha: 1)
  6003. inputBorder = NSColor(calibratedRed: 211.0 / 255.0, green: 218.0 / 255.0, blue: 228.0 / 255.0, alpha: 1)
  6004. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  6005. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  6006. cancelButton = NSColor(calibratedRed: 240.0 / 255.0, green: 243.0 / 255.0, blue: 248.0 / 255.0, alpha: 1)
  6007. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  6008. separator = NSColor(calibratedRed: 212.0 / 255.0, green: 219.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
  6009. textPrimary = NSColor(calibratedRed: 32.0 / 255.0, green: 38.0 / 255.0, blue: 47.0 / 255.0, alpha: 1)
  6010. textSecondary = NSColor(calibratedRed: 82.0 / 255.0, green: 92.0 / 255.0, blue: 107.0 / 255.0, alpha: 1)
  6011. textTertiary = NSColor(calibratedRed: 110.0 / 255.0, green: 120.0 / 255.0, blue: 136.0 / 255.0, alpha: 1)
  6012. textMuted = NSColor(calibratedRed: 134.0 / 255.0, green: 145.0 / 255.0, blue: 162.0 / 255.0, alpha: 1)
  6013. }
  6014. }
  6015. }
  6016. private struct Typography {
  6017. let sidebarBrand = NSFont.systemFont(ofSize: 26, weight: .bold)
  6018. let sidebarSection = NSFont.systemFont(ofSize: 11, weight: .medium)
  6019. let sidebarIcon = NSFont.systemFont(ofSize: 12, weight: .medium)
  6020. let sidebarItem = NSFont.systemFont(ofSize: 16, weight: .medium)
  6021. let pageTitle = NSFont.systemFont(ofSize: 27, weight: .semibold)
  6022. let joinWithURLTitle = NSFont.systemFont(ofSize: 17, weight: .semibold)
  6023. let sectionTitleBold = NSFont.systemFont(ofSize: 25, weight: .bold)
  6024. let dateHeading = NSFont.systemFont(ofSize: 18, weight: .medium)
  6025. let tabIcon = NSFont.systemFont(ofSize: 13, weight: .regular)
  6026. let tabTitle = NSFont.systemFont(ofSize: 31 / 2, weight: .semibold)
  6027. let fieldLabel = NSFont.systemFont(ofSize: 15, weight: .medium)
  6028. let inputPlaceholder = NSFont.systemFont(ofSize: 14, weight: .regular)
  6029. let buttonText = NSFont.systemFont(ofSize: 16, weight: .medium)
  6030. let filterText = NSFont.systemFont(ofSize: 15, weight: .regular)
  6031. let filterArrow = NSFont.systemFont(ofSize: 12, weight: .regular)
  6032. let iconButton = NSFont.systemFont(ofSize: 14, weight: .medium)
  6033. let cardIcon = NSFont.systemFont(ofSize: 8, weight: .bold)
  6034. let cardTitle = NSFont.systemFont(ofSize: 15, weight: .semibold)
  6035. let cardSubtitle = NSFont.systemFont(ofSize: 13, weight: .bold)
  6036. let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
  6037. }
  6038. // MARK: - In-app browser (macOS WKWebView + chrome)
  6039. // Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
  6040. private enum InAppBrowserURLPolicy: Equatable {
  6041. case allowAll
  6042. case whitelist(hostSuffixes: [String])
  6043. }
  6044. private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
  6045. let scheme = (url.scheme ?? "").lowercased()
  6046. if scheme == "about" { return true }
  6047. guard scheme == "http" || scheme == "https" else { return false }
  6048. guard let host = url.host?.lowercased() else { return false }
  6049. switch policy {
  6050. case .allowAll:
  6051. return true
  6052. case .whitelist(let suffixes):
  6053. for suffix in suffixes {
  6054. let s = suffix.lowercased()
  6055. if host == s || host.hasSuffix("." + s) { return true }
  6056. }
  6057. return false
  6058. }
  6059. }
  6060. private enum InAppBrowserWebKitSupport {
  6061. static func makeWebViewConfiguration() -> WKWebViewConfiguration {
  6062. let config = WKWebViewConfiguration()
  6063. config.websiteDataStore = .default()
  6064. config.preferences.javaScriptCanOpenWindowsAutomatically = true
  6065. if #available(macOS 12.3, *) {
  6066. config.preferences.isElementFullscreenEnabled = true
  6067. }
  6068. config.mediaTypesRequiringUserActionForPlayback = []
  6069. if #available(macOS 11.0, *) {
  6070. config.defaultWebpagePreferences.allowsContentJavaScript = true
  6071. }
  6072. config.applicationNameForUserAgent = "MeetingsApp/1.0"
  6073. return config
  6074. }
  6075. }
  6076. private final class InAppBrowserWindowController: NSWindowController {
  6077. private static let defaultContentSize = NSSize(width: 1100, height: 760)
  6078. private static let minimumContentSize = NSSize(width: 800, height: 520)
  6079. private let browserViewController = InAppBrowserContainerViewController()
  6080. init() {
  6081. let browserWindow = NSWindow(
  6082. contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
  6083. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  6084. backing: .buffered,
  6085. defer: false
  6086. )
  6087. browserWindow.title = "Browser"
  6088. browserWindow.isRestorable = false
  6089. browserWindow.setFrameAutosaveName("")
  6090. browserWindow.minSize = browserWindow.frameRect(forContentRect: NSRect(origin: .zero, size: Self.minimumContentSize)).size
  6091. browserWindow.center()
  6092. browserWindow.contentViewController = browserViewController
  6093. super.init(window: browserWindow)
  6094. }
  6095. @available(*, unavailable)
  6096. required init?(coder: NSCoder) {
  6097. nil
  6098. }
  6099. /// Resets size and position each time the browser is shown so a previously tiny window is never reused.
  6100. func applyDefaultFrameCenteredOnVisibleScreen() {
  6101. guard let w = window, let screen = w.screen ?? NSScreen.main else { return }
  6102. let windowFrame = w.frameRect(forContentRect: NSRect(origin: .zero, size: Self.defaultContentSize))
  6103. let vf = screen.visibleFrame
  6104. var frame = windowFrame
  6105. frame.origin.x = vf.midX - frame.width / 2
  6106. frame.origin.y = vf.midY - frame.height / 2
  6107. if frame.maxX > vf.maxX { frame.origin.x = vf.maxX - frame.width }
  6108. if frame.minX < vf.minX { frame.origin.x = vf.minX }
  6109. if frame.maxY > vf.maxY { frame.origin.y = vf.maxY - frame.height }
  6110. if frame.minY < vf.minY { frame.origin.y = vf.minY }
  6111. w.setFrame(frame, display: true)
  6112. }
  6113. func load(url: URL, policy: InAppBrowserURLPolicy) {
  6114. browserViewController.setNavigationPolicy(policy)
  6115. browserViewController.load(url: url)
  6116. }
  6117. }
  6118. private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
  6119. private var webView: WKWebView!
  6120. private var webContainerView: NSView!
  6121. private weak var urlField: NSTextField?
  6122. private var backButton: NSButton!
  6123. private var forwardButton: NSButton!
  6124. private var reloadStopButton: NSButton!
  6125. private var goButton: NSButton!
  6126. private var progressBar: NSProgressIndicator!
  6127. private var lastLoadedURL: URL?
  6128. private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
  6129. private var processTerminateRetryCount = 0
  6130. /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
  6131. private let maxProcessTerminateRetries = 3
  6132. private var kvoTokens: [NSKeyValueObservation] = []
  6133. deinit {
  6134. kvoTokens.removeAll()
  6135. }
  6136. func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
  6137. navigationPolicy = policy
  6138. }
  6139. override func loadView() {
  6140. let root = NSView()
  6141. root.translatesAutoresizingMaskIntoConstraints = false
  6142. let wv = makeWebView()
  6143. webView = wv
  6144. let webHost = NSView()
  6145. webHost.translatesAutoresizingMaskIntoConstraints = false
  6146. webHost.wantsLayer = true
  6147. webHost.addSubview(wv)
  6148. NSLayoutConstraint.activate([
  6149. wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
  6150. wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
  6151. wv.topAnchor.constraint(equalTo: webHost.topAnchor),
  6152. wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
  6153. ])
  6154. webContainerView = webHost
  6155. let toolbar = NSStackView()
  6156. toolbar.translatesAutoresizingMaskIntoConstraints = false
  6157. toolbar.orientation = .horizontal
  6158. toolbar.spacing = 8
  6159. toolbar.alignment = .centerY
  6160. toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
  6161. backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
  6162. backButton.target = self
  6163. backButton.action = #selector(goBack)
  6164. forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
  6165. forwardButton.target = self
  6166. forwardButton.action = #selector(goForward)
  6167. reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
  6168. reloadStopButton.target = self
  6169. reloadStopButton.action = #selector(reloadOrStop)
  6170. let field = NSTextField(string: "")
  6171. field.translatesAutoresizingMaskIntoConstraints = false
  6172. field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  6173. field.placeholderString = "Address"
  6174. field.cell?.sendsActionOnEndEditing = false
  6175. field.delegate = self
  6176. urlField = field
  6177. goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
  6178. goButton.translatesAutoresizingMaskIntoConstraints = false
  6179. goButton.bezelStyle = .rounded
  6180. toolbar.addArrangedSubview(backButton)
  6181. toolbar.addArrangedSubview(forwardButton)
  6182. toolbar.addArrangedSubview(reloadStopButton)
  6183. toolbar.addArrangedSubview(field)
  6184. toolbar.addArrangedSubview(goButton)
  6185. field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
  6186. let bar = NSProgressIndicator()
  6187. bar.translatesAutoresizingMaskIntoConstraints = false
  6188. bar.style = .bar
  6189. bar.isIndeterminate = false
  6190. bar.minValue = 0
  6191. bar.maxValue = 1
  6192. bar.doubleValue = 0
  6193. bar.isHidden = true
  6194. progressBar = bar
  6195. let separator = NSBox()
  6196. separator.translatesAutoresizingMaskIntoConstraints = false
  6197. separator.boxType = .separator
  6198. webView.navigationDelegate = self
  6199. webView.uiDelegate = self
  6200. root.addSubview(toolbar)
  6201. root.addSubview(bar)
  6202. root.addSubview(separator)
  6203. root.addSubview(webHost)
  6204. NSLayoutConstraint.activate([
  6205. toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6206. toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6207. toolbar.topAnchor.constraint(equalTo: root.topAnchor),
  6208. bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6209. bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6210. bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
  6211. bar.heightAnchor.constraint(equalToConstant: 3),
  6212. separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6213. separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6214. separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
  6215. webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6216. webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6217. webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
  6218. webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  6219. ])
  6220. view = root
  6221. installWebViewObservers()
  6222. syncToolbarFromWebView()
  6223. }
  6224. private func makeWebView() -> WKWebView {
  6225. let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
  6226. wv.translatesAutoresizingMaskIntoConstraints = false
  6227. return wv
  6228. }
  6229. private func teardownWebViewObservers() {
  6230. kvoTokens.removeAll()
  6231. }
  6232. /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
  6233. private func replaceWebViewAndLoad(url: URL) {
  6234. teardownWebViewObservers()
  6235. webView.navigationDelegate = nil
  6236. webView.uiDelegate = nil
  6237. webView.removeFromSuperview()
  6238. let wv = makeWebView()
  6239. webView = wv
  6240. webContainerView.addSubview(wv)
  6241. NSLayoutConstraint.activate([
  6242. wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
  6243. wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
  6244. wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
  6245. wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
  6246. ])
  6247. webView.navigationDelegate = self
  6248. webView.uiDelegate = self
  6249. installWebViewObservers()
  6250. syncToolbarFromWebView()
  6251. webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
  6252. }
  6253. private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
  6254. let b = NSButton()
  6255. b.translatesAutoresizingMaskIntoConstraints = false
  6256. b.bezelStyle = .texturedRounded
  6257. b.setAccessibilityLabel(accessibilityDescription)
  6258. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
  6259. b.image = img
  6260. b.imagePosition = .imageOnly
  6261. } else {
  6262. b.title = title
  6263. }
  6264. b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
  6265. return b
  6266. }
  6267. private func installWebViewObservers() {
  6268. kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
  6269. self?.syncToolbarFromWebView()
  6270. })
  6271. kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
  6272. self?.syncToolbarFromWebView()
  6273. })
  6274. kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
  6275. self?.syncToolbarFromWebView()
  6276. })
  6277. kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
  6278. self?.syncProgressFromWebView()
  6279. })
  6280. kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
  6281. self?.syncWindowTitle()
  6282. })
  6283. kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
  6284. self?.syncAddressFieldFromWebView()
  6285. })
  6286. }
  6287. private func syncToolbarFromWebView() {
  6288. backButton?.isEnabled = webView.canGoBack
  6289. forwardButton?.isEnabled = webView.canGoForward
  6290. if webView.isLoading {
  6291. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
  6292. reloadStopButton.image = img
  6293. reloadStopButton.imagePosition = .imageOnly
  6294. reloadStopButton.title = ""
  6295. } else {
  6296. reloadStopButton.title = "Stop"
  6297. }
  6298. } else {
  6299. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
  6300. reloadStopButton.image = img
  6301. reloadStopButton.imagePosition = .imageOnly
  6302. reloadStopButton.title = ""
  6303. } else {
  6304. reloadStopButton.title = "Reload"
  6305. }
  6306. }
  6307. syncProgressFromWebView()
  6308. }
  6309. private func syncProgressFromWebView() {
  6310. guard let progressBar else { return }
  6311. if webView.isLoading {
  6312. progressBar.isHidden = false
  6313. progressBar.doubleValue = webView.estimatedProgress
  6314. } else {
  6315. progressBar.isHidden = true
  6316. progressBar.doubleValue = 0
  6317. }
  6318. }
  6319. private func syncAddressFieldFromWebView() {
  6320. guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
  6321. urlField.stringValue = url.absoluteString
  6322. }
  6323. private func syncWindowTitle() {
  6324. let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  6325. let host = webView.url?.host ?? ""
  6326. view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
  6327. }
  6328. func load(url: URL) {
  6329. lastLoadedURL = url
  6330. processTerminateRetryCount = 0
  6331. urlField?.stringValue = url.absoluteString
  6332. webView.load(URLRequest(url: url))
  6333. syncWindowTitle()
  6334. }
  6335. @objc private func goBack() {
  6336. webView.goBack()
  6337. }
  6338. @objc private func goForward() {
  6339. webView.goForward()
  6340. }
  6341. @objc private func reloadOrStop() {
  6342. if webView.isLoading {
  6343. webView.stopLoading()
  6344. } else {
  6345. webView.reload()
  6346. }
  6347. }
  6348. @objc private func addressFieldSubmitted() {
  6349. let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  6350. guard raw.isEmpty == false else { return }
  6351. var normalized = raw
  6352. if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
  6353. normalized = "https://\(normalized)"
  6354. }
  6355. guard let url = URL(string: normalized),
  6356. let scheme = url.scheme?.lowercased(),
  6357. scheme == "http" || scheme == "https",
  6358. url.host != nil
  6359. else {
  6360. let alert = NSAlert()
  6361. alert.messageText = "Invalid address"
  6362. alert.informativeText = "Enter a valid web address, for example https://example.com"
  6363. alert.addButton(withTitle: "OK")
  6364. alert.runModal()
  6365. return
  6366. }
  6367. guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
  6368. presentBlockedHostAlert()
  6369. return
  6370. }
  6371. load(url: url)
  6372. }
  6373. private func presentBlockedHostAlert() {
  6374. let alert = NSAlert()
  6375. alert.messageText = "Address not allowed"
  6376. alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
  6377. alert.addButton(withTitle: "OK")
  6378. alert.runModal()
  6379. }
  6380. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  6381. processTerminateRetryCount = 0
  6382. syncAddressFieldFromWebView()
  6383. syncWindowTitle()
  6384. }
  6385. func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
  6386. let nsError = error as NSError
  6387. if nsError.code == NSURLErrorCancelled {
  6388. return
  6389. }
  6390. let alert = NSAlert()
  6391. alert.messageText = "Unable to load page"
  6392. alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
  6393. alert.addButton(withTitle: "Try Again")
  6394. alert.addButton(withTitle: "OK")
  6395. if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
  6396. processTerminateRetryCount = 0
  6397. webView.load(URLRequest(url: url))
  6398. }
  6399. }
  6400. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  6401. guard let url = lastLoadedURL else { return }
  6402. if processTerminateRetryCount < maxProcessTerminateRetries {
  6403. processTerminateRetryCount += 1
  6404. replaceWebViewAndLoad(url: url)
  6405. return
  6406. }
  6407. let alert = NSAlert()
  6408. alert.messageText = "Page stopped loading"
  6409. alert.informativeText =
  6410. "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
  6411. alert.addButton(withTitle: "Try Again")
  6412. alert.addButton(withTitle: "OK")
  6413. if alert.runModal() == .alertFirstButtonReturn {
  6414. processTerminateRetryCount = 0
  6415. replaceWebViewAndLoad(url: url)
  6416. }
  6417. }
  6418. func webView(
  6419. _ webView: WKWebView,
  6420. decidePolicyFor navigationAction: WKNavigationAction,
  6421. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  6422. ) {
  6423. guard let url = navigationAction.request.url else {
  6424. decisionHandler(.allow)
  6425. return
  6426. }
  6427. let scheme = (url.scheme ?? "").lowercased()
  6428. if scheme == "mailto" || scheme == "tel" {
  6429. decisionHandler(.cancel)
  6430. return
  6431. }
  6432. if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
  6433. if navigationAction.targetFrame?.isMainFrame != false {
  6434. DispatchQueue.main.async { [weak self] in
  6435. self?.presentBlockedHostAlert()
  6436. }
  6437. }
  6438. decisionHandler(.cancel)
  6439. return
  6440. }
  6441. decisionHandler(.allow)
  6442. }
  6443. func webView(
  6444. _ webView: WKWebView,
  6445. createWebViewWith configuration: WKWebViewConfiguration,
  6446. for navigationAction: WKNavigationAction,
  6447. windowFeatures: WKWindowFeatures
  6448. ) -> WKWebView? {
  6449. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  6450. if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
  6451. webView.load(URLRequest(url: requestURL))
  6452. } else {
  6453. presentBlockedHostAlert()
  6454. }
  6455. }
  6456. return nil
  6457. }
  6458. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  6459. if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  6460. addressFieldSubmitted()
  6461. return true
  6462. }
  6463. return false
  6464. }
  6465. }