暫無描述

ViewController.swift 311KB

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