Sin descripción

ViewController.swift 372KB

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