ViewController.swift 489 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614106151061610617106181061910620106211062210623106241062510626106271062810629106301063110632106331063410635106361063710638106391064010641106421064310644106451064610647106481064910650106511065210653106541065510656106571065810659106601066110662106631066410665106661066710668106691067010671106721067310674106751067610677106781067910680106811068210683106841068510686106871068810689106901069110692106931069410695106961069710698106991070010701107021070310704107051070610707107081070910710107111071210713107141071510716107171071810719107201072110722107231072410725107261072710728107291073010731107321073310734107351073610737107381073910740107411074210743107441074510746107471074810749107501075110752107531075410755107561075710758107591076010761107621076310764107651076610767107681076910770107711077210773107741077510776107771077810779107801078110782107831078410785107861078710788107891079010791107921079310794107951079610797107981079910800108011080210803108041080510806108071080810809108101081110812108131081410815108161081710818108191082010821108221082310824108251082610827108281082910830108311083210833108341083510836
  1. //
  2. // ViewController.swift
  3. // Assistant for Google Meet
  4. //
  5. // Created by Dev Mac 1 on 06/04/2026.
  6. //
  7. import Cocoa
  8. import QuartzCore
  9. import AVFoundation
  10. import AVKit
  11. import Speech
  12. import WebKit
  13. import AuthenticationServices
  14. import StoreKit
  15. import ScreenCaptureKit
  16. private enum SidebarPage: Int {
  17. case joinMeetings = 0
  18. case photo = 1
  19. case video = 2
  20. case widgets = 3
  21. case settings = 4
  22. case aiCompanion = 5
  23. }
  24. private enum ZoomJoinMode: Int {
  25. case id = 0
  26. case url = 1
  27. }
  28. private enum SettingsAction: Int {
  29. case restore = 0
  30. case rateUs = 1
  31. case support = 2
  32. case moreApps = 3
  33. case shareApp = 4
  34. case upgrade = 5
  35. case privacyPolicy = 6
  36. case termsOfServices = 7
  37. }
  38. private struct SpeechLocaleOption {
  39. let identifier: String
  40. let displayName: String
  41. }
  42. private enum PremiumPlan: Int {
  43. case weekly = 0
  44. case monthly = 1
  45. case yearly = 2
  46. case lifetime = 3
  47. }
  48. private enum PremiumStoreProduct {
  49. static let weekly = "com.mqldev.meetingsapp.premium.weekly"
  50. static let monthly = "com.mqldev.meetingsapp.premium.monthly"
  51. static let yearly = "com.mqldev.meetingsapp.premium.yearly"
  52. static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
  53. static let allIDs = [weekly, monthly, yearly, lifetime]
  54. static func productID(for plan: PremiumPlan) -> String {
  55. switch plan {
  56. case .weekly: return weekly
  57. case .monthly: return monthly
  58. case .yearly: return yearly
  59. case .lifetime: return lifetime
  60. }
  61. }
  62. static func plan(for productID: String) -> PremiumPlan? {
  63. switch productID {
  64. case weekly: return .weekly
  65. case monthly: return .monthly
  66. case yearly: return .yearly
  67. case lifetime: return .lifetime
  68. default: return nil
  69. }
  70. }
  71. }
  72. @MainActor
  73. private final class StoreKitCoordinator {
  74. enum PurchaseOutcome {
  75. case success
  76. case cancelled
  77. case pending
  78. case unavailable
  79. case alreadyOwned
  80. case failed(String)
  81. }
  82. private(set) var productsByID: [String: Product] = [:]
  83. private(set) var activeEntitlementProductIDs: Set<String> = []
  84. private(set) var lastProductLoadError: String?
  85. var onEntitlementsChanged: ((Bool) -> Void)?
  86. var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
  87. var hasLifetimeAccess: Bool { activeEntitlementProductIDs.contains(PremiumStoreProduct.lifetime) }
  88. var activeNonLifetimePlan: PremiumPlan? {
  89. activeEntitlementProductIDs
  90. .compactMap { PremiumStoreProduct.plan(for: $0) }
  91. .filter { $0 != .lifetime }
  92. .max(by: { $0.rawValue < $1.rawValue })
  93. }
  94. private var transactionUpdatesTask: Task<Void, Never>?
  95. deinit {
  96. transactionUpdatesTask?.cancel()
  97. }
  98. func start() async {
  99. if transactionUpdatesTask == nil {
  100. transactionUpdatesTask = Task { [weak self] in
  101. await self?.observeTransactionUpdates()
  102. }
  103. }
  104. await refreshProducts()
  105. await refreshEntitlements()
  106. }
  107. func refreshProducts() async {
  108. do {
  109. let products = try await Product.products(for: PremiumStoreProduct.allIDs)
  110. productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
  111. lastProductLoadError = nil
  112. } catch {
  113. productsByID = [:]
  114. lastProductLoadError = error.localizedDescription
  115. }
  116. }
  117. func refreshEntitlements() async {
  118. let previousHasPremiumAccess = hasPremiumAccess
  119. var active = Set<String>()
  120. for await entitlement in Transaction.currentEntitlements {
  121. guard case .verified(let transaction) = entitlement else { continue }
  122. guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
  123. if Self.isTransactionActive(transaction) {
  124. active.insert(transaction.productID)
  125. }
  126. }
  127. // Some StoreKit test timelines can briefly report empty current entitlements
  128. // even though a latest verified transaction exists for a non-consumable.
  129. // Merge in latest transactions to keep launch access state accurate.
  130. for productID in PremiumStoreProduct.allIDs {
  131. guard let latest = await Transaction.latest(for: productID),
  132. case .verified(let transaction) = latest,
  133. Self.isTransactionActive(transaction) else { continue }
  134. active.insert(productID)
  135. }
  136. activeEntitlementProductIDs = active
  137. let newHasPremiumAccess = hasPremiumAccess
  138. if newHasPremiumAccess != previousHasPremiumAccess {
  139. onEntitlementsChanged?(newHasPremiumAccess)
  140. }
  141. }
  142. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  143. let productID = PremiumStoreProduct.productID(for: plan)
  144. if activeEntitlementProductIDs.contains(productID) {
  145. return .alreadyOwned
  146. }
  147. guard let product = productsByID[productID] else {
  148. await refreshProducts()
  149. guard let refreshed = productsByID[productID] else {
  150. if let lastProductLoadError, !lastProductLoadError.isEmpty {
  151. return .failed("Unable to load products: \(lastProductLoadError)")
  152. }
  153. let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
  154. let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
  155. return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
  156. }
  157. return await purchase(product: refreshed)
  158. }
  159. return await purchase(product: product)
  160. }
  161. func restorePurchases() async -> String {
  162. do {
  163. try await AppStore.sync()
  164. await refreshEntitlements()
  165. if hasPremiumAccess {
  166. return "Purchases restored successfully."
  167. }
  168. return "No previous premium purchase was found for this Apple ID."
  169. } catch {
  170. return "Restore failed: \(error.localizedDescription)"
  171. }
  172. }
  173. private func purchase(product: Product) async -> PurchaseOutcome {
  174. do {
  175. let result = try await product.purchase()
  176. switch result {
  177. case .success(let verificationResult):
  178. guard case .verified(let transaction) = verificationResult else {
  179. return .failed("Purchase verification failed.")
  180. }
  181. await transaction.finish()
  182. await refreshEntitlements()
  183. return .success
  184. case .pending:
  185. return .pending
  186. case .userCancelled:
  187. return .cancelled
  188. @unknown default:
  189. return .failed("Unknown purchase state.")
  190. }
  191. } catch {
  192. return .failed(error.localizedDescription)
  193. }
  194. }
  195. private func observeTransactionUpdates() async {
  196. for await update in Transaction.updates {
  197. guard case .verified(let transaction) = update else { continue }
  198. if PremiumStoreProduct.allIDs.contains(transaction.productID) {
  199. await refreshEntitlements()
  200. }
  201. await transaction.finish()
  202. }
  203. }
  204. private static func isTransactionActive(_ transaction: Transaction) -> Bool {
  205. if transaction.revocationDate != nil { return false }
  206. if let expirationDate = transaction.expirationDate {
  207. return expirationDate > Date()
  208. }
  209. return true
  210. }
  211. }
  212. final class ViewController: NSViewController {
  213. private enum MeetingTranscriptStatus: String, Codable {
  214. case notRequested
  215. case processing
  216. case ready
  217. case failed
  218. }
  219. private enum MeetingNotesStatus: String, Codable {
  220. case notRequested
  221. case processing
  222. case ready
  223. case failed
  224. }
  225. private enum MeetingTranscriptSource: String, Codable {
  226. case meetApi
  227. case localAudioAppleSpeech
  228. case localMultiChannelAppleSpeech
  229. }
  230. private enum PaywallFooterAction {
  231. case manageSubscription
  232. case restorePurchase
  233. case continueWithFreePlan
  234. case privacyPolicy
  235. case support
  236. case termsOfServices
  237. }
  238. private struct GoogleProfileDisplay {
  239. let name: String
  240. let email: String
  241. let pictureURL: URL?
  242. }
  243. private struct MeetingRecordingSummary: Codable {
  244. let id: String
  245. let title: String
  246. let meetURLString: String
  247. let startedAt: Date
  248. let endedAt: Date
  249. let audioFilePath: String
  250. /// Google account email that was signed in when this recording was saved.
  251. /// Used to scope AI Companion history per-account.
  252. var accountEmail: String?
  253. var microphoneAudioFilePath: String?
  254. var systemAudioFilePath: String?
  255. var transcriptStatusRaw: String?
  256. var transcriptSourceRaw: String?
  257. var transcriptText: String?
  258. var transcriptSegmentsJSON: String?
  259. var transcriptErrorMessage: String?
  260. var notesStatusRaw: String?
  261. var notesText: String?
  262. var notesErrorMessage: String?
  263. }
  264. private struct ActiveMeetingRecordingSession {
  265. let id: String
  266. let title: String
  267. let meetURL: URL
  268. let startedAt: Date
  269. let systemAudioFileURL: URL
  270. let microphoneAudioFileURL: URL
  271. }
  272. private var palette = Palette(isDarkMode: true)
  273. private let typography = Typography()
  274. private let launchContentSize = NSSize(width: 920, height: 690)
  275. private let launchMinContentSize = NSSize(width: 760, height: 600)
  276. private let launchSplashMinimumVisibleDuration: TimeInterval = 2
  277. private let launchSplashTimeout: TimeInterval = 12
  278. private var launchSplashView: LaunchSplashView?
  279. private var launchSplashTimeoutWorkItem: DispatchWorkItem?
  280. private var launchSplashMinimumDelayWorkItem: DispatchWorkItem?
  281. private var launchSplashShownAt: Date?
  282. private var hasDismissedLaunchSplash = false
  283. private var mainContentHost: NSView?
  284. /// Pin constraints for the current page inside `mainContentHost`; deactivated before each swap so relayout never stacks duplicates.
  285. private var mainContentHostPinConstraints: [NSLayoutConstraint] = []
  286. private var sidebarRowViews: [SidebarPage: NSView] = [:]
  287. private var selectedSidebarPage: SidebarPage = .joinMeetings
  288. private var selectedZoomJoinMode: ZoomJoinMode = .id
  289. private var pageCache: [SidebarPage: NSView] = [:]
  290. private var sidebarPageByView = [ObjectIdentifier: SidebarPage]()
  291. private var zoomJoinModeByView = [ObjectIdentifier: ZoomJoinMode]()
  292. private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
  293. private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
  294. private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
  295. private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
  296. private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
  297. private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
  298. private var aiCompanionTranscriptCurrentRequestId: UUID?
  299. private var aiCompanionTranscriptWindow: NSWindow?
  300. private weak var aiCompanionTranscriptTextView: NSTextView?
  301. private var aiCompanionTranscriptTaskByMeetingId = [String: Task<Void, Never>]()
  302. private var aiCompanionNotesMeetingIdByView = [ObjectIdentifier: String]()
  303. private var aiCompanionNotesStatusLabelByView = [ObjectIdentifier: NSTextField]()
  304. private var aiCompanionNotesCurrentRequestId: UUID?
  305. private var aiCompanionNotesPanel: NSPanel?
  306. private weak var aiCompanionNotesRootView: NSView?
  307. private weak var aiCompanionNotesHeaderView: NSView?
  308. private weak var aiCompanionNotesTitleLabel: NSTextField?
  309. private weak var aiCompanionNotesSubtitleLabel: NSTextField?
  310. private weak var aiCompanionNotesTextView: NSTextView?
  311. private weak var aiCompanionNotesCopyButton: NSButton?
  312. private weak var aiCompanionNotesCloseButton: NSButton?
  313. private var aiCompanionNotesOutsideClickMonitor: Any?
  314. private var aiCompanionNotesTaskByMeetingId = [String: Task<Void, Never>]()
  315. private var aiCompanionAudioPlayer: AVPlayer?
  316. private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
  317. private var aiCompanionCurrentlyPlayingURL: URL?
  318. private weak var aiCompanionCurrentlyPlayingButton: NSButton?
  319. private var aiCompanionPlaybackEndObserver: NSObjectProtocol?
  320. private var aiCompanionPlaybackFailedObserver: NSObjectProtocol?
  321. private var aiCompanionTimeControlObserver: NSKeyValueObservation?
  322. private var aiCompanionAudioRequestID = UUID()
  323. private var aiCompanionNoProgressWorkItem: DispatchWorkItem?
  324. private let aiCompanionSpeechSynthesizer = AVSpeechSynthesizer()
  325. private var aiCompanionIsUsingSpeech = false
  326. private var aiCompanionSpeechTextByView = [ObjectIdentifier: String]()
  327. private var aiCompanionCurrentlySpeakingButtonId: ObjectIdentifier?
  328. private var paywallWindow: NSWindow?
  329. private weak var paywallOverlayView: NSView?
  330. private let paywallContentWidth: CGFloat = 520
  331. private let launchWindowLeftOffset: CGFloat = 80
  332. private var selectedPremiumPlan: PremiumPlan = .monthly
  333. private var paywallPlanViews: [PremiumPlan: NSView] = [:]
  334. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  335. private var paywallFooterActionByView = [ObjectIdentifier: PaywallFooterAction]()
  336. private weak var paywallOfferLabel: NSTextField?
  337. private weak var paywallContinueLabel: NSTextField?
  338. private weak var paywallContinueButton: NSView?
  339. private weak var sidebarPremiumTitleLabel: NSTextField?
  340. private weak var sidebarPremiumIconView: NSImageView?
  341. private weak var sidebarPremiumButtonView: HoverTrackingView?
  342. private weak var instantMeetCardView: HoverSurfaceView?
  343. private weak var instantMeetTitleLabel: NSTextField?
  344. private weak var instantMeetSubtitleLabel: NSTextField?
  345. private weak var joinWithLinkCardView: HoverSurfaceView?
  346. private weak var joinWithLinkTitleLabel: NSTextField?
  347. private weak var joinMeetPrimaryButton: NSButton?
  348. private weak var meetLinkField: NSTextField?
  349. private weak var browseAddressField: NSTextField?
  350. private var inAppBrowserWindowController: InAppBrowserWindowController?
  351. private var embeddedBrowserViewController: InAppBrowserContainerViewController?
  352. private var embeddedBrowserURL: URL?
  353. private var embeddedBrowserPolicy: InAppBrowserURLPolicy = .allowAll
  354. private weak var mainPanelAuthBar: NSView?
  355. private var mainContentHostTopToAuthConstraint: NSLayoutConstraint?
  356. private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
  357. private let googleOAuth = GoogleOAuthService.shared
  358. private let calendarClient = GoogleCalendarClient()
  359. private let googleMeetClient = GoogleMeetClient()
  360. private let storeKitCoordinator = StoreKitCoordinator()
  361. private var storeKitStartupTask: Task<Void, Never>?
  362. private var paywallPurchaseTask: Task<Void, Never>?
  363. private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
  364. private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
  365. private var paywallBadgeLabels: [PremiumPlan: NSTextField] = [:]
  366. private var paywallStrikePriceLabels: [PremiumPlan: NSTextField] = [:]
  367. private var paywallContinueEnabled = true
  368. private var paywallUpgradeFlowEnabled = false
  369. private let launchPaywallDelay: TimeInterval = 3
  370. private var hasCompletedInitialStoreKitSync = false
  371. private var hasPresentedLaunchPaywall = false
  372. private var launchPaywallWorkItem: DispatchWorkItem?
  373. private var hasViewAppearedOnce = false
  374. private var lastKnownPremiumAccess = false
  375. private var displayedScheduleMeetings: [ScheduledMeeting] = []
  376. private var appUsageSessionStartDate: Date?
  377. private var hasObservedAppLifecycleForUsage = false
  378. private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
  379. private enum ScheduleFilter: Int {
  380. case all = 0
  381. case today = 1
  382. case week = 2
  383. }
  384. private enum SchedulePageFilter: Int {
  385. case all = 0
  386. case today = 1
  387. case week = 2
  388. case month = 3
  389. case customRange = 4
  390. }
  391. /// Saved meeting list filters on AI Companion (uses each recording’s `endedAt` day in the current calendar).
  392. private enum AiCompanionRecordingsFilter: Int {
  393. case all = 0
  394. case today = 1
  395. case week = 2
  396. case previousWeek = 3
  397. case previousMonth = 4
  398. case customRange = 5
  399. }
  400. private var scheduleFilter: ScheduleFilter = .all
  401. private weak var scheduleDateHeadingLabel: NSTextField?
  402. private weak var scheduleCardsStack: NSStackView?
  403. private weak var scheduleCardsScrollView: NSScrollView?
  404. private weak var scheduleScrollLeftButton: NSView?
  405. private weak var scheduleScrollRightButton: NSView?
  406. private weak var scheduleFilterDropdown: NSPopUpButton?
  407. private weak var scheduleGoogleAuthButton: NSButton?
  408. private weak var scheduleGoogleAuthHostView: GoogleProfileAuthHostView?
  409. private var scheduleGoogleAuthHostPadWidthConstraint: NSLayoutConstraint?
  410. private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
  411. private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
  412. private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
  413. private weak var settingsReminderMasterSwitch: NSSwitch?
  414. private weak var settingsReminder1DaySwitch: NSSwitch?
  415. private weak var settingsReminder12HoursSwitch: NSSwitch?
  416. private weak var settingsReminder1HourSwitch: NSSwitch?
  417. /// Circular avatar size when signed in (top-right, Google-style).
  418. private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
  419. private var scheduleGoogleAuthHovering = false
  420. private var scheduleCurrentProfile: GoogleProfileDisplay?
  421. /// Larger copy of the header avatar for the account popover (optional).
  422. private var scheduleProfileMenuAvatar: NSImage?
  423. private var scheduleProfileImageTask: Task<Void, Never>?
  424. private var googleAccountPopover: NSPopover?
  425. private var scheduleCachedMeetings: [ScheduledMeeting] = []
  426. private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
  427. private var aiCompanionRecordingsFilter: AiCompanionRecordingsFilter = .today
  428. private var aiCompanionFilterFromDate: Date = Calendar.current.startOfDay(for: Date())
  429. private var aiCompanionFilterToDate: Date = Calendar.current.startOfDay(for: Date())
  430. private var aiCompanionRecordingsRangeErrorMessage: String?
  431. private weak var aiCompanionRecordingsFilterDropdown: NSPopUpButton?
  432. private weak var aiCompanionFilterFromDatePicker: NSDatePicker?
  433. private weak var aiCompanionFilterToDatePicker: NSDatePicker?
  434. private weak var aiCompanionRecordingsRangeErrorLabel: NSTextField?
  435. private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
  436. private var activeMeetingAudioRecorder: AVAudioRecorder?
  437. private var activeMeetingSystemAudioStopper: (() async -> Void)?
  438. private var meetingRecordingMonitorTask: Task<Void, Never>?
  439. private weak var aiCompanionStopRecordingButton: NSButton?
  440. private let widgetSnapshotLimit: Int = 3
  441. private var schedulePageFilter: SchedulePageFilter = .all
  442. private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
  443. private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date())
  444. private var schedulePageFilteredMeetings: [ScheduledMeeting] = []
  445. private var schedulePageVisibleCount: Int = 0
  446. private let schedulePageBatchSize: Int = 6
  447. private let schedulePageCardsPerRow: Int = 3
  448. private let schedulePageCardSpacing: CGFloat = 20
  449. private let schedulePageCardHeight: CGFloat = 182
  450. /// Match `makeJoinMeetingsContent` vertical rhythm between sections.
  451. private let schedulePageStackSpacing: CGFloat = 14
  452. /// Tighter gap from header block (title + filters) to the date line below.
  453. private let schedulePageHeaderToDateSpacing: CGFloat = 10
  454. /// Join Meetings: gap from “Schedule” row to date heading, and date heading to card strip (keeps cards aligned with the rest of the column).
  455. private let joinPageScheduleHeaderToDateSpacing: CGFloat = 8
  456. private let joinPageDateToMeetingCardsSpacing: CGFloat = 8
  457. /// Match Join Meetings main content insets so the top auth/profile bar lines up with page edges.
  458. private let schedulePageLeadingInset: CGFloat = 28
  459. private let schedulePageTrailingInset: CGFloat = 28
  460. private var schedulePageScrollObservation: NSObjectProtocol?
  461. private weak var schedulePageDateHeadingLabel: NSTextField?
  462. private weak var schedulePageFilterDropdown: NSPopUpButton?
  463. private weak var schedulePageFromDatePicker: NSDatePicker?
  464. private weak var schedulePageToDatePicker: NSDatePicker?
  465. private weak var schedulePageRangeErrorLabel: NSTextField?
  466. private weak var schedulePageCardsStack: NSStackView?
  467. private weak var schedulePageCardsScrollView: NSScrollView?
  468. private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
  469. private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
  470. // MARK: - Calendar page (custom month UI)
  471. private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
  472. private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date())
  473. private weak var calendarPageMonthLabel: NSTextField?
  474. private weak var calendarPageGridStack: NSStackView?
  475. private var calendarPageGridHeightConstraint: NSLayoutConstraint?
  476. private weak var calendarPageDaySummaryLabel: NSTextField?
  477. private var calendarPageActionPopover: NSPopover?
  478. private var calendarPageCreatePopover: NSPopover?
  479. private weak var topToastView: NSVisualEffectView?
  480. private var topToastHideWorkItem: DispatchWorkItem?
  481. /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
  482. private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
  483. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  484. private let appUsageAccumulatedSecondsDefaultsKey = "rating.appUsageAccumulatedSeconds"
  485. private let userHasRatedDefaultsKey = "rating.userHasRated"
  486. private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
  487. private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
  488. private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
  489. private let aiCompanionPreferredLanguage1DefaultsKey = "aiCompanion.preferredLanguage1"
  490. private let aiCompanionPreferredLanguage2DefaultsKey = "aiCompanion.preferredLanguage2"
  491. private weak var activeMeetingConsentWindow: NSWindow?
  492. private weak var meetingConsentPrimaryLanguagePopup: NSPopUpButton?
  493. private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
  494. private var didTapInlineMeetingConsentClose = false
  495. private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
  496. private let meetingTranscriptionService = MeetingTranscriptionService()
  497. private let meetingNotesService = MeetingNotesService()
  498. private var aiCompanionTranscriptProgressByMeetingId: [String: String] = [:]
  499. private var aiCompanionNotesProgressByMeetingId: [String: String] = [:]
  500. private var darkModeEnabled: Bool {
  501. get {
  502. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  503. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  504. }
  505. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  506. }
  507. private var nonPremiumJoinTrialConsumed: Bool {
  508. get { UserDefaults.standard.bool(forKey: nonPremiumJoinTrialConsumedDefaultsKey) }
  509. set { UserDefaults.standard.set(newValue, forKey: nonPremiumJoinTrialConsumedDefaultsKey) }
  510. }
  511. private var shouldGateJoinActionsForNonPremium: Bool {
  512. !storeKitCoordinator.hasPremiumAccess && nonPremiumJoinTrialConsumed
  513. }
  514. private func makeSettingsPopover() -> NSPopover {
  515. let popover = NSPopover()
  516. popover.behavior = .transient
  517. popover.animates = true
  518. let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
  519. let showRateUsInSettings = shouldShowRateUsInSettings
  520. let speechOptions = aiCompanionSupportedSpeechLocaleOptions()
  521. popover.contentViewController = SettingsMenuViewController(
  522. palette: palette,
  523. typography: typography,
  524. darkModeEnabled: darkModeEnabled,
  525. showRateUsInSettings: showRateUsInSettings,
  526. showUpgradeInSettings: showUpgradeInSettings,
  527. speechLocaleOptions: speechOptions,
  528. selectedPrimaryLanguageIdentifier: UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
  529. selectedSecondaryLanguageIdentifier: UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
  530. onToggleDarkMode: { [weak self] enabled in
  531. self?.setDarkMode(enabled)
  532. },
  533. onUpdatePreferredSpeechLanguages: { [weak self] primary, secondary in
  534. self?.updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
  535. },
  536. onAction: { [weak self] action, sourceView, clickPoint in
  537. self?.handleSettingsAction(action, sourceView: sourceView, clickLocationInSourceView: clickPoint)
  538. }
  539. )
  540. return popover
  541. }
  542. private var settingsPopover: NSPopover?
  543. override func viewDidLoad() {
  544. super.viewDidLoad()
  545. aiCompanionSpeechSynthesizer.delegate = self
  546. loadAiCompanionLocalRecordings()
  547. // Sync toggle + palette with current macOS appearance on launch.
  548. darkModeEnabled = systemPrefersDarkMode()
  549. palette = Palette(isDarkMode: darkModeEnabled)
  550. storeKitCoordinator.onEntitlementsChanged = { [weak self] hasPremiumAccess in
  551. guard let self else { return }
  552. self.handlePremiumAccessChanged(hasPremiumAccess)
  553. }
  554. NotificationCenter.default.post(
  555. name: .statusBarPremiumAccessChanged,
  556. object: nil,
  557. userInfo: ["hasPremiumAccess": storeKitCoordinator.hasPremiumAccess]
  558. )
  559. migrateLegacyRatingStateIfNeeded()
  560. beginUsageTrackingSessionIfNeeded()
  561. observeAppLifecycleForUsageTrackingIfNeeded()
  562. registerWidgetNotificationObservers()
  563. setupRootView()
  564. buildMainLayout()
  565. showLaunchSplashIfNeeded()
  566. startStoreKit()
  567. }
  568. override func viewDidAppear() {
  569. super.viewDidAppear()
  570. DesktopWidgetWindowManager.shared.restore()
  571. hasViewAppearedOnce = true
  572. presentLaunchPaywallIfNeeded()
  573. dismissLaunchSplashIfReady()
  574. applyWindowTitle(for: selectedSidebarPage)
  575. guard let window = view.window else { return }
  576. configureMainWindowChrome(window)
  577. // Ensure launch size is applied even when macOS tries to restore prior window state.
  578. window.isRestorable = false
  579. window.setFrameAutosaveName("")
  580. DispatchQueue.main.async { [weak self, weak window] in
  581. guard let self, let window else { return }
  582. let frameSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchContentSize)).size
  583. var newFrame = window.frame
  584. newFrame.size = frameSize
  585. window.setFrame(newFrame, display: true)
  586. window.center()
  587. if let screen = window.screen ?? NSScreen.main {
  588. var adjustedFrame = window.frame
  589. adjustedFrame.origin.x -= self.launchWindowLeftOffset
  590. let minX = screen.visibleFrame.minX
  591. adjustedFrame.origin.x = max(minX, adjustedFrame.origin.x)
  592. window.setFrame(adjustedFrame, display: true)
  593. }
  594. window.minSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchMinContentSize)).size
  595. }
  596. }
  597. override var representedObject: Any? {
  598. didSet {}
  599. }
  600. deinit {
  601. premiumUpgradeRatingPromptWorkItem?.cancel()
  602. endUsageTrackingSession()
  603. launchSplashTimeoutWorkItem?.cancel()
  604. launchSplashMinimumDelayWorkItem?.cancel()
  605. NotificationCenter.default.removeObserver(self)
  606. if let observer = schedulePageScrollObservation {
  607. NotificationCenter.default.removeObserver(observer)
  608. }
  609. storeKitStartupTask?.cancel()
  610. paywallPurchaseTask?.cancel()
  611. launchPaywallWorkItem?.cancel()
  612. meetingRecordingMonitorTask?.cancel()
  613. }
  614. }
  615. private extension ViewController {
  616. func setupRootView() {
  617. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  618. view.wantsLayer = true
  619. view.layer?.backgroundColor = palette.pageBackground.cgColor
  620. }
  621. private func makeLaunchSplashTheme() -> LaunchSplashView.Theme {
  622. let borderAlpha: CGFloat = darkModeEnabled ? 0.9 : 0.45
  623. let cardBackground = darkModeEnabled ? palette.sectionCard : palette.tabBarBackground
  624. let cardBorder = darkModeEnabled ? palette.inputBorder : palette.separator
  625. return LaunchSplashView.Theme(
  626. background: palette.pageBackground,
  627. cardBackground: cardBackground,
  628. cardBorder: cardBorder.withAlphaComponent(borderAlpha),
  629. titleText: palette.textPrimary,
  630. subtitleText: palette.textSecondary,
  631. accent: palette.primaryBlue
  632. )
  633. }
  634. private func showLaunchSplashIfNeeded() {
  635. guard launchSplashView == nil else { return }
  636. let splash = LaunchSplashView(frame: .zero)
  637. splash.alphaValue = 1
  638. let displayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)?
  639. .trimmingCharacters(in: .whitespacesAndNewlines)
  640. let fallbackName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
  641. let resolvedName = (displayName?.isEmpty == false ? displayName : fallbackName) ?? "Assistant for Google Meet"
  642. splash.configure(appName: resolvedName, appIcon: NSApp.applicationIconImage, theme: makeLaunchSplashTheme())
  643. view.addSubview(splash)
  644. NSLayoutConstraint.activate([
  645. splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  646. splash.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  647. splash.topAnchor.constraint(equalTo: view.topAnchor),
  648. splash.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  649. ])
  650. view.layoutSubtreeIfNeeded()
  651. launchSplashView = splash
  652. launchSplashShownAt = Date()
  653. launchSplashTimeoutWorkItem?.cancel()
  654. let timeoutWorkItem = DispatchWorkItem { [weak self] in
  655. self?.dismissLaunchSplash(force: true)
  656. }
  657. launchSplashTimeoutWorkItem = timeoutWorkItem
  658. DispatchQueue.main.asyncAfter(deadline: .now() + launchSplashTimeout, execute: timeoutWorkItem)
  659. }
  660. private func dismissLaunchSplashIfReady() {
  661. guard hasCompletedInitialStoreKitSync else { return }
  662. guard !hasDismissedLaunchSplash else { return }
  663. if let shownAt = launchSplashShownAt {
  664. let elapsed = Date().timeIntervalSince(shownAt)
  665. let remaining = launchSplashMinimumVisibleDuration - elapsed
  666. if remaining > 0 {
  667. launchSplashMinimumDelayWorkItem?.cancel()
  668. let minDelayWorkItem = DispatchWorkItem { [weak self] in
  669. self?.dismissLaunchSplash(force: false)
  670. }
  671. launchSplashMinimumDelayWorkItem = minDelayWorkItem
  672. DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: minDelayWorkItem)
  673. return
  674. }
  675. }
  676. dismissLaunchSplash(force: false)
  677. }
  678. private func dismissLaunchSplash(force: Bool) {
  679. guard !hasDismissedLaunchSplash else { return }
  680. guard let splash = launchSplashView else { return }
  681. if !force && !hasCompletedInitialStoreKitSync { return }
  682. hasDismissedLaunchSplash = true
  683. launchSplashTimeoutWorkItem?.cancel()
  684. launchSplashTimeoutWorkItem = nil
  685. launchSplashMinimumDelayWorkItem?.cancel()
  686. launchSplashMinimumDelayWorkItem = nil
  687. let removeSplash: () -> Void = { [weak self] in
  688. splash.removeFromSuperview()
  689. self?.launchSplashView = nil
  690. self?.launchSplashShownAt = nil
  691. }
  692. let fadeOutAndRemove: () -> Void = {
  693. NSAnimationContext.runAnimationGroup({ context in
  694. context.duration = 0.24
  695. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  696. splash.animator().alphaValue = 0
  697. }, completionHandler: removeSplash)
  698. }
  699. if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
  700. splash.completeLoading(duration: 0) {
  701. removeSplash()
  702. }
  703. return
  704. }
  705. splash.completeLoading {
  706. fadeOutAndRemove()
  707. }
  708. }
  709. func systemPrefersDarkMode() -> Bool {
  710. // Use the system-wide appearance setting (not app/window effective appearance).
  711. // When the key is missing, macOS is in Light mode.
  712. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  713. let style = global?["AppleInterfaceStyle"] as? String
  714. return style?.lowercased() == "dark"
  715. }
  716. func buildMainLayout() {
  717. let splitContainer = NSStackView()
  718. splitContainer.translatesAutoresizingMaskIntoConstraints = false
  719. splitContainer.orientation = .horizontal
  720. splitContainer.spacing = 14
  721. splitContainer.distribution = .fill
  722. splitContainer.alignment = .top
  723. view.addSubview(splitContainer)
  724. NSLayoutConstraint.activate([
  725. splitContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  726. splitContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  727. splitContainer.topAnchor.constraint(equalTo: view.topAnchor),
  728. splitContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  729. ])
  730. let sidebar = makeSidebar()
  731. let mainPanel = makeMainPanel()
  732. sidebar.setContentHuggingPriority(.required, for: .horizontal)
  733. sidebar.setContentCompressionResistancePriority(.required, for: .horizontal)
  734. mainPanel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  735. mainPanel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  736. splitContainer.addArrangedSubview(sidebar)
  737. splitContainer.addArrangedSubview(mainPanel)
  738. }
  739. @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) {
  740. guard let view = sender.view else { return }
  741. activateSidebarItem(view)
  742. }
  743. private func activateSidebarItem(_ view: NSView) {
  744. guard let page = sidebarPageByView[ObjectIdentifier(view)],
  745. page != selectedSidebarPage || page == .settings else { return }
  746. showSidebarPage(page)
  747. }
  748. @objc private func zoomJoinModeClicked(_ sender: NSClickGestureRecognizer) {
  749. guard let view = sender.view,
  750. let mode = zoomJoinModeByView[ObjectIdentifier(view)],
  751. mode != selectedZoomJoinMode else { return }
  752. selectedZoomJoinMode = mode
  753. updateZoomJoinModeAppearance()
  754. if selectedSidebarPage == .joinMeetings {
  755. pageCache[.joinMeetings] = nil
  756. showSidebarPage(.joinMeetings)
  757. }
  758. }
  759. @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
  760. if storeKitCoordinator.hasLifetimeAccess {
  761. openManageSubscriptions()
  762. } else if storeKitCoordinator.hasPremiumAccess {
  763. showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
  764. } else {
  765. showPaywall()
  766. }
  767. }
  768. @objc private func sidebarButtonClicked(_ sender: NSButton) {
  769. guard let page = SidebarPage(rawValue: sender.tag),
  770. page != selectedSidebarPage || page == .settings else { return }
  771. showSidebarPage(page)
  772. }
  773. private func registerWidgetNotificationObservers() {
  774. NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenJoinMeetingsPageRequested), name: .widgetOpenJoinMeetingsPage, object: nil)
  775. NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenSchedulePageRequested), name: .widgetOpenSchedulePage, object: nil)
  776. NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenCalendarPageRequested), name: .widgetOpenCalendarPage, object: nil)
  777. NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenSettingsPageRequested), name: .widgetOpenSettingsPage, object: nil)
  778. NotificationCenter.default.addObserver(self, selector: #selector(widgetSignInRequested), name: .widgetSignInRequested, object: nil)
  779. NotificationCenter.default.addObserver(self, selector: #selector(widgetRefreshRequested), name: .widgetRefreshRequested, object: nil)
  780. NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenMeetWebRequested), name: .widgetOpenMeetWebRequested, object: nil)
  781. NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenMeetingLinkRequested(_:)), name: .widgetOpenMeetingLinkRequested, object: nil)
  782. NotificationCenter.default.addObserver(self, selector: #selector(statusBarOpenSidebarPageRequested(_:)), name: .statusBarOpenSidebarPage, object: nil)
  783. NotificationCenter.default.addObserver(self, selector: #selector(statusBarSignOutRequested), name: .statusBarSignOutRequested, object: nil)
  784. }
  785. @objc private func widgetOpenJoinMeetingsPageRequested() { showSidebarPage(.joinMeetings) }
  786. @objc private func widgetOpenSchedulePageRequested() { showSidebarPage(.photo) }
  787. @objc private func widgetOpenCalendarPageRequested() { showSidebarPage(.video) }
  788. @objc private func widgetOpenSettingsPageRequested() { showSidebarPage(.settings) }
  789. @objc private func widgetSignInRequested() { scheduleConnectClicked() }
  790. @objc private func widgetRefreshRequested() { scheduleReloadClicked() }
  791. @objc private func widgetOpenMeetWebRequested() {
  792. guard let url = URL(string: "https://meet.google.com/") else { return }
  793. openInDefaultBrowser(url: url)
  794. }
  795. @objc private func widgetOpenMeetingLinkRequested(_ notification: Notification) {
  796. guard let link = notification.userInfo?["link"] as? String,
  797. let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
  798. openInDefaultBrowser(url: url)
  799. }
  800. @objc private func statusBarOpenSidebarPageRequested(_ notification: Notification) {
  801. guard let pageRaw = notification.userInfo?["page"] as? Int,
  802. let page = SidebarPage(rawValue: pageRaw) else { return }
  803. showSidebarPage(page)
  804. }
  805. @objc private func statusBarSignOutRequested() { performGoogleSignOut() }
  806. @objc private func joinMeetClicked(_ sender: Any?) {
  807. guard shouldGateJoinActionsForNonPremium == false else {
  808. showPaywall()
  809. return
  810. }
  811. let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  812. guard let url = normalizedMeetJoinURL(from: rawInput) else {
  813. showSimpleAlert(
  814. title: "Invalid Meet link",
  815. message: "Enter a valid Google Meet link or meeting code (for example nkd-grps-duv, meet.google.com/nkd-grps-duv, or https://meet.google.com/nkd-grps-duv)."
  816. )
  817. return
  818. }
  819. let shouldOpenMeeting = beginMeetingRecordingIfConsented(meetingTitle: "Quick Join Meeting", meetingURL: url)
  820. guard shouldOpenMeeting else { return }
  821. openInDefaultBrowser(url: url)
  822. consumeNonPremiumJoinTrialIfNeeded()
  823. }
  824. @objc private func joinWithLinkCardClicked(_ sender: NSClickGestureRecognizer) {
  825. meetLinkField?.window?.makeFirstResponder(meetLinkField)
  826. }
  827. @objc private func cancelMeetJoinClicked(_ sender: Any?) {
  828. meetLinkField?.stringValue = ""
  829. }
  830. @objc private func browseOpenAddressClicked(_ sender: Any?) {
  831. let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  832. guard raw.isEmpty == false else {
  833. showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
  834. return
  835. }
  836. let normalized = normalizedURLString(from: raw)
  837. guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
  838. showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
  839. return
  840. }
  841. openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
  842. }
  843. @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
  844. guard let url = URL(string: "https://meet.google.com/") else { return }
  845. openInDefaultBrowser(url: url)
  846. }
  847. @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
  848. guard let url = URL(string: "https://support.google.com/meet") else { return }
  849. openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
  850. }
  851. @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
  852. guard let url = URL(string: "https://support.zoom.us") else { return }
  853. openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
  854. }
  855. @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
  856. guard shouldGateJoinActionsForNonPremium == false else {
  857. showPaywall()
  858. return
  859. }
  860. // Meet blocks WKWebView. To support instant-stop recording we must create a real Meet space (with meeting code)
  861. // before launching the user's browser.
  862. guard hasGoogleSessionAvailable() else {
  863. showSimpleAlert(
  864. title: "Connect Google to start Instant Meeting",
  865. message: "To auto-stop recording when the meeting ends, connect your Google account first."
  866. )
  867. guard let fallbackURL = URL(string: "https://meet.google.com/new") else { return }
  868. openInDefaultBrowser(url: fallbackURL)
  869. consumeNonPremiumJoinTrialIfNeeded()
  870. return
  871. }
  872. let title = "Instant Meeting"
  873. let consentChoice = requestMeetingRecordingConsent(title: title)
  874. if consentChoice == .dismissed { return }
  875. meetingRecordingMonitorTask?.cancel()
  876. meetingRecordingMonitorTask = nil
  877. if activeMeetingRecordingSession != nil { finishActiveMeetingRecording(cancelMonitoring: false) }
  878. Task { [weak self] in
  879. guard let self else { return }
  880. do {
  881. let now = Date()
  882. let end = now.addingTimeInterval(60 * 60) // 1h placeholder; Meet lifecycle monitor will stop recording when call ends.
  883. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  884. let meeting = try await self.calendarClient.createEvent(
  885. accessToken: token,
  886. title: title,
  887. description: nil,
  888. start: now,
  889. end: end
  890. )
  891. DispatchQueue.main.async {
  892. if consentChoice == .allowAndContinue {
  893. // Start Meet API-based recording lifecycle monitoring using the real meeting URL (has code).
  894. self.startAutomaticRecordingFlow(meetingTitle: title, meetingURL: meeting.meetURL)
  895. }
  896. self.openInDefaultBrowser(url: meeting.meetURL)
  897. self.consumeNonPremiumJoinTrialIfNeeded()
  898. }
  899. } catch {
  900. DispatchQueue.main.async {
  901. self.showSimpleAlert(title: "Could not create Instant Meeting", message: error.localizedDescription)
  902. }
  903. }
  904. }
  905. }
  906. private func consumeNonPremiumJoinTrialIfNeeded() {
  907. guard !storeKitCoordinator.hasPremiumAccess,
  908. !nonPremiumJoinTrialConsumed else { return }
  909. nonPremiumJoinTrialConsumed = true
  910. refreshInstantMeetPremiumState()
  911. }
  912. private func normalizedURLString(from value: String) -> String {
  913. if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
  914. return value
  915. }
  916. return "https://\(value)"
  917. }
  918. /// Typical Meet meeting code shape: three hyphen-separated groups (e.g. `nkd-grps-duv`).
  919. private func isValidMeetMeetingCode(_ code: String) -> Bool {
  920. let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
  921. guard trimmed.isEmpty == false else { return false }
  922. let pattern = "^[a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{3}$"
  923. return trimmed.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
  924. }
  925. /// Accepts `https://meet.google.com/...`, `meet.google.com/...`, or a bare code; returns canonical Meet URL or `nil`.
  926. private func normalizedMeetJoinURL(from rawInput: String) -> URL? {
  927. let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
  928. guard trimmed.isEmpty == false else { return nil }
  929. let lower = trimmed.lowercased()
  930. if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
  931. guard let url = URL(string: trimmed),
  932. let host = url.host?.lowercased(),
  933. host == "meet.google.com" || host.hasSuffix(".meet.google.com") else {
  934. return nil
  935. }
  936. let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  937. guard path.isEmpty == false else { return nil }
  938. let firstSegment = path.split(separator: "/").first.map(String.init) ?? path
  939. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  940. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  941. }
  942. if lower.hasPrefix("meet.google.com/") {
  943. let afterHost = trimmed.dropFirst("meet.google.com/".count)
  944. let beforeQuery = String(afterHost).split(separator: "?").first.map(String.init) ?? String(afterHost)
  945. let firstSegment = beforeQuery.split(separator: "/").first.map(String.init) ?? beforeQuery
  946. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  947. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  948. }
  949. if isValidMeetMeetingCode(trimmed) {
  950. return URL(string: "https://meet.google.com/\(trimmed.lowercased())")
  951. }
  952. return nil
  953. }
  954. private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
  955. let browserController: InAppBrowserWindowController
  956. if let existing = inAppBrowserWindowController {
  957. browserController = existing
  958. } else {
  959. browserController = InAppBrowserWindowController()
  960. inAppBrowserWindowController = browserController
  961. }
  962. browserController.load(url: url, policy: policy)
  963. browserController.applyDefaultFrameCenteredOnVisibleScreen()
  964. browserController.showWindow(nil)
  965. browserController.window?.makeKeyAndOrderFront(nil)
  966. browserController.window?.orderFrontRegardless()
  967. NSApp.activate(ignoringOtherApps: true)
  968. }
  969. private func openInDefaultBrowser(url: URL) {
  970. let routed = googleOAuth.urlForPreferredGoogleAccountIfPossible(url)
  971. NSWorkspace.shared.open(routed, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
  972. if let error {
  973. DispatchQueue.main.async {
  974. self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription)
  975. }
  976. }
  977. }
  978. }
  979. private func openURLWithRouting(_ url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
  980. let scheme = (url.scheme ?? "").lowercased()
  981. if scheme == "http" || scheme == "https" {
  982. showEmbeddedWebPage(url, policy: policy)
  983. return
  984. }
  985. openInDefaultBrowser(url: url)
  986. }
  987. private func embeddedBrowserController() -> InAppBrowserContainerViewController {
  988. if let existing = embeddedBrowserViewController {
  989. return existing
  990. }
  991. let controller = InAppBrowserContainerViewController()
  992. embeddedBrowserViewController = controller
  993. return controller
  994. }
  995. private func detachEmbeddedBrowserIfNeeded() {
  996. guard let controller = embeddedBrowserViewController, controller.parent === self else { return }
  997. controller.view.removeFromSuperview()
  998. controller.removeFromParent()
  999. }
  1000. private func mountMainContentView(_ child: NSView) {
  1001. guard let host = mainContentHost else { return }
  1002. NSLayoutConstraint.deactivate(mainContentHostPinConstraints)
  1003. mainContentHostPinConstraints.removeAll()
  1004. host.subviews.forEach { $0.removeFromSuperview() }
  1005. child.translatesAutoresizingMaskIntoConstraints = false
  1006. host.addSubview(child)
  1007. mainContentHostPinConstraints = [
  1008. child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  1009. child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  1010. child.topAnchor.constraint(equalTo: host.topAnchor),
  1011. child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
  1012. ]
  1013. NSLayoutConstraint.activate(mainContentHostPinConstraints)
  1014. }
  1015. private func showEmbeddedWebPage(_ url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
  1016. embeddedBrowserURL = url
  1017. embeddedBrowserPolicy = policy
  1018. let controller = embeddedBrowserController()
  1019. _ = controller.view
  1020. if controller.parent !== self {
  1021. addChild(controller)
  1022. }
  1023. controller.setNavigationPolicy(policy)
  1024. controller.load(url: url)
  1025. setEmbeddedBrowserLayoutMode(isEmbedded: true)
  1026. applyEmbeddedBrowserWindowTitle(url: url)
  1027. mountMainContentView(controller.view)
  1028. }
  1029. private func setEmbeddedBrowserLayoutMode(isEmbedded: Bool) {
  1030. mainPanelAuthBar?.isHidden = isEmbedded
  1031. mainContentHostTopToAuthConstraint?.isActive = !isEmbedded
  1032. mainContentHostTopToPanelConstraint?.isActive = isEmbedded
  1033. }
  1034. private func applyEmbeddedBrowserWindowTitle(url: URL) {
  1035. if let host = url.host, host.isEmpty == false {
  1036. view.window?.title = host
  1037. } else {
  1038. view.window?.title = "Browser"
  1039. }
  1040. }
  1041. private func openRateUsDestination() {
  1042. let configured = (Bundle.main.object(forInfoDictionaryKey: "RateUsURL") as? String)?
  1043. .trimmingCharacters(in: .whitespacesAndNewlines)
  1044. let placeholder = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String)?
  1045. .trimmingCharacters(in: .whitespacesAndNewlines)
  1046. let hardcodedRateUsURL = "https://apps.apple.com/pk/app/meeting-app-for-google-meet/id6654920763?mt=12"
  1047. var candidateStrings = [String]()
  1048. if let configured, !configured.isEmpty {
  1049. candidateStrings.append(configured)
  1050. if let appID = extractAppleAppID(from: configured) {
  1051. candidateStrings.append("macappstore://apps.apple.com/app/id\(appID)")
  1052. candidateStrings.append("macappstore://itunes.apple.com/app/id\(appID)")
  1053. }
  1054. }
  1055. candidateStrings.append(hardcodedRateUsURL)
  1056. candidateStrings.append("macappstore://apps.apple.com/app/id6654920763")
  1057. candidateStrings.append("macappstore://itunes.apple.com/app/id6654920763")
  1058. if let placeholder, !placeholder.isEmpty {
  1059. candidateStrings.append(placeholder)
  1060. }
  1061. for candidate in candidateStrings {
  1062. guard let url = URL(string: candidate) else { continue }
  1063. if NSWorkspace.shared.open(url) {
  1064. return
  1065. }
  1066. }
  1067. showSimpleAlert(
  1068. title: "Unable to Open Rate Page",
  1069. message: "Could not open the App Store rating page. Please try again."
  1070. )
  1071. requestAppRatingIfEligible(markAsRated: true)
  1072. }
  1073. private func extractAppleAppID(from urlString: String) -> String? {
  1074. guard let match = urlString.range(of: "id[0-9]+", options: .regularExpression) else {
  1075. return nil
  1076. }
  1077. let token = String(urlString[match])
  1078. return String(token.dropFirst())
  1079. }
  1080. private func openInSafari(url: URL) {
  1081. guard let safariAppURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else {
  1082. NSWorkspace.shared.open(url)
  1083. return
  1084. }
  1085. let configuration = NSWorkspace.OpenConfiguration()
  1086. NSWorkspace.shared.open([url], withApplicationAt: safariAppURL, configuration: configuration) { _, error in
  1087. if let error {
  1088. self.showSimpleAlert(title: "Unable to Open Safari", message: error.localizedDescription)
  1089. }
  1090. }
  1091. }
  1092. private func openManageSubscriptions() {
  1093. if let appStoreURL = URL(string: "macappstore://apps.apple.com/account/subscriptions"),
  1094. NSWorkspace.shared.open(appStoreURL) {
  1095. return
  1096. }
  1097. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else {
  1098. showSimpleAlert(title: "Unable to Open Subscriptions", message: "The subscriptions URL is invalid.")
  1099. return
  1100. }
  1101. openInDefaultBrowser(url: url)
  1102. }
  1103. private func openRestoreSubscriptionPage() {
  1104. let fallbackURL = "https://support.apple.com/en-us/108096"
  1105. let restoreURL = (Bundle.main.object(forInfoDictionaryKey: "RestoreSubscriptionURL") as? String) ?? fallbackURL
  1106. guard let url = URL(string: restoreURL) else {
  1107. if let fallback = URL(string: fallbackURL) {
  1108. NSWorkspace.shared.open(fallback)
  1109. }
  1110. return
  1111. }
  1112. NSWorkspace.shared.open(url)
  1113. }
  1114. private func performRestorePurchases() {
  1115. Task { [weak self] in
  1116. guard let self else { return }
  1117. let message = await self.storeKitCoordinator.restorePurchases()
  1118. self.refreshPaywallStoreUI()
  1119. self.showSimpleAlert(title: "Restore Purchases", message: message)
  1120. }
  1121. }
  1122. private func shareAppURL() -> URL? {
  1123. if let configured = Bundle.main.object(forInfoDictionaryKey: "AppShareURL") as? String {
  1124. let trimmed = configured.trimmingCharacters(in: .whitespacesAndNewlines)
  1125. if trimmed.isEmpty == false, let url = URL(string: trimmed) {
  1126. return url
  1127. }
  1128. }
  1129. return nil
  1130. }
  1131. private func shareAppFromSettingsMenu(sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) {
  1132. let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
  1133. ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
  1134. ?? "Meetings App"
  1135. let message = "Check out \(appName) for managing and joining meetings."
  1136. let appURL = shareAppURL()
  1137. let shareItems: [Any] = appURL.map { [message, $0] } ?? [message]
  1138. let picker = NSSharingServicePicker(items: shareItems)
  1139. let anchorView = sourceView ?? sidebarRowViews[.settings] ?? view
  1140. let anchorPoint = clickLocationInSourceView
  1141. ?? NSPoint(x: anchorView.bounds.midX, y: anchorView.bounds.midY)
  1142. let anchorRect = NSRect(x: anchorPoint.x, y: anchorPoint.y, width: 1, height: 1)
  1143. picker.show(relativeTo: anchorRect, of: anchorView, preferredEdge: .minY)
  1144. let clipboardText = ([message, appURL?.absoluteString].compactMap { $0 }).joined(separator: "\n")
  1145. NSPasteboard.general.clearContents()
  1146. NSPasteboard.general.setString(clipboardText, forType: .string)
  1147. }
  1148. private func showSidebarPage(_ page: SidebarPage) {
  1149. selectedSidebarPage = page
  1150. updateSidebarAppearance()
  1151. applyWindowTitle(for: page)
  1152. setEmbeddedBrowserLayoutMode(isEmbedded: false)
  1153. detachEmbeddedBrowserIfNeeded()
  1154. let child = viewForPage(page)
  1155. mountMainContentView(child)
  1156. }
  1157. private func showSettingsPopover() {
  1158. guard let anchor = sidebarRowViews[.settings] else { return }
  1159. if settingsPopover?.isShown == true {
  1160. settingsPopover?.performClose(nil)
  1161. return
  1162. }
  1163. settingsPopover = makeSettingsPopover()
  1164. if let menu = settingsPopover?.contentViewController as? SettingsMenuViewController {
  1165. menu.setDarkModeEnabled(darkModeEnabled)
  1166. }
  1167. settingsPopover?.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
  1168. }
  1169. private func setDarkMode(_ enabled: Bool) {
  1170. darkModeEnabled = enabled
  1171. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  1172. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  1173. palette = Palette(isDarkMode: enabled)
  1174. launchSplashView?.apply(theme: makeLaunchSplashTheme())
  1175. reloadTheme()
  1176. }
  1177. private func reloadTheme() {
  1178. pageCache.removeAll()
  1179. if let observer = schedulePageScrollObservation {
  1180. NotificationCenter.default.removeObserver(observer)
  1181. }
  1182. schedulePageScrollObservation = nil
  1183. sidebarRowViews.removeAll()
  1184. sidebarPageByView.removeAll()
  1185. zoomJoinModeByView.removeAll()
  1186. zoomJoinModeViews.removeAll()
  1187. settingsActionByView.removeAll()
  1188. paywallPlanViews.removeAll()
  1189. premiumPlanByView.removeAll()
  1190. paywallFooterActionByView.removeAll()
  1191. paywallPriceLabels.removeAll()
  1192. paywallSubtitleLabels.removeAll()
  1193. paywallBadgeLabels.removeAll()
  1194. paywallStrikePriceLabels.removeAll()
  1195. paywallContinueLabel = nil
  1196. paywallContinueButton = nil
  1197. paywallContinueEnabled = true
  1198. detachEmbeddedBrowserIfNeeded()
  1199. embeddedBrowserViewController = nil
  1200. embeddedBrowserURL = nil
  1201. embeddedBrowserPolicy = .allowAll
  1202. mainPanelAuthBar = nil
  1203. mainContentHostTopToAuthConstraint = nil
  1204. mainContentHostTopToPanelConstraint = nil
  1205. googleAccountPopover?.performClose(nil)
  1206. googleAccountPopover = nil
  1207. NSLayoutConstraint.deactivate(mainContentHostPinConstraints)
  1208. mainContentHostPinConstraints.removeAll()
  1209. mainContentHost = nil
  1210. view.subviews.forEach { $0.removeFromSuperview() }
  1211. setupRootView()
  1212. buildMainLayout()
  1213. showSidebarPage(selectedSidebarPage)
  1214. }
  1215. private func handleSettingsAction(_ action: SettingsAction, sourceView: NSView? = nil, clickLocationInSourceView: NSPoint? = nil) {
  1216. switch action {
  1217. case .restore:
  1218. performRestorePurchases()
  1219. case .rateUs:
  1220. openRateUsDestination()
  1221. case .support:
  1222. openSettingsLink(infoKey: "SupportURL")
  1223. case .privacyPolicy:
  1224. openSettingsLink(infoKey: "PrivacyPolicyURL")
  1225. case .termsOfServices:
  1226. openSettingsLink(infoKey: "TermsOfServiceURL")
  1227. case .moreApps:
  1228. if let moreAppsURL = Bundle.main.object(forInfoDictionaryKey: "MoreAppsURL") as? String,
  1229. let url = URL(string: moreAppsURL) {
  1230. openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
  1231. }
  1232. case .shareApp:
  1233. shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
  1234. case .upgrade:
  1235. showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
  1236. }
  1237. }
  1238. private func openSettingsLink(infoKey: String) {
  1239. let defaultURL = (Bundle.main.object(forInfoDictionaryKey: "AppLaunchPlaceholderURL") as? String) ?? "https://example.com/app-link-coming-soon"
  1240. let urlString = (Bundle.main.object(forInfoDictionaryKey: infoKey) as? String) ?? defaultURL
  1241. guard let url = URL(string: urlString) else { return }
  1242. openURLWithRouting(url, policy: inAppBrowserDefaultPolicy)
  1243. }
  1244. private func showSimpleAlert(title: String, message: String) {
  1245. let alert = NSAlert()
  1246. alert.messageText = title
  1247. alert.informativeText = message
  1248. alert.addButton(withTitle: "OK")
  1249. alert.runModal()
  1250. }
  1251. private enum MeetingRecordingConsentChoice {
  1252. case allowAndContinue
  1253. case continueWithoutRecording
  1254. case dismissed
  1255. }
  1256. private func requestMeetingRecordingConsent(title: String) -> MeetingRecordingConsentChoice {
  1257. let alert = NSAlert()
  1258. alert.messageText = "Record this meeting locally?"
  1259. alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
  1260. alert.addButton(withTitle: "Allow and Continue")
  1261. alert.addButton(withTitle: "Continue Without Recording")
  1262. alert.window.title = title
  1263. let speechOptions = aiCompanionSupportedSpeechLocaleOptions()
  1264. let languageContainer = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 96))
  1265. languageContainer.translatesAutoresizingMaskIntoConstraints = false
  1266. languageContainer.widthAnchor.constraint(equalToConstant: 320).isActive = true
  1267. languageContainer.heightAnchor.constraint(equalToConstant: 96).isActive = true
  1268. let languageTitle = NSTextField(labelWithString: "Transcription Languages")
  1269. languageTitle.translatesAutoresizingMaskIntoConstraints = false
  1270. languageTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1271. languageTitle.textColor = NSColor.secondaryLabelColor
  1272. let language1Popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1273. language1Popup.translatesAutoresizingMaskIntoConstraints = false
  1274. language1Popup.controlSize = .small
  1275. language1Popup.target = self
  1276. language1Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
  1277. for option in speechOptions {
  1278. language1Popup.addItem(withTitle: option.displayName)
  1279. language1Popup.lastItem?.representedObject = option.identifier
  1280. }
  1281. let selectedPrimary = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
  1282. ?? speechOptions.first?.identifier
  1283. ?? Locale.current.identifier
  1284. selectSettingsPageLanguage(identifier: selectedPrimary, in: language1Popup)
  1285. let language2Popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1286. language2Popup.translatesAutoresizingMaskIntoConstraints = false
  1287. language2Popup.controlSize = .small
  1288. language2Popup.target = self
  1289. language2Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
  1290. language2Popup.addItem(withTitle: "None")
  1291. language2Popup.lastItem?.representedObject = ""
  1292. for option in speechOptions {
  1293. language2Popup.addItem(withTitle: option.displayName)
  1294. language2Popup.lastItem?.representedObject = option.identifier
  1295. }
  1296. if let selectedSecondary = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
  1297. selectedSecondary.isEmpty == false {
  1298. selectSettingsPageLanguage(identifier: selectedSecondary, in: language2Popup)
  1299. } else {
  1300. language2Popup.selectItem(at: 0)
  1301. }
  1302. let language1Label = NSTextField(labelWithString: "Preferred Language 1")
  1303. language1Label.translatesAutoresizingMaskIntoConstraints = false
  1304. language1Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1305. language1Label.textColor = NSColor.secondaryLabelColor
  1306. language1Label.alignment = .center
  1307. let language2Label = NSTextField(labelWithString: "Preferred Language 2")
  1308. language2Label.translatesAutoresizingMaskIntoConstraints = false
  1309. language2Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1310. language2Label.textColor = NSColor.secondaryLabelColor
  1311. language2Label.alignment = .center
  1312. languageContainer.addSubview(languageTitle)
  1313. languageContainer.addSubview(language1Label)
  1314. languageContainer.addSubview(language1Popup)
  1315. languageContainer.addSubview(language2Label)
  1316. languageContainer.addSubview(language2Popup)
  1317. NSLayoutConstraint.activate([
  1318. languageTitle.topAnchor.constraint(equalTo: languageContainer.topAnchor, constant: 0),
  1319. languageTitle.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1320. language1Label.topAnchor.constraint(equalTo: languageTitle.bottomAnchor, constant: 2),
  1321. language1Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1322. language1Popup.topAnchor.constraint(equalTo: language1Label.bottomAnchor, constant: 2),
  1323. language1Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1324. language1Popup.widthAnchor.constraint(equalToConstant: 240),
  1325. language2Label.topAnchor.constraint(equalTo: language1Popup.bottomAnchor, constant: 3),
  1326. language2Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1327. language2Popup.topAnchor.constraint(equalTo: language2Label.bottomAnchor, constant: 2),
  1328. language2Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1329. language2Popup.widthAnchor.constraint(equalToConstant: 240),
  1330. language2Popup.bottomAnchor.constraint(equalTo: languageContainer.bottomAnchor, constant: -1)
  1331. ])
  1332. alert.accessoryView = languageContainer
  1333. didTapInlineMeetingConsentClose = false
  1334. activeMeetingConsentWindow = alert.window
  1335. meetingConsentPrimaryLanguagePopup = language1Popup
  1336. meetingConsentSecondaryLanguagePopup = language2Popup
  1337. if let contentView = alert.window.contentView {
  1338. let topCloseButton = NSButton(title: "✕", target: self, action: #selector(meetingConsentInlineCloseTapped(_:)))
  1339. topCloseButton.translatesAutoresizingMaskIntoConstraints = false
  1340. topCloseButton.isBordered = false
  1341. topCloseButton.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  1342. topCloseButton.contentTintColor = NSColor.tertiaryLabelColor
  1343. topCloseButton.wantsLayer = true
  1344. topCloseButton.layer?.cornerRadius = 20
  1345. topCloseButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.35).cgColor
  1346. contentView.addSubview(topCloseButton)
  1347. NSLayoutConstraint.activate([
  1348. topCloseButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
  1349. topCloseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
  1350. topCloseButton.widthAnchor.constraint(equalToConstant: 40),
  1351. topCloseButton.heightAnchor.constraint(equalToConstant: 40)
  1352. ])
  1353. }
  1354. let response = alert.runModal()
  1355. activeMeetingConsentWindow = nil
  1356. meetingConsentPrimaryLanguagePopup = nil
  1357. meetingConsentSecondaryLanguagePopup = nil
  1358. if didTapInlineMeetingConsentClose {
  1359. return .dismissed
  1360. }
  1361. if let primary = language1Popup.selectedItem?.representedObject as? String,
  1362. primary.isEmpty == false {
  1363. var secondary = language2Popup.selectedItem?.representedObject as? String
  1364. if secondary?.isEmpty == true {
  1365. secondary = nil
  1366. }
  1367. updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
  1368. }
  1369. switch response {
  1370. case .alertFirstButtonReturn:
  1371. return .allowAndContinue
  1372. case .alertSecondButtonReturn:
  1373. return .continueWithoutRecording
  1374. default:
  1375. return .dismissed
  1376. }
  1377. }
  1378. @objc private func meetingConsentInlineCloseTapped(_ sender: NSButton) {
  1379. didTapInlineMeetingConsentClose = true
  1380. guard let consentWindow = activeMeetingConsentWindow else { return }
  1381. NSApp.stopModal(withCode: .abort)
  1382. consentWindow.orderOut(nil)
  1383. }
  1384. @objc private func meetingConsentSpeechLanguageChanged(_ sender: NSPopUpButton) {
  1385. guard let primary = meetingConsentPrimaryLanguagePopup?.selectedItem?.representedObject as? String,
  1386. primary.isEmpty == false else { return }
  1387. var secondary: String? = meetingConsentSecondaryLanguagePopup?.selectedItem?.representedObject as? String
  1388. if secondary?.isEmpty == true {
  1389. secondary = nil
  1390. }
  1391. if let secondaryValue = secondary,
  1392. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  1393. secondary = nil
  1394. meetingConsentSecondaryLanguagePopup?.selectItem(at: 0)
  1395. }
  1396. updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
  1397. }
  1398. private func loadAiCompanionLocalRecordings() {
  1399. guard let raw = UserDefaults.standard.data(forKey: aiCompanionLocalRecordingsDefaultsKey) else {
  1400. aiCompanionLocalRecordings = []
  1401. return
  1402. }
  1403. let decoder = JSONDecoder()
  1404. if let decoded = try? decoder.decode([MeetingRecordingSummary].self, from: raw) {
  1405. var migrated = decoded
  1406. let testEmail = "mqlmac1@gmail.com"
  1407. var didMigrate = false
  1408. for idx in migrated.indices {
  1409. let existing = migrated[idx].accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
  1410. if existing?.isEmpty != false {
  1411. migrated[idx].accountEmail = testEmail
  1412. didMigrate = true
  1413. }
  1414. }
  1415. aiCompanionLocalRecordings = migrated.sorted(by: { $0.endedAt > $1.endedAt })
  1416. if didMigrate {
  1417. persistAiCompanionLocalRecordings()
  1418. }
  1419. } else {
  1420. aiCompanionLocalRecordings = []
  1421. }
  1422. }
  1423. private func persistAiCompanionLocalRecordings() {
  1424. let encoder = JSONEncoder()
  1425. guard let encoded = try? encoder.encode(aiCompanionLocalRecordings) else { return }
  1426. UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
  1427. }
  1428. private func aiCompanionActiveAccountEmail() -> String? {
  1429. guard hasGoogleSessionAvailable() else { return nil }
  1430. let raw = scheduleCurrentProfile?.email.trimmingCharacters(in: .whitespacesAndNewlines)
  1431. guard let raw, raw.isEmpty == false, raw.contains("@") else { return nil }
  1432. return raw.lowercased()
  1433. }
  1434. private func aiCompanionRecordingsForActiveAccount() -> [MeetingRecordingSummary] {
  1435. guard let email = aiCompanionActiveAccountEmail() else { return [] }
  1436. return aiCompanionLocalRecordings.filter { ($0.accountEmail ?? "").lowercased() == email }
  1437. }
  1438. private func requestSpeechRecognitionAuthorizationIfNeeded() async throws {
  1439. switch SFSpeechRecognizer.authorizationStatus() {
  1440. case .authorized:
  1441. return
  1442. case .notDetermined:
  1443. let status = await withCheckedContinuation { continuation in
  1444. SFSpeechRecognizer.requestAuthorization { status in
  1445. continuation.resume(returning: status)
  1446. }
  1447. }
  1448. guard status == .authorized else {
  1449. throw NSError(
  1450. domain: "AiCompanionTranscript",
  1451. code: 15,
  1452. userInfo: [NSLocalizedDescriptionKey: "Speech recognition permission denied. Enable it in System Settings and try again."]
  1453. )
  1454. }
  1455. case .denied:
  1456. throw NSError(
  1457. domain: "AiCompanionTranscript",
  1458. code: 15,
  1459. userInfo: [NSLocalizedDescriptionKey: "Speech recognition permission denied. Enable it in System Settings and try again."]
  1460. )
  1461. case .restricted:
  1462. throw NSError(
  1463. domain: "AiCompanionTranscript",
  1464. code: 16,
  1465. userInfo: [NSLocalizedDescriptionKey: "Speech recognition is restricted on this Mac."]
  1466. )
  1467. @unknown default:
  1468. throw NSError(
  1469. domain: "AiCompanionTranscript",
  1470. code: 17,
  1471. userInfo: [NSLocalizedDescriptionKey: "Speech recognition authorization is unavailable."]
  1472. )
  1473. }
  1474. }
  1475. private func transcribeLocalAudioWithAppleSpeech(audioURL: URL) async throws -> String {
  1476. try await requestSpeechRecognitionAuthorizationIfNeeded()
  1477. guard let recognizer = SFSpeechRecognizer(locale: Locale.current) ?? SFSpeechRecognizer(locale: Locale(identifier: "en-US")) else {
  1478. throw NSError(
  1479. domain: "AiCompanionTranscript",
  1480. code: 18,
  1481. userInfo: [NSLocalizedDescriptionKey: "Speech recognizer is unavailable for the current locale."]
  1482. )
  1483. }
  1484. let request = SFSpeechURLRecognitionRequest(url: audioURL)
  1485. request.shouldReportPartialResults = false
  1486. if #available(macOS 13.0, *) {
  1487. request.addsPunctuation = true
  1488. }
  1489. return try await withCheckedThrowingContinuation { continuation in
  1490. var hasResumed = false
  1491. var task: SFSpeechRecognitionTask?
  1492. task = recognizer.recognitionTask(with: request) { result, error in
  1493. if hasResumed { return }
  1494. if let error {
  1495. hasResumed = true
  1496. task?.cancel()
  1497. continuation.resume(throwing: error)
  1498. return
  1499. }
  1500. guard let result else { return }
  1501. if result.isFinal {
  1502. hasResumed = true
  1503. task?.finish()
  1504. continuation.resume(returning: result.bestTranscription.formattedString)
  1505. }
  1506. }
  1507. }
  1508. }
  1509. private func aiCompanionTranscriptStatus(for recording: MeetingRecordingSummary) -> MeetingTranscriptStatus {
  1510. guard let raw = recording.transcriptStatusRaw, let status = MeetingTranscriptStatus(rawValue: raw) else {
  1511. return .notRequested
  1512. }
  1513. return status
  1514. }
  1515. private func aiCompanionTranscriptStatusText(for recording: MeetingRecordingSummary) -> String {
  1516. switch aiCompanionTranscriptStatus(for: recording) {
  1517. case .notRequested:
  1518. return "Transcript not requested"
  1519. case .processing:
  1520. if let progress = aiCompanionTranscriptProgressByMeetingId[recording.id], progress.isEmpty == false {
  1521. return progress
  1522. }
  1523. return "Transcript processing..."
  1524. case .ready:
  1525. return "Transcript ready"
  1526. case .failed:
  1527. let error = recording.transcriptErrorMessage?.trimmingCharacters(in: .whitespacesAndNewlines)
  1528. if let error, error.isEmpty == false {
  1529. return "Transcript unavailable (tap to retry)"
  1530. }
  1531. return "Transcript unavailable (tap to retry)"
  1532. }
  1533. }
  1534. private func aiCompanionNotesStatus(for recording: MeetingRecordingSummary) -> MeetingNotesStatus {
  1535. guard let raw = recording.notesStatusRaw, let status = MeetingNotesStatus(rawValue: raw) else {
  1536. return .notRequested
  1537. }
  1538. return status
  1539. }
  1540. private func aiCompanionNotesStatusText(for recording: MeetingRecordingSummary) -> String {
  1541. switch aiCompanionNotesStatus(for: recording) {
  1542. case .notRequested:
  1543. return "Notes not requested"
  1544. case .processing:
  1545. if let progress = aiCompanionNotesProgressByMeetingId[recording.id], progress.isEmpty == false {
  1546. return progress
  1547. }
  1548. return "Preparing notes, please wait..."
  1549. case .ready:
  1550. return "Notes ready"
  1551. case .failed:
  1552. return "Notes unavailable (tap to retry)"
  1553. }
  1554. }
  1555. private func aiCompanionMeetingFromRecording(_ recording: MeetingRecordingSummary) -> ScheduledMeeting? {
  1556. guard let meetURL = URL(string: recording.meetURLString) else { return nil }
  1557. return ScheduledMeeting(
  1558. id: recording.id,
  1559. title: recording.title,
  1560. subtitle: nil,
  1561. startDate: recording.startedAt,
  1562. endDate: recording.endedAt,
  1563. meetURL: meetURL,
  1564. isAllDay: false
  1565. )
  1566. }
  1567. @discardableResult
  1568. private func aiCompanionUpdateRecording(meetingId: String, mutate: (inout MeetingRecordingSummary) -> Void) -> MeetingRecordingSummary? {
  1569. guard let idx = aiCompanionLocalRecordings.firstIndex(where: { $0.id == meetingId }) else { return nil }
  1570. mutate(&aiCompanionLocalRecordings[idx])
  1571. persistAiCompanionLocalRecordings()
  1572. return aiCompanionLocalRecordings[idx]
  1573. }
  1574. private func aiCompanionRefreshTranscriptStatusLabels(forMeetingID meetingId: String) {
  1575. guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else { return }
  1576. let statusText = aiCompanionTranscriptStatusText(for: recording)
  1577. for (buttonId, linkedMeetingId) in aiCompanionTranscriptMeetingIdByView where linkedMeetingId == meetingId {
  1578. aiCompanionTranscriptStatusLabelByView[buttonId]?.stringValue = statusText
  1579. }
  1580. }
  1581. private func aiCompanionRefreshNotesStatusLabels(forMeetingID meetingId: String) {
  1582. guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else { return }
  1583. let statusText = aiCompanionNotesStatusText(for: recording)
  1584. for (buttonId, linkedMeetingId) in aiCompanionNotesMeetingIdByView where linkedMeetingId == meetingId {
  1585. aiCompanionNotesStatusLabelByView[buttonId]?.stringValue = statusText
  1586. }
  1587. }
  1588. private func localRecordingDirectoryURL() -> URL {
  1589. let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
  1590. ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
  1591. let bundleID = Bundle.main.bundleIdentifier ?? "meetings_app"
  1592. let directory = base.appendingPathComponent(bundleID, isDirectory: true).appendingPathComponent("MeetingRecordings", isDirectory: true)
  1593. try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
  1594. return directory
  1595. }
  1596. private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) -> Bool {
  1597. let consentChoice = requestMeetingRecordingConsent(title: meetingTitle)
  1598. if consentChoice == .dismissed {
  1599. return false
  1600. }
  1601. meetingRecordingMonitorTask?.cancel()
  1602. meetingRecordingMonitorTask = nil
  1603. if activeMeetingRecordingSession != nil { finishActiveMeetingRecording(cancelMonitoring: false) }
  1604. guard consentChoice == .allowAndContinue else { return true }
  1605. let permission = AVCaptureDevice.authorizationStatus(for: .audio)
  1606. switch permission {
  1607. case .authorized:
  1608. startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
  1609. case .notDetermined:
  1610. AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
  1611. DispatchQueue.main.async {
  1612. guard let self else { return }
  1613. if granted {
  1614. self.startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
  1615. } else {
  1616. self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
  1617. }
  1618. }
  1619. }
  1620. case .denied, .restricted:
  1621. showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
  1622. @unknown default:
  1623. break
  1624. }
  1625. return true
  1626. }
  1627. private func startAutomaticRecordingFlow(meetingTitle: String, meetingURL: URL) {
  1628. guard let meetingCode = aiCompanionMeetMeetingCode(from: meetingURL), hasGoogleSessionAvailable() else {
  1629. startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
  1630. return
  1631. }
  1632. showTopToast(message: "Recording will start when the meeting starts", isError: false)
  1633. meetingRecordingMonitorTask = Task { [weak self] in
  1634. guard let self else { return }
  1635. defer { self.meetingRecordingMonitorTask = nil }
  1636. do {
  1637. let space = try await self.googleMeetClient.getSpace(
  1638. accessToken: try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window),
  1639. spaceNameOrMeetingCode: "spaces/\(meetingCode)"
  1640. )
  1641. let spaceName = space.name ?? "spaces/\(meetingCode)"
  1642. var hasStarted = false
  1643. var inactivePollCount = 0
  1644. let noStartDeadline = Date().addingTimeInterval(20 * 60)
  1645. while !Task.isCancelled {
  1646. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  1647. let isActive = try await self.aiCompanionIsMeetingActive(spaceResourceName: spaceName, accessToken: token)
  1648. if isActive {
  1649. inactivePollCount = 0
  1650. if !hasStarted {
  1651. self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
  1652. hasStarted = true
  1653. }
  1654. } else if hasStarted {
  1655. inactivePollCount += 1
  1656. if inactivePollCount >= 1 {
  1657. self.finishActiveMeetingRecording(cancelMonitoring: false)
  1658. break
  1659. }
  1660. } else if Date() > noStartDeadline {
  1661. break
  1662. }
  1663. try await Task.sleep(nanoseconds: 2_000_000_000)
  1664. }
  1665. } catch {
  1666. if self.activeMeetingRecordingSession == nil {
  1667. self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
  1668. }
  1669. }
  1670. }
  1671. }
  1672. private func aiCompanionIsMeetingActive(spaceResourceName: String, accessToken: String) async throws -> Bool {
  1673. let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceResourceName)
  1674. return records.contains { $0.startTime != nil && $0.endTime == nil }
  1675. }
  1676. private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
  1677. let recordingID = UUID().uuidString
  1678. let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-system.m4a")
  1679. let microphoneURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-mic.m4a")
  1680. activeMeetingRecordingSession = ActiveMeetingRecordingSession(
  1681. id: recordingID,
  1682. title: meetingTitle,
  1683. meetURL: meetingURL,
  1684. startedAt: Date(),
  1685. systemAudioFileURL: outputURL,
  1686. microphoneAudioFileURL: microphoneURL
  1687. )
  1688. pageCache[.aiCompanion] = nil
  1689. if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
  1690. startMicrophoneRecording(at: microphoneURL, showToast: false)
  1691. if #available(macOS 13.0, *) {
  1692. let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
  1693. activeMeetingSystemAudioStopper = { [systemRecorder] in
  1694. try? await systemRecorder.stop()
  1695. }
  1696. Task { [weak self] in
  1697. guard let self else { return }
  1698. do {
  1699. try await systemRecorder.start()
  1700. await MainActor.run {
  1701. self.showTopToast(message: "Meeting recording started (meeting + microphone)", isError: false)
  1702. }
  1703. } catch {
  1704. await MainActor.run {
  1705. self.activeMeetingSystemAudioStopper = nil
  1706. self.showTopToast(message: "System audio unavailable. Recording microphone only.", isError: true)
  1707. }
  1708. }
  1709. }
  1710. return
  1711. }
  1712. showTopToast(message: "Meeting recording started (microphone only)", isError: false)
  1713. }
  1714. private func startMicrophoneRecording(at outputURL: URL, showToast: Bool) {
  1715. let settings: [String: Any] = [
  1716. AVFormatIDKey: kAudioFormatMPEG4AAC,
  1717. AVSampleRateKey: 48_000,
  1718. AVNumberOfChannelsKey: 1,
  1719. AVEncoderBitRateKey: 128_000,
  1720. AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue
  1721. ]
  1722. do {
  1723. let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
  1724. recorder.prepareToRecord()
  1725. recorder.record()
  1726. activeMeetingAudioRecorder = recorder
  1727. if showToast {
  1728. showTopToast(message: "Meeting recording started (microphone only)", isError: false)
  1729. }
  1730. } catch {
  1731. activeMeetingRecordingSession = nil
  1732. showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
  1733. }
  1734. }
  1735. private func finishActiveMeetingRecording(cancelMonitoring: Bool = true) {
  1736. if cancelMonitoring {
  1737. meetingRecordingMonitorTask?.cancel()
  1738. meetingRecordingMonitorTask = nil
  1739. }
  1740. guard let session = activeMeetingRecordingSession else { return }
  1741. let stopSystemAudio = activeMeetingSystemAudioStopper
  1742. activeMeetingSystemAudioStopper = nil
  1743. activeMeetingAudioRecorder?.stop()
  1744. activeMeetingAudioRecorder = nil
  1745. activeMeetingRecordingSession = nil
  1746. Task { [weak self] in
  1747. guard let self else { return }
  1748. if let stopSystemAudio { await stopSystemAudio() }
  1749. let finalized = await self.finalizeMeetingAudioFile(
  1750. systemURL: session.systemAudioFileURL,
  1751. microphoneURL: session.microphoneAudioFileURL,
  1752. recordingID: session.id
  1753. )
  1754. var accountEmail = await MainActor.run { self.aiCompanionActiveAccountEmail() }
  1755. if accountEmail == nil, self.hasGoogleSessionAvailable() {
  1756. do {
  1757. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  1758. if let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token) {
  1759. let display = await MainActor.run { self.makeGoogleProfileDisplay(from: profile) }
  1760. await MainActor.run { [weak self] in
  1761. self?.applyGoogleProfile(display)
  1762. }
  1763. let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  1764. if let cleaned, cleaned.isEmpty == false {
  1765. accountEmail = cleaned.lowercased()
  1766. }
  1767. }
  1768. } catch {
  1769. // If profile lookup fails, persist recording without account scoping (it will be hidden in AI Companion).
  1770. }
  1771. }
  1772. await MainActor.run {
  1773. let summary = MeetingRecordingSummary(
  1774. id: session.id,
  1775. title: session.title,
  1776. meetURLString: session.meetURL.absoluteString,
  1777. startedAt: session.startedAt,
  1778. endedAt: Date(),
  1779. audioFilePath: finalized.mixedURL.path,
  1780. accountEmail: accountEmail,
  1781. microphoneAudioFilePath: finalized.microphoneURL?.path,
  1782. systemAudioFilePath: finalized.systemURL?.path,
  1783. transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
  1784. transcriptSourceRaw: nil,
  1785. transcriptText: nil,
  1786. transcriptSegmentsJSON: nil,
  1787. transcriptErrorMessage: nil,
  1788. notesStatusRaw: MeetingNotesStatus.notRequested.rawValue,
  1789. notesText: nil,
  1790. notesErrorMessage: nil
  1791. )
  1792. self.aiCompanionLocalRecordings.insert(summary, at: 0)
  1793. self.aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
  1794. self.persistAiCompanionLocalRecordings()
  1795. self.aiCompanionStartTranscriptProcessing(forMeetingID: summary.id, requestId: nil, interactiveAuth: false, forceRegenerate: false)
  1796. self.pageCache[.aiCompanion] = nil
  1797. self.showTopToast(message: "Meeting recording saved", isError: false)
  1798. if self.selectedSidebarPage == .aiCompanion {
  1799. self.showSidebarPage(.aiCompanion)
  1800. }
  1801. }
  1802. }
  1803. }
  1804. private func fileSize(at url: URL) -> Int64 {
  1805. let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
  1806. return attrs?[.size] as? Int64 ?? 0
  1807. }
  1808. private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
  1809. guard FileManager.default.fileExists(atPath: url.path) else { return false }
  1810. return fileSize(at: url) >= minBytes
  1811. }
  1812. private struct FinalizedMeetingAudio {
  1813. let mixedURL: URL
  1814. let microphoneURL: URL?
  1815. let systemURL: URL?
  1816. }
  1817. private func finalizeMeetingAudioFile(systemURL: URL, microphoneURL: URL, recordingID: String) async -> FinalizedMeetingAudio {
  1818. let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
  1819. let hasSystem = hasAudioPayload(at: systemURL)
  1820. let hasMic = hasAudioPayload(at: microphoneURL)
  1821. if !hasSystem { try? FileManager.default.removeItem(at: systemURL) }
  1822. if !hasMic { try? FileManager.default.removeItem(at: microphoneURL) }
  1823. let savedMicURL: URL? = hasMic ? microphoneURL : nil
  1824. let savedSystemURL: URL? = hasSystem ? systemURL : nil
  1825. if hasSystem && hasMic {
  1826. do {
  1827. try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: destinationURL)
  1828. return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
  1829. } catch {
  1830. // Fall back to best available single track for playback; per-channel files stay intact.
  1831. }
  1832. }
  1833. let chosenURL: URL?
  1834. if hasSystem {
  1835. chosenURL = systemURL
  1836. } else if hasMic {
  1837. chosenURL = microphoneURL
  1838. } else {
  1839. chosenURL = nil
  1840. }
  1841. if let chosenURL {
  1842. if chosenURL.path != destinationURL.path {
  1843. try? FileManager.default.removeItem(at: destinationURL)
  1844. do {
  1845. try FileManager.default.copyItem(at: chosenURL, to: destinationURL)
  1846. } catch {
  1847. return FinalizedMeetingAudio(mixedURL: chosenURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
  1848. }
  1849. }
  1850. return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
  1851. }
  1852. return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: nil, systemURL: nil)
  1853. }
  1854. private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
  1855. try? FileManager.default.removeItem(at: destinationURL)
  1856. let composition = AVMutableComposition()
  1857. guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
  1858. let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
  1859. throw NSError(domain: "MeetingAudioMix", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio composition tracks."])
  1860. }
  1861. let systemAsset = AVURLAsset(url: systemURL)
  1862. let micAsset = AVURLAsset(url: microphoneURL)
  1863. if let src = try await systemAsset.loadTracks(withMediaType: .audio).first {
  1864. let duration = try await systemAsset.load(.duration)
  1865. try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
  1866. }
  1867. if let src = try await micAsset.loadTracks(withMediaType: .audio).first {
  1868. let duration = try await micAsset.load(.duration)
  1869. try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
  1870. }
  1871. guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
  1872. throw NSError(domain: "MeetingAudioMix", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio export session."])
  1873. }
  1874. // Give the local microphone a small speech-focused boost while preserving meeting audio.
  1875. let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
  1876. systemMix.setVolume(1.0, at: .zero)
  1877. let micMix = AVMutableAudioMixInputParameters(track: micTrack)
  1878. micMix.setVolume(1.35, at: .zero)
  1879. let audioMix = AVMutableAudioMix()
  1880. audioMix.inputParameters = [systemMix, micMix]
  1881. export.outputURL = destinationURL
  1882. export.outputFileType = .m4a
  1883. export.audioMix = audioMix
  1884. await withCheckedContinuation { continuation in
  1885. export.exportAsynchronously {
  1886. continuation.resume()
  1887. }
  1888. }
  1889. if export.status != .completed {
  1890. throw export.error ?? NSError(domain: "MeetingAudioMix", code: 3, userInfo: [NSLocalizedDescriptionKey: "Audio mix export failed."])
  1891. }
  1892. }
  1893. private func showTopToast(message: String, isError: Bool) {
  1894. topToastHideWorkItem?.cancel()
  1895. topToastHideWorkItem = nil
  1896. topToastView?.removeFromSuperview()
  1897. topToastView = nil
  1898. let toast = NSVisualEffectView()
  1899. toast.translatesAutoresizingMaskIntoConstraints = false
  1900. toast.material = darkModeEnabled ? .hudWindow : .popover
  1901. toast.blendingMode = .withinWindow
  1902. toast.state = .active
  1903. toast.wantsLayer = true
  1904. toast.layer?.cornerRadius = 10
  1905. toast.layer?.masksToBounds = true
  1906. toast.layer?.borderWidth = 1
  1907. toast.layer?.borderColor = NSColor.white.withAlphaComponent(darkModeEnabled ? 0.10 : 0.18).cgColor
  1908. toast.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.68 : 0.78).cgColor
  1909. toast.alphaValue = 0
  1910. let row = NSStackView()
  1911. row.translatesAutoresizingMaskIntoConstraints = false
  1912. row.orientation = .horizontal
  1913. row.alignment = .centerY
  1914. row.spacing = 8
  1915. row.distribution = .fill
  1916. let icon = NSImageView()
  1917. icon.translatesAutoresizingMaskIntoConstraints = false
  1918. icon.imageScaling = .scaleProportionallyDown
  1919. icon.image = NSImage(
  1920. systemSymbolName: isError ? "xmark.circle.fill" : "checkmark.circle.fill",
  1921. accessibilityDescription: isError ? "Error" : "Success"
  1922. )
  1923. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
  1924. icon.contentTintColor = isError ? NSColor.systemRed : NSColor.systemGreen
  1925. let label = textLabel(
  1926. message,
  1927. font: NSFont.systemFont(ofSize: 13, weight: .semibold),
  1928. color: NSColor.white
  1929. )
  1930. label.alignment = .left
  1931. label.maximumNumberOfLines = 2
  1932. label.lineBreakMode = .byWordWrapping
  1933. row.addArrangedSubview(icon)
  1934. row.addArrangedSubview(label)
  1935. toast.addSubview(row)
  1936. view.addSubview(toast)
  1937. NSLayoutConstraint.activate([
  1938. toast.topAnchor.constraint(equalTo: view.topAnchor, constant: 14),
  1939. toast.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  1940. toast.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -40),
  1941. toast.heightAnchor.constraint(greaterThanOrEqualToConstant: 40),
  1942. icon.widthAnchor.constraint(equalToConstant: 16),
  1943. icon.heightAnchor.constraint(equalToConstant: 16),
  1944. row.leadingAnchor.constraint(equalTo: toast.leadingAnchor, constant: 14),
  1945. row.trailingAnchor.constraint(equalTo: toast.trailingAnchor, constant: -14),
  1946. row.topAnchor.constraint(equalTo: toast.topAnchor, constant: 10),
  1947. row.bottomAnchor.constraint(equalTo: toast.bottomAnchor, constant: -10)
  1948. ])
  1949. topToastView = toast
  1950. NSAnimationContext.runAnimationGroup { context in
  1951. context.duration = 0.18
  1952. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  1953. toast.animator().alphaValue = 1
  1954. }
  1955. let hideWorkItem = DispatchWorkItem { [weak self, weak toast] in
  1956. guard let self, let toast else { return }
  1957. NSAnimationContext.runAnimationGroup({ context in
  1958. context.duration = 0.2
  1959. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  1960. toast.animator().alphaValue = 0
  1961. }, completionHandler: {
  1962. toast.removeFromSuperview()
  1963. if self.topToastView === toast {
  1964. self.topToastView = nil
  1965. }
  1966. })
  1967. }
  1968. topToastHideWorkItem = hideWorkItem
  1969. DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: hideWorkItem)
  1970. }
  1971. private func confirmPremiumUpgrade(for targetPlan: PremiumPlan) -> Bool {
  1972. let alert = NSAlert()
  1973. alert.messageText = "Already Premium"
  1974. alert.informativeText = "You are already premium. Do you want to continue with this purchase?"
  1975. alert.addButton(withTitle: "Continue")
  1976. alert.addButton(withTitle: "Cancel")
  1977. return alert.runModal() == .alertFirstButtonReturn
  1978. }
  1979. private func showPaywall(upgradeFlow: Bool = false, preferredPlan: PremiumPlan? = nil) {
  1980. if !Thread.isMainThread {
  1981. DispatchQueue.main.async { [weak self] in
  1982. self?.showPaywall(upgradeFlow: upgradeFlow, preferredPlan: preferredPlan)
  1983. }
  1984. return
  1985. }
  1986. paywallUpgradeFlowEnabled = upgradeFlow
  1987. if let preferredPlan {
  1988. selectedPremiumPlan = preferredPlan
  1989. } else if !storeKitCoordinator.hasPremiumAccess {
  1990. selectedPremiumPlan = .yearly
  1991. }
  1992. if let existingOverlay = paywallOverlayView {
  1993. refreshPaywallStoreUI()
  1994. existingOverlay.alphaValue = 1
  1995. view.addSubview(existingOverlay, positioned: .above, relativeTo: nil)
  1996. return
  1997. }
  1998. if let existing = paywallWindow {
  1999. refreshPaywallStoreUI()
  2000. animatePaywallPresentation(existing)
  2001. existing.makeKeyAndOrderFront(nil)
  2002. NSApp.activate(ignoringOtherApps: true)
  2003. return
  2004. }
  2005. let content = makePaywallContent()
  2006. let overlay = NSView()
  2007. overlay.translatesAutoresizingMaskIntoConstraints = false
  2008. overlay.wantsLayer = true
  2009. overlay.layer?.backgroundColor = palette.pageBackground.withAlphaComponent(0.98).cgColor
  2010. overlay.alphaValue = 0
  2011. overlay.addSubview(content)
  2012. view.addSubview(overlay, positioned: .above, relativeTo: nil)
  2013. NSLayoutConstraint.activate([
  2014. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  2015. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  2016. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  2017. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  2018. content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  2019. content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  2020. content.topAnchor.constraint(equalTo: overlay.topAnchor),
  2021. content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
  2022. ])
  2023. paywallOverlayView = overlay
  2024. NSAnimationContext.runAnimationGroup { context in
  2025. context.duration = 0.20
  2026. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  2027. overlay.animator().alphaValue = 1
  2028. }
  2029. Task { [weak self] in
  2030. guard let self else { return }
  2031. await self.storeKitCoordinator.refreshProducts()
  2032. self.refreshPaywallStoreUI()
  2033. }
  2034. }
  2035. private func animatePaywallPresentation(_ window: NSWindow) {
  2036. let finalFrame = window.frame
  2037. let targetScreen = window.screen ?? NSScreen.main
  2038. let startY: CGFloat
  2039. if let screen = targetScreen {
  2040. startY = screen.visibleFrame.maxY + 12
  2041. } else {
  2042. startY = finalFrame.origin.y + 120
  2043. }
  2044. let startFrame = NSRect(x: finalFrame.origin.x, y: startY, width: finalFrame.width, height: finalFrame.height)
  2045. window.setFrame(startFrame, display: false)
  2046. window.alphaValue = 0
  2047. NSAnimationContext.runAnimationGroup { context in
  2048. context.duration = 0.28
  2049. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  2050. window.animator().alphaValue = 1
  2051. window.animator().setFrame(finalFrame, display: true)
  2052. }
  2053. }
  2054. @objc private func closePaywallClicked(_ sender: Any?) {
  2055. if let overlay = paywallOverlayView {
  2056. paywallOverlayView = nil
  2057. paywallUpgradeFlowEnabled = false
  2058. overlay.removeFromSuperview()
  2059. return
  2060. }
  2061. if let win = paywallWindow {
  2062. win.performClose(nil)
  2063. return
  2064. }
  2065. if let gesture = sender as? NSGestureRecognizer, let win = gesture.view?.window {
  2066. win.performClose(nil)
  2067. return
  2068. }
  2069. if let view = sender as? NSView, let win = view.window {
  2070. win.performClose(nil)
  2071. return
  2072. }
  2073. }
  2074. private func dismissPaywallIfPresented() {
  2075. if !Thread.isMainThread {
  2076. DispatchQueue.main.async { [weak self] in
  2077. self?.dismissPaywallIfPresented()
  2078. }
  2079. return
  2080. }
  2081. closePaywallClicked(nil)
  2082. }
  2083. @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
  2084. guard let view = sender.view else { return }
  2085. let action = paywallFooterActionByView[ObjectIdentifier(view)]
  2086. let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
  2087. guard let action else {
  2088. showSimpleAlert(title: text, message: "\(text) tapped.")
  2089. return
  2090. }
  2091. handlePaywallFooterAction(action)
  2092. }
  2093. private func handlePaywallFooterAction(_ action: PaywallFooterAction) {
  2094. switch action {
  2095. case .manageSubscription:
  2096. openManageSubscriptions()
  2097. case .restorePurchase:
  2098. performRestorePurchases()
  2099. case .continueWithFreePlan:
  2100. closePaywallClicked(nil)
  2101. case .privacyPolicy:
  2102. openSettingsLink(infoKey: "PrivacyPolicyURL")
  2103. case .support:
  2104. openSettingsLink(infoKey: "SupportURL")
  2105. case .termsOfServices:
  2106. openSettingsLink(infoKey: "TermsOfServiceURL")
  2107. }
  2108. }
  2109. @objc private func paywallFooterButtonPressed(_ sender: NSButton) {
  2110. guard let action = paywallFooterActionByView[ObjectIdentifier(sender)] else { return }
  2111. handlePaywallFooterAction(action)
  2112. }
  2113. @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
  2114. guard let view = sender.view,
  2115. let plan = premiumPlanByView[ObjectIdentifier(view)] else { return }
  2116. selectedPremiumPlan = plan
  2117. updatePaywallPlanSelection()
  2118. }
  2119. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  2120. guard let plan = PremiumPlan(rawValue: sender.tag) else { return }
  2121. selectedPremiumPlan = plan
  2122. updatePaywallPlanSelection()
  2123. }
  2124. private func updatePaywallPlanSelection() {
  2125. for (plan, view) in paywallPlanViews {
  2126. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  2127. }
  2128. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  2129. updatePaywallContinueState(isLoading: false)
  2130. }
  2131. private func paywallOfferText(for plan: PremiumPlan) -> String {
  2132. if storeKitCoordinator.hasPremiumAccess {
  2133. if storeKitCoordinator.hasLifetimeAccess {
  2134. return "Lifetime premium is active on this Apple ID."
  2135. }
  2136. if paywallUpgradeFlowEnabled {
  2137. let currentPlanName = storeKitCoordinator.activeNonLifetimePlan?.displayName ?? "Premium"
  2138. if plan == .lifetime {
  2139. return "Current plan: \(currentPlanName). Tap Continue to upgrade to Lifetime."
  2140. }
  2141. return "Current plan: \(currentPlanName). Select Lifetime to upgrade."
  2142. }
  2143. return "Premium is active on this Apple ID."
  2144. }
  2145. let productID = PremiumStoreProduct.productID(for: plan)
  2146. if let product = storeKitCoordinator.productsByID[productID] {
  2147. let localizedPrice = localizedDisplayPrice(product.displayPrice)
  2148. if product.type == .nonConsumable {
  2149. return "\(localizedPrice) one-time purchase"
  2150. }
  2151. if let subscription = product.subscription {
  2152. let billingText = "\(localizedPrice)/\(subscriptionUnitText(subscription.subscriptionPeriod.unit))"
  2153. if let introOffer = subscription.introductoryOffer,
  2154. introOffer.paymentMode == .freeTrial {
  2155. return "Free for \(subscriptionPeriodText(introOffer.period)), then \(billingText)"
  2156. }
  2157. return billingText
  2158. }
  2159. return localizedPrice
  2160. }
  2161. let fallbackPrice = fallbackPriceText(for: plan)
  2162. switch plan {
  2163. case .weekly:
  2164. return "\(fallbackPrice)/week"
  2165. case .monthly:
  2166. return "\(fallbackPrice)/month"
  2167. case .yearly:
  2168. return "\(fallbackPrice)/year (3 days free trial)"
  2169. case .lifetime:
  2170. return "\(fallbackPrice) one-time purchase"
  2171. }
  2172. }
  2173. private func localizedDisplayPrice(_ value: String) -> String {
  2174. value.trimmingCharacters(in: .whitespacesAndNewlines)
  2175. }
  2176. private func fallbackPriceText(for plan: PremiumPlan) -> String {
  2177. let fallbackAmount: Decimal
  2178. switch plan {
  2179. case .weekly:
  2180. fallbackAmount = 1100
  2181. case .monthly:
  2182. fallbackAmount = 2500
  2183. case .yearly:
  2184. fallbackAmount = 9900
  2185. case .lifetime:
  2186. fallbackAmount = 14900
  2187. }
  2188. return localeCurrencyText(for: fallbackAmount)
  2189. }
  2190. private func fallbackLifetimeStrikePriceText() -> String {
  2191. localeCurrencyText(for: 29_800)
  2192. }
  2193. private func fallbackAmount(for plan: PremiumPlan) -> Decimal {
  2194. switch plan {
  2195. case .weekly:
  2196. return 1100
  2197. case .monthly:
  2198. return 2500
  2199. case .yearly:
  2200. return 9900
  2201. case .lifetime:
  2202. return 14900
  2203. }
  2204. }
  2205. private func yearlySavingsBadgeText() -> String {
  2206. let yearlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.yearly]
  2207. let monthlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.monthly]
  2208. let yearlyAmount = yearlyProduct?.price ?? fallbackAmount(for: .yearly)
  2209. let monthlyAmount = monthlyProduct?.price ?? fallbackAmount(for: .monthly)
  2210. let yearlyCostIfMonthly = monthlyAmount * Decimal(12)
  2211. guard yearlyCostIfMonthly > 0 else { return "Save 0%" }
  2212. let savingsRatio = (yearlyCostIfMonthly - yearlyAmount) / yearlyCostIfMonthly
  2213. guard savingsRatio > 0 else { return "Save 0%" }
  2214. let savingsPercentDecimal = savingsRatio * Decimal(100)
  2215. let roundedPercent = Int(NSDecimalNumber(decimal: savingsPercentDecimal).doubleValue.rounded())
  2216. let savingsPercent = max(1, roundedPercent)
  2217. return "Save \(savingsPercent)%"
  2218. }
  2219. private func localeCurrencyText(for amount: Decimal) -> String {
  2220. let formatter = NumberFormatter()
  2221. formatter.numberStyle = .currency
  2222. formatter.locale = Locale.current
  2223. if formatter.currencyCode == nil {
  2224. formatter.currencyCode = "USD"
  2225. }
  2226. return formatter.string(from: amount as NSDecimalNumber) ?? "\(amount)"
  2227. }
  2228. private func strikePriceText(for product: Product) -> String {
  2229. let strikeAmount = product.price * Decimal(2)
  2230. return strikeAmount.formatted(product.priceFormatStyle)
  2231. }
  2232. private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
  2233. switch unit {
  2234. case .day: return "day"
  2235. case .week: return "week"
  2236. case .month: return "month"
  2237. case .year: return "year"
  2238. @unknown default: return "period"
  2239. }
  2240. }
  2241. private func subscriptionPeriodText(_ period: Product.SubscriptionPeriod) -> String {
  2242. let unit = subscriptionUnitText(period.unit)
  2243. if period.value == 1 {
  2244. return "1 \(unit)"
  2245. }
  2246. return "\(period.value) \(unit)s"
  2247. }
  2248. private func freeTrialPackageText(for product: Product) -> String? {
  2249. guard let introOffer = product.subscription?.introductoryOffer,
  2250. introOffer.paymentMode == .freeTrial else {
  2251. return nil
  2252. }
  2253. return "\(subscriptionPeriodText(introOffer.period)) free trial"
  2254. }
  2255. private func startStoreKit() {
  2256. storeKitStartupTask?.cancel()
  2257. storeKitStartupTask = Task { [weak self] in
  2258. guard let self else { return }
  2259. await self.storeKitCoordinator.start()
  2260. self.hasCompletedInitialStoreKitSync = true
  2261. self.refreshPaywallStoreUI()
  2262. self.presentLaunchPaywallIfNeeded()
  2263. self.dismissLaunchSplashIfReady()
  2264. }
  2265. }
  2266. private func refreshPaywallStoreUI() {
  2267. for (plan, label) in paywallPriceLabels {
  2268. let productID = PremiumStoreProduct.productID(for: plan)
  2269. if let product = storeKitCoordinator.productsByID[productID] {
  2270. label.stringValue = localizedDisplayPrice(product.displayPrice)
  2271. } else {
  2272. label.stringValue = fallbackPriceText(for: plan)
  2273. }
  2274. }
  2275. for (plan, label) in paywallSubtitleLabels {
  2276. let productID = PremiumStoreProduct.productID(for: plan)
  2277. guard let product = storeKitCoordinator.productsByID[productID],
  2278. let period = product.subscription?.subscriptionPeriod else {
  2279. // Show neutral fallback text when subscription metadata isn't available.
  2280. label.stringValue = "Billed via App Store"
  2281. continue
  2282. }
  2283. let recurringText = "\(localizedDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
  2284. if let trialText = freeTrialPackageText(for: product) {
  2285. label.stringValue = "\(recurringText) • \(trialText)"
  2286. } else {
  2287. label.stringValue = recurringText
  2288. }
  2289. }
  2290. for (plan, label) in paywallStrikePriceLabels {
  2291. guard plan == .lifetime else { continue }
  2292. let productID = PremiumStoreProduct.productID(for: plan)
  2293. if let product = storeKitCoordinator.productsByID[productID] {
  2294. label.stringValue = strikePriceText(for: product)
  2295. } else {
  2296. label.stringValue = fallbackLifetimeStrikePriceText()
  2297. }
  2298. }
  2299. paywallBadgeLabels[.yearly]?.stringValue = yearlySavingsBadgeText()
  2300. paywallBadgeLabels[.lifetime]?.stringValue = "Best Value"
  2301. refreshSidebarPremiumButton()
  2302. refreshInstantMeetPremiumState()
  2303. updatePaywallPlanSelection()
  2304. updatePaywallContinueState(isLoading: false)
  2305. }
  2306. private func refreshInstantMeetPremiumState() {
  2307. let isLocked = shouldGateJoinActionsForNonPremium
  2308. let lockedAlpha: CGFloat = 0.6
  2309. instantMeetCardView?.alphaValue = isLocked ? lockedAlpha : 1.0
  2310. instantMeetTitleLabel?.alphaValue = isLocked ? lockedAlpha : 1.0
  2311. instantMeetSubtitleLabel?.alphaValue = isLocked ? lockedAlpha : 1.0
  2312. instantMeetCardView?.toolTip = isLocked ? "Free trial used. Upgrade to continue." : nil
  2313. instantMeetCardView?.onHoverChanged?(false)
  2314. joinWithLinkCardView?.alphaValue = isLocked ? lockedAlpha : 1.0
  2315. joinWithLinkTitleLabel?.alphaValue = isLocked ? lockedAlpha : 1.0
  2316. meetLinkField?.isEditable = !isLocked
  2317. meetLinkField?.isSelectable = !isLocked
  2318. meetLinkField?.alphaValue = isLocked ? lockedAlpha : 1.0
  2319. joinMeetPrimaryButton?.isEnabled = true
  2320. joinMeetPrimaryButton?.alphaValue = isLocked ? lockedAlpha : 1.0
  2321. joinMeetPrimaryButton?.toolTip = isLocked ? "Free trial used. Upgrade to continue." : nil
  2322. joinWithLinkCardView?.toolTip = isLocked ? "Free trial used. Upgrade to continue." : nil
  2323. }
  2324. private func handlePremiumAccessChanged(_ hasPremiumAccess: Bool) {
  2325. let hadPremiumAccess = lastKnownPremiumAccess
  2326. lastKnownPremiumAccess = hasPremiumAccess
  2327. NotificationCenter.default.post(
  2328. name: .statusBarPremiumAccessChanged,
  2329. object: nil,
  2330. userInfo: ["hasPremiumAccess": hasPremiumAccess]
  2331. )
  2332. premiumUpgradeRatingPromptWorkItem?.cancel()
  2333. refreshPaywallStoreUI()
  2334. refreshScheduleCardsForPremiumStateChange()
  2335. refreshPagesAfterPremiumStateUpdate()
  2336. Task { [weak self] in
  2337. await self?.loadSchedule()
  2338. }
  2339. if !hadPremiumAccess && hasPremiumAccess {
  2340. dismissPaywallIfPresented()
  2341. if selectedSidebarPage != .joinMeetings {
  2342. Task { [weak self] in
  2343. await self?.loadSchedule()
  2344. }
  2345. }
  2346. // Skip delayed review prompt during initial launch entitlement sync.
  2347. // We only want this after a real in-session upgrade.
  2348. if hasCompletedInitialStoreKitSync {
  2349. scheduleRatingPromptAfterPremiumUpgrade()
  2350. }
  2351. MeetingReminderManager.shared.requestPermissionIfNeeded { [weak self] _ in
  2352. guard let self else { return }
  2353. MeetingReminderManager.shared.scheduleReminders(for: self.scheduleCachedMeetings)
  2354. }
  2355. }
  2356. if hadPremiumAccess && !hasPremiumAccess {
  2357. MeetingReminderManager.shared.cancelAllReminders()
  2358. DesktopWidgetWindowManager.shared.removeAllInstances()
  2359. showPaywall()
  2360. }
  2361. }
  2362. private func refreshPagesAfterPremiumStateUpdate() {
  2363. pageCache[.joinMeetings] = nil
  2364. pageCache[.photo] = nil
  2365. pageCache[.video] = nil
  2366. pageCache[.widgets] = nil
  2367. pageCache[.settings] = nil
  2368. pageCache[.aiCompanion] = nil
  2369. showSidebarPage(selectedSidebarPage)
  2370. }
  2371. private var userHasRated: Bool {
  2372. UserDefaults.standard.bool(forKey: userHasRatedDefaultsKey)
  2373. }
  2374. private var accumulatedAppUsageSeconds: TimeInterval {
  2375. get {
  2376. UserDefaults.standard.double(forKey: appUsageAccumulatedSecondsDefaultsKey)
  2377. }
  2378. set {
  2379. UserDefaults.standard.set(newValue, forKey: appUsageAccumulatedSecondsDefaultsKey)
  2380. }
  2381. }
  2382. private var totalTrackedUsageSeconds: TimeInterval {
  2383. let liveSessionSeconds: TimeInterval
  2384. if let start = appUsageSessionStartDate {
  2385. liveSessionSeconds = max(0, Date().timeIntervalSince(start))
  2386. } else {
  2387. liveSessionSeconds = 0
  2388. }
  2389. return accumulatedAppUsageSeconds + liveSessionSeconds
  2390. }
  2391. private var hasReachedRatingUsageThreshold: Bool {
  2392. totalTrackedUsageSeconds >= ratingEligibleUsageSeconds
  2393. }
  2394. private var shouldShowRateUsInSettings: Bool {
  2395. true
  2396. }
  2397. private func migrateLegacyRatingStateIfNeeded() {
  2398. let defaults = UserDefaults.standard
  2399. guard !defaults.bool(forKey: ratingStateMigrationV2DoneDefaultsKey) else { return }
  2400. // Legacy behavior marked "rated" immediately after requesting review.
  2401. // Clear once so testing and new logic can run correctly.
  2402. defaults.set(false, forKey: userHasRatedDefaultsKey)
  2403. defaults.set(true, forKey: ratingStateMigrationV2DoneDefaultsKey)
  2404. }
  2405. private func beginUsageTrackingSessionIfNeeded() {
  2406. guard appUsageSessionStartDate == nil else { return }
  2407. appUsageSessionStartDate = Date()
  2408. }
  2409. private func endUsageTrackingSession() {
  2410. guard let start = appUsageSessionStartDate else { return }
  2411. let sessionElapsedSeconds = max(0, Date().timeIntervalSince(start))
  2412. accumulatedAppUsageSeconds += sessionElapsedSeconds
  2413. appUsageSessionStartDate = nil
  2414. }
  2415. private func observeAppLifecycleForUsageTrackingIfNeeded() {
  2416. guard !hasObservedAppLifecycleForUsage else { return }
  2417. hasObservedAppLifecycleForUsage = true
  2418. NotificationCenter.default.addObserver(
  2419. self,
  2420. selector: #selector(applicationDidBecomeActiveForUsageTracking),
  2421. name: NSApplication.didBecomeActiveNotification,
  2422. object: nil
  2423. )
  2424. NotificationCenter.default.addObserver(
  2425. self,
  2426. selector: #selector(applicationWillResignActiveForUsageTracking),
  2427. name: NSApplication.willResignActiveNotification,
  2428. object: nil
  2429. )
  2430. NotificationCenter.default.addObserver(
  2431. self,
  2432. selector: #selector(applicationWillTerminateForUsageTracking),
  2433. name: NSApplication.willTerminateNotification,
  2434. object: nil
  2435. )
  2436. }
  2437. @objc private func applicationDidBecomeActiveForUsageTracking() {
  2438. beginUsageTrackingSessionIfNeeded()
  2439. }
  2440. @objc private func applicationWillResignActiveForUsageTracking() {
  2441. endUsageTrackingSession()
  2442. }
  2443. @objc private func applicationWillTerminateForUsageTracking() {
  2444. endUsageTrackingSession()
  2445. }
  2446. private func scheduleRatingPromptAfterPremiumUpgrade() {
  2447. guard !userHasRated else { return }
  2448. let workItem = DispatchWorkItem { [weak self] in
  2449. self?.requestAppRatingIfEligible(markAsRated: false)
  2450. }
  2451. premiumUpgradeRatingPromptWorkItem = workItem
  2452. DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: workItem)
  2453. }
  2454. private func requestAppRatingIfEligible(markAsRated: Bool) {
  2455. guard storeKitCoordinator.hasPremiumAccess, !userHasRated else { return }
  2456. SKStoreReviewController.requestReview()
  2457. if markAsRated {
  2458. UserDefaults.standard.set(true, forKey: userHasRatedDefaultsKey)
  2459. }
  2460. }
  2461. private func refreshScheduleCardsForPremiumStateChange() {
  2462. if let stack = scheduleCardsStack {
  2463. renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
  2464. }
  2465. applySchedulePageFiltersAndRender()
  2466. }
  2467. private func refreshSidebarPremiumButton() {
  2468. let isPremium = storeKitCoordinator.hasPremiumAccess
  2469. if isPremium {
  2470. sidebarPremiumTitleLabel?.stringValue = "Manage Subscription"
  2471. sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "crown.fill")
  2472. } else {
  2473. sidebarPremiumTitleLabel?.stringValue = "Get Premium"
  2474. sidebarPremiumIconView?.image = premiumButtonSymbolImage(named: "star.fill")
  2475. }
  2476. sidebarPremiumIconView?.contentTintColor = .white
  2477. sidebarPremiumButtonView?.onHoverChanged?(false)
  2478. }
  2479. private func premiumButtonSymbolImage(named symbolName: String) -> NSImage? {
  2480. let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  2481. return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
  2482. .withSymbolConfiguration(configuration)
  2483. }
  2484. private func presentLaunchPaywallIfNeeded() {
  2485. guard hasCompletedInitialStoreKitSync, hasViewAppearedOnce, !hasPresentedLaunchPaywall else { return }
  2486. hasPresentedLaunchPaywall = true
  2487. if !storeKitCoordinator.hasPremiumAccess {
  2488. launchPaywallWorkItem?.cancel()
  2489. let workItem = DispatchWorkItem { [weak self] in
  2490. guard let self else { return }
  2491. self.launchPaywallWorkItem = nil
  2492. guard !self.storeKitCoordinator.hasPremiumAccess else { return }
  2493. self.showPaywall()
  2494. }
  2495. launchPaywallWorkItem = workItem
  2496. DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
  2497. }
  2498. }
  2499. @objc private func paywallContinueClicked(_ sender: Any?) {
  2500. startSelectedPlanPurchase()
  2501. }
  2502. private func startSelectedPlanPurchase() {
  2503. guard paywallContinueEnabled else {
  2504. if storeKitCoordinator.hasPremiumAccess {
  2505. showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
  2506. } else {
  2507. showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
  2508. }
  2509. return
  2510. }
  2511. if paywallUpgradeFlowEnabled,
  2512. storeKitCoordinator.hasPremiumAccess,
  2513. selectedPremiumPlan != .lifetime,
  2514. !confirmPremiumUpgrade(for: selectedPremiumPlan) {
  2515. return
  2516. }
  2517. paywallPurchaseTask?.cancel()
  2518. updatePaywallContinueState(isLoading: true)
  2519. let selectedPlan = selectedPremiumPlan
  2520. paywallPurchaseTask = Task { [weak self] in
  2521. guard let self else { return }
  2522. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  2523. self.updatePaywallContinueState(isLoading: false)
  2524. self.refreshPaywallStoreUI()
  2525. switch result {
  2526. case .success:
  2527. self.refreshPagesAfterPremiumStateUpdate()
  2528. Task { [weak self] in
  2529. await self?.loadSchedule()
  2530. }
  2531. if selectedPlan == .lifetime, self.storeKitCoordinator.activeNonLifetimePlan != nil {
  2532. self.showSimpleAlert(
  2533. title: "Lifetime Premium Activated",
  2534. message: "You are premium for lifetime now. Please cancel your previous subscription in App Store Subscriptions to avoid future renewal charges."
  2535. )
  2536. } else if selectedPlan == .lifetime {
  2537. self.showSimpleAlert(title: "Purchase Complete", message: "Lifetime premium has been unlocked successfully.")
  2538. } else {
  2539. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  2540. }
  2541. self.dismissPaywallIfPresented()
  2542. self.scheduleRatingPromptAfterPremiumUpgrade()
  2543. case .cancelled:
  2544. break
  2545. case .pending:
  2546. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  2547. case .unavailable:
  2548. self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
  2549. case .alreadyOwned:
  2550. self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
  2551. case .failed(let message):
  2552. self.showSimpleAlert(title: "Purchase Failed", message: message)
  2553. }
  2554. }
  2555. }
  2556. private func updatePaywallContinueState(isLoading: Bool) {
  2557. if isLoading {
  2558. paywallContinueEnabled = false
  2559. paywallContinueLabel?.stringValue = "Processing..."
  2560. paywallContinueButton?.alphaValue = 0.75
  2561. return
  2562. }
  2563. if storeKitCoordinator.hasLifetimeAccess {
  2564. paywallContinueEnabled = false
  2565. paywallContinueLabel?.stringValue = "Premium Active"
  2566. paywallContinueButton?.alphaValue = 0.75
  2567. } else if paywallUpgradeFlowEnabled && storeKitCoordinator.hasPremiumAccess {
  2568. paywallContinueEnabled = true
  2569. paywallContinueLabel?.stringValue = "Continue"
  2570. paywallContinueButton?.alphaValue = 1.0
  2571. } else {
  2572. paywallContinueEnabled = true
  2573. paywallContinueLabel?.stringValue = (selectedPremiumPlan == .yearly) ? "Start with 3 day trial" : "Continue"
  2574. paywallContinueButton?.alphaValue = 1.0
  2575. }
  2576. }
  2577. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  2578. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  2579. let idleBorder = palette.inputBorder
  2580. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2581. let hoverIdleBackground =
  2582. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  2583. let selectedBackground = darkModeEnabled
  2584. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  2585. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  2586. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  2587. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  2588. card.layer?.borderWidth = isSelected ? 2 : 1
  2589. card.layer?.shadowColor = NSColor.black.cgColor
  2590. card.layer?.shadowOpacity = isSelected ? (darkModeEnabled ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  2591. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2592. card.layer?.shadowRadius = isSelected ? (darkModeEnabled ? 10 : 6) : (hovering ? 7 : 5)
  2593. }
  2594. private func viewForPage(_ page: SidebarPage) -> NSView {
  2595. if let cached = pageCache[page] { return cached }
  2596. let built: NSView
  2597. switch page {
  2598. case .joinMeetings:
  2599. built = makeJoinMeetingsContent()
  2600. case .photo:
  2601. built = makeSchedulePageContent()
  2602. case .video:
  2603. built = makeCalendarPageContent()
  2604. case .widgets:
  2605. built = makeWidgetsPageContent()
  2606. case .settings:
  2607. built = makeSettingsPageContent()
  2608. case .aiCompanion:
  2609. built = makeAiCompanionPageContent()
  2610. }
  2611. pageCache[page] = built
  2612. return built
  2613. }
  2614. private func makeWidgetsPageContent() -> NSView {
  2615. let panel = NSView()
  2616. panel.translatesAutoresizingMaskIntoConstraints = false
  2617. let host = makeWidgetsPageHost(canAddWidgets: storeKitCoordinator.hasPremiumAccess) { [weak self] in
  2618. self?.showPaywall()
  2619. }
  2620. panel.addSubview(host)
  2621. NSLayoutConstraint.activate([
  2622. host.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  2623. host.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  2624. host.topAnchor.constraint(equalTo: panel.topAnchor),
  2625. host.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16)
  2626. ])
  2627. // Keep widgets anchored to the top instead of drifting vertically in tall windows.
  2628. host.setContentHuggingPriority(.required, for: .vertical)
  2629. host.setContentCompressionResistancePriority(.required, for: .vertical)
  2630. return panel
  2631. }
  2632. private func makeAiCompanionPageContent() -> NSView {
  2633. // Reset per-card mappings so stale buttons/labels from previous page builds don't linger.
  2634. aiCompanionAudioPlayer?.pause()
  2635. aiCompanionAudioPlayer = nil
  2636. aiCompanionLocalAudioPlayer?.stop()
  2637. aiCompanionLocalAudioPlayer = nil
  2638. aiCompanionCurrentlyPlayingURL = nil
  2639. aiCompanionCurrentlyPlayingButton = nil
  2640. aiCompanionTimeControlObserver = nil
  2641. aiCompanionNoProgressWorkItem?.cancel()
  2642. aiCompanionNoProgressWorkItem = nil
  2643. aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
  2644. aiCompanionIsUsingSpeech = false
  2645. aiCompanionCurrentlySpeakingButtonId = nil
  2646. if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
  2647. if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
  2648. aiCompanionPlaybackEndObserver = nil
  2649. aiCompanionPlaybackFailedObserver = nil
  2650. aiCompanionAudioURLByView.removeAll()
  2651. aiCompanionAudioStatusLabelByView.removeAll()
  2652. aiCompanionSpeechTextByView.removeAll()
  2653. aiCompanionTranscriptMeetingIdByView.removeAll()
  2654. aiCompanionTranscriptStatusLabelByView.removeAll()
  2655. aiCompanionNotesMeetingIdByView.removeAll()
  2656. aiCompanionNotesStatusLabelByView.removeAll()
  2657. // Keep transcript requests and window state alive across AI Companion page rebuilds.
  2658. // The page is rebuilt while processing starts/completes, and cancelling here interrupts
  2659. // the active request before it can update the transcript view.
  2660. let panel = NSView()
  2661. panel.translatesAutoresizingMaskIntoConstraints = false
  2662. panel.userInterfaceLayoutDirection = .leftToRight
  2663. let scroll = NSScrollView()
  2664. scroll.translatesAutoresizingMaskIntoConstraints = false
  2665. scroll.drawsBackground = false
  2666. scroll.hasHorizontalScroller = false
  2667. scroll.hasVerticalScroller = true
  2668. scroll.autohidesScrollers = true
  2669. scroll.borderType = .noBorder
  2670. scroll.scrollerStyle = .overlay
  2671. scroll.automaticallyAdjustsContentInsets = false
  2672. let clip = TopAlignedClipView()
  2673. clip.drawsBackground = false
  2674. scroll.contentView = clip
  2675. panel.addSubview(scroll)
  2676. let content = NSView()
  2677. content.translatesAutoresizingMaskIntoConstraints = false
  2678. scroll.documentView = content
  2679. let contentStack = NSStackView()
  2680. contentStack.translatesAutoresizingMaskIntoConstraints = false
  2681. contentStack.userInterfaceLayoutDirection = .leftToRight
  2682. contentStack.orientation = .vertical
  2683. contentStack.spacing = 12
  2684. contentStack.alignment = .width
  2685. contentStack.distribution = .fill
  2686. let titleLabel = textLabel("AI Companion", font: typography.pageTitle, color: palette.textPrimary)
  2687. titleLabel.alignment = .left
  2688. contentStack.addArrangedSubview(titleLabel)
  2689. let subtitle = textLabel("Get notes from your previous meetings", font: typography.fieldLabel, color: palette.textSecondary)
  2690. subtitle.alignment = .left
  2691. contentStack.addArrangedSubview(subtitle)
  2692. contentStack.setCustomSpacing(14, after: subtitle)
  2693. titleLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2694. subtitle.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2695. let signedIn = hasGoogleSessionAvailable()
  2696. let activeEmail = aiCompanionActiveAccountEmail()
  2697. if signedIn == false || activeEmail == nil {
  2698. let locked = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
  2699. locked.translatesAutoresizingMaskIntoConstraints = false
  2700. styleSurface(locked, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2701. let lockedStack = NSStackView()
  2702. lockedStack.translatesAutoresizingMaskIntoConstraints = false
  2703. lockedStack.orientation = .vertical
  2704. lockedStack.alignment = .leading
  2705. lockedStack.spacing = 10
  2706. let msg = textLabel(
  2707. "Connect your Google account to see your AI Companion meeting history.",
  2708. font: NSFont.systemFont(ofSize: 14, weight: .semibold),
  2709. color: palette.textPrimary
  2710. )
  2711. msg.alignment = .left
  2712. msg.maximumNumberOfLines = 2
  2713. msg.lineBreakMode = .byWordWrapping
  2714. lockedStack.addArrangedSubview(msg)
  2715. locked.addSubview(lockedStack)
  2716. NSLayoutConstraint.activate([
  2717. lockedStack.leadingAnchor.constraint(equalTo: locked.leadingAnchor, constant: 14),
  2718. lockedStack.trailingAnchor.constraint(equalTo: locked.trailingAnchor, constant: -14),
  2719. lockedStack.topAnchor.constraint(equalTo: locked.topAnchor, constant: 14),
  2720. lockedStack.bottomAnchor.constraint(equalTo: locked.bottomAnchor, constant: -14)
  2721. ])
  2722. contentStack.addArrangedSubview(locked)
  2723. locked.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2724. // If tokens exist but we haven't fetched the profile yet, try to hydrate it so we can scope history correctly.
  2725. if signedIn, scheduleCurrentProfile == nil {
  2726. Task { [weak self] in
  2727. guard let self else { return }
  2728. do {
  2729. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  2730. if let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token) {
  2731. await MainActor.run { [weak self] in
  2732. guard let self else { return }
  2733. self.applyGoogleProfile(self.makeGoogleProfileDisplay(from: profile))
  2734. self.redrawAiCompanionPageIfNeeded()
  2735. }
  2736. }
  2737. } catch {
  2738. // Leave UI in connect state; schedule page handles showing auth errors.
  2739. }
  2740. }
  2741. }
  2742. content.addSubview(contentStack)
  2743. NSLayoutConstraint.activate([
  2744. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  2745. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  2746. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  2747. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  2748. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  2749. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  2750. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  2751. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  2752. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  2753. contentStack.leftAnchor.constraint(equalTo: content.leftAnchor, constant: 28),
  2754. contentStack.rightAnchor.constraint(equalTo: content.rightAnchor, constant: -28),
  2755. contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
  2756. content.bottomAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 16)
  2757. ])
  2758. return panel
  2759. }
  2760. if let session = activeMeetingRecordingSession {
  2761. let activeCard = aiCompanionActiveRecordingCard(session: session)
  2762. contentStack.addArrangedSubview(activeCard)
  2763. activeCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2764. }
  2765. let scopedRecordings = aiCompanionRecordingsForActiveAccount()
  2766. if scopedRecordings.isEmpty {
  2767. let emptyLabel = textLabel(
  2768. "No previous meetings yet for \(activeEmail ?? "this account"). End a meeting to save it here and generate notes.",
  2769. font: typography.fieldLabel,
  2770. color: palette.textMuted
  2771. )
  2772. emptyLabel.alignment = .left
  2773. emptyLabel.maximumNumberOfLines = 2
  2774. emptyLabel.lineBreakMode = .byWordWrapping
  2775. contentStack.addArrangedSubview(emptyLabel)
  2776. emptyLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2777. } else {
  2778. let filterRow = NSStackView()
  2779. filterRow.translatesAutoresizingMaskIntoConstraints = false
  2780. filterRow.userInterfaceLayoutDirection = .leftToRight
  2781. filterRow.orientation = .horizontal
  2782. filterRow.alignment = .centerY
  2783. filterRow.spacing = 18
  2784. filterRow.distribution = .fill
  2785. let filterDropdown = makeAiCompanionRecordingsFilterDropdown()
  2786. aiCompanionRecordingsFilterDropdown = filterDropdown
  2787. filterRow.addArrangedSubview(filterDropdown)
  2788. filterRow.setCustomSpacing(20, after: filterDropdown)
  2789. let (fromShell, fromPicker) = makeFilterStyleDatePicker(
  2790. date: aiCompanionFilterFromDate,
  2791. changeAction: #selector(aiCompanionFilterDatePickerChanged(_:))
  2792. )
  2793. aiCompanionFilterFromDatePicker = fromPicker
  2794. filterRow.addArrangedSubview(fromShell)
  2795. filterRow.setCustomSpacing(16, after: fromShell)
  2796. let (toShell, toPicker) = makeFilterStyleDatePicker(
  2797. date: aiCompanionFilterToDate,
  2798. changeAction: #selector(aiCompanionFilterDatePickerChanged(_:))
  2799. )
  2800. aiCompanionFilterToDatePicker = toPicker
  2801. filterRow.addArrangedSubview(toShell)
  2802. NSLayoutConstraint.activate([
  2803. fromShell.widthAnchor.constraint(equalTo: toShell.widthAnchor),
  2804. fromShell.widthAnchor.constraint(greaterThanOrEqualToConstant: 152)
  2805. ])
  2806. let filterRowSpacer = NSView()
  2807. filterRowSpacer.translatesAutoresizingMaskIntoConstraints = false
  2808. filterRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  2809. filterRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2810. filterRow.addArrangedSubview(filterRowSpacer)
  2811. let applyFilterButton = makeSchedulePagePillButton(title: "Apply", action: #selector(aiCompanionRecordingsApplyPressed(_:)))
  2812. filterRow.addArrangedSubview(applyFilterButton)
  2813. filterRow.setCustomSpacing(22, after: applyFilterButton)
  2814. let resetFilterButton = makeSchedulePagePillButton(title: "Reset", action: #selector(aiCompanionRecordingsResetPressed(_:)))
  2815. filterRow.addArrangedSubview(resetFilterButton)
  2816. filterRow.setCustomSpacing(22, after: resetFilterButton)
  2817. filterRow.addArrangedSubview(
  2818. makeScheduleRefreshButton(
  2819. action: #selector(aiCompanionRecordingsRefreshPressed(_:)),
  2820. accessibilityDescription: "Refresh saved meetings"
  2821. )
  2822. )
  2823. contentStack.addArrangedSubview(filterRow)
  2824. filterRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2825. let rangeError = textLabel(
  2826. aiCompanionRecordingsRangeErrorMessage ?? "",
  2827. font: NSFont.systemFont(ofSize: 12, weight: .semibold),
  2828. color: .systemRed
  2829. )
  2830. rangeError.alignment = .left
  2831. rangeError.isHidden = aiCompanionRecordingsRangeErrorMessage == nil
  2832. rangeError.maximumNumberOfLines = 3
  2833. rangeError.lineBreakMode = .byWordWrapping
  2834. aiCompanionRecordingsRangeErrorLabel = rangeError
  2835. contentStack.addArrangedSubview(rangeError)
  2836. rangeError.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2837. let previousMeetingsLabel = textLabel("Previous meetings", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: palette.textPrimary)
  2838. previousMeetingsLabel.alignment = .left
  2839. contentStack.addArrangedSubview(previousMeetingsLabel)
  2840. previousMeetingsLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2841. contentStack.setCustomSpacing(10, after: previousMeetingsLabel)
  2842. let displayedRecordings = filteredAiCompanionRecordings()
  2843. if displayedRecordings.isEmpty {
  2844. let emptyFiltered = textLabel(
  2845. aiCompanionRecordingsRangeErrorMessage == nil
  2846. ? "No meetings match the selected filters."
  2847. : "Adjust the date range and tap Apply.",
  2848. font: typography.fieldLabel,
  2849. color: palette.textMuted
  2850. )
  2851. emptyFiltered.alignment = .left
  2852. emptyFiltered.maximumNumberOfLines = 2
  2853. emptyFiltered.lineBreakMode = .byWordWrapping
  2854. contentStack.addArrangedSubview(emptyFiltered)
  2855. emptyFiltered.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2856. } else {
  2857. for recording in displayedRecordings {
  2858. let card = aiCompanionMeetingCard(recording)
  2859. contentStack.addArrangedSubview(card)
  2860. card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  2861. }
  2862. }
  2863. refreshAiCompanionRecordingsFilterChrome()
  2864. }
  2865. content.addSubview(contentStack)
  2866. NSLayoutConstraint.activate([
  2867. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  2868. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  2869. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  2870. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  2871. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  2872. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  2873. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  2874. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  2875. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  2876. contentStack.leftAnchor.constraint(equalTo: content.leftAnchor, constant: 28),
  2877. contentStack.rightAnchor.constraint(equalTo: content.rightAnchor, constant: -28),
  2878. contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
  2879. content.bottomAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 16)
  2880. ])
  2881. return panel
  2882. }
  2883. private func makeAiCompanionRecordingsFilterDropdown() -> NSPopUpButton {
  2884. let button = HoverPopUpButton(frame: .zero, pullsDown: false)
  2885. button.translatesAutoresizingMaskIntoConstraints = false
  2886. button.autoenablesItems = false
  2887. button.isBordered = false
  2888. button.bezelStyle = .regularSquare
  2889. button.wantsLayer = true
  2890. button.layer?.cornerRadius = 8
  2891. button.layer?.masksToBounds = true
  2892. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2893. button.layer?.borderColor = palette.inputBorder.cgColor
  2894. button.layer?.borderWidth = 1
  2895. button.font = typography.filterText
  2896. button.contentTintColor = palette.textSecondary
  2897. button.target = self
  2898. button.action = #selector(aiCompanionRecordingsFilterChanged(_:))
  2899. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  2900. button.widthAnchor.constraint(equalToConstant: 268).isActive = true
  2901. button.removeAllItems()
  2902. button.addItems(withTitles: ["All", "Today", "This week", "Previous week", "Previous month", "Custom range"])
  2903. button.selectItem(at: aiCompanionRecordingsFilter.rawValue)
  2904. if let menu = button.menu {
  2905. for (index, item) in menu.items.enumerated() {
  2906. item.tag = index
  2907. }
  2908. }
  2909. let baseColor = palette.inputBackground
  2910. let baseBorder = palette.inputBorder
  2911. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2912. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  2913. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  2914. button.onHoverChanged = { [weak button] hovering in
  2915. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  2916. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  2917. }
  2918. button.onHoverChanged?(false)
  2919. return button
  2920. }
  2921. private func aiCompanionHasValidCustomFilterRange() -> Bool {
  2922. let calendar = Calendar.current
  2923. let start = calendar.startOfDay(for: aiCompanionFilterFromDate)
  2924. let end = calendar.startOfDay(for: aiCompanionFilterToDate)
  2925. return start <= end
  2926. }
  2927. private func filteredAiCompanionRecordings() -> [MeetingRecordingSummary] {
  2928. let calendar = Calendar.current
  2929. let now = Date()
  2930. let recordings = aiCompanionRecordingsForActiveAccount()
  2931. func previousCalendarWeekBounds(reference: Date) -> (start: Date, end: Date)? {
  2932. guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: reference),
  2933. let prevStart = calendar.date(byAdding: .weekOfYear, value: -1, to: thisWeek.start)
  2934. else { return nil }
  2935. return (prevStart, thisWeek.start)
  2936. }
  2937. func previousCalendarMonthBounds(reference: Date) -> (start: Date, end: Date)? {
  2938. guard let thisMonth = calendar.dateInterval(of: .month, for: reference),
  2939. let prevStart = calendar.date(byAdding: .month, value: -1, to: thisMonth.start)
  2940. else { return nil }
  2941. return (prevStart, thisMonth.start)
  2942. }
  2943. switch aiCompanionRecordingsFilter {
  2944. case .all:
  2945. return recordings
  2946. case .today:
  2947. let dayStart = calendar.startOfDay(for: now)
  2948. guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return [] }
  2949. return recordings.filter { $0.endedAt >= dayStart && $0.endedAt < nextDay }
  2950. case .week:
  2951. guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: now) else { return [] }
  2952. return recordings.filter { $0.endedAt >= thisWeek.start && $0.endedAt < thisWeek.end }
  2953. case .previousWeek:
  2954. guard let bounds = previousCalendarWeekBounds(reference: now) else { return [] }
  2955. return recordings.filter { $0.endedAt >= bounds.start && $0.endedAt < bounds.end }
  2956. case .previousMonth:
  2957. guard let bounds = previousCalendarMonthBounds(reference: now) else { return [] }
  2958. return recordings.filter { $0.endedAt >= bounds.start && $0.endedAt < bounds.end }
  2959. case .customRange:
  2960. guard aiCompanionHasValidCustomFilterRange() else { return [] }
  2961. let start = calendar.startOfDay(for: aiCompanionFilterFromDate)
  2962. let inclusiveEndDay = calendar.startOfDay(for: aiCompanionFilterToDate)
  2963. guard let endExclusive = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else { return [] }
  2964. return recordings.filter { $0.endedAt >= start && $0.endedAt < endExclusive }
  2965. }
  2966. }
  2967. private func refreshAiCompanionRecordingsFilterChrome() {
  2968. let isCustom = aiCompanionRecordingsFilter == .customRange
  2969. aiCompanionFilterFromDatePicker?.isEnabled = isCustom
  2970. aiCompanionFilterToDatePicker?.isEnabled = isCustom
  2971. let dim: CGFloat = isCustom ? 1.0 : 0.65
  2972. aiCompanionFilterFromDatePicker?.superview?.alphaValue = dim
  2973. aiCompanionFilterToDatePicker?.superview?.alphaValue = dim
  2974. }
  2975. private func setAiCompanionRecordingsRangeError(_ message: String?) {
  2976. aiCompanionRecordingsRangeErrorMessage = message
  2977. aiCompanionRecordingsRangeErrorLabel?.stringValue = message ?? ""
  2978. aiCompanionRecordingsRangeErrorLabel?.isHidden = message == nil
  2979. }
  2980. private func redrawAiCompanionPageIfNeeded() {
  2981. guard selectedSidebarPage == .aiCompanion else { return }
  2982. pageCache[.aiCompanion] = nil
  2983. showSidebarPage(.aiCompanion)
  2984. }
  2985. private func aiCompanionActiveRecordingCard(session: ActiveMeetingRecordingSession) -> NSView {
  2986. let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
  2987. card.translatesAutoresizingMaskIntoConstraints = false
  2988. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2989. let stack = NSStackView()
  2990. stack.translatesAutoresizingMaskIntoConstraints = false
  2991. stack.userInterfaceLayoutDirection = .leftToRight
  2992. stack.orientation = .vertical
  2993. stack.alignment = .leading
  2994. stack.spacing = 8
  2995. let title = textLabel("Recording in progress: \(session.title)", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  2996. let started = DateFormatter.localizedString(from: session.startedAt, dateStyle: .medium, timeStyle: .short)
  2997. let status = textLabel("Started: \(started)", font: typography.fieldLabel, color: palette.textSecondary)
  2998. let stopButton = NSButton(title: "Stop Recording", target: self, action: #selector(aiCompanionStopRecordingTapped(_:)))
  2999. stopButton.translatesAutoresizingMaskIntoConstraints = false
  3000. stopButton.isBordered = false
  3001. stopButton.bezelStyle = .inline
  3002. stopButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
  3003. stopButton.contentTintColor = NSColor.systemRed
  3004. stopButton.alignment = .left
  3005. stopButton.setButtonType(.momentaryPushIn)
  3006. aiCompanionStopRecordingButton = stopButton
  3007. stack.addArrangedSubview(title)
  3008. stack.addArrangedSubview(status)
  3009. stack.addArrangedSubview(stopButton)
  3010. card.addSubview(stack)
  3011. NSLayoutConstraint.activate([
  3012. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  3013. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  3014. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  3015. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
  3016. ])
  3017. return card
  3018. }
  3019. private func aiCompanionMeetingCard(_ recording: MeetingRecordingSummary) -> NSView {
  3020. let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
  3021. card.translatesAutoresizingMaskIntoConstraints = false
  3022. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3023. let stack = NSStackView()
  3024. stack.translatesAutoresizingMaskIntoConstraints = false
  3025. stack.userInterfaceLayoutDirection = .leftToRight
  3026. stack.orientation = .vertical
  3027. stack.alignment = .leading
  3028. stack.spacing = 8
  3029. let title = textLabel(recording.title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  3030. title.alignment = .left
  3031. title.maximumNumberOfLines = 2
  3032. title.lineBreakMode = .byTruncatingTail
  3033. let dateText = DateFormatter.localizedString(from: recording.endedAt, dateStyle: .medium, timeStyle: .none)
  3034. let dateLabel = textLabel("Date: \(dateText)", font: typography.fieldLabel, color: palette.textSecondary)
  3035. dateLabel.alignment = .left
  3036. let savedTimeText = DateFormatter.localizedString(from: recording.endedAt, dateStyle: .none, timeStyle: .short)
  3037. let savedTimeLabel = textLabel("Saved time: \(savedTimeText)", font: typography.fieldLabel, color: palette.textMuted)
  3038. savedTimeLabel.alignment = .left
  3039. let notesButton = HoverButton(title: "Get Notes", target: self, action: #selector(aiCompanionNotesTapped(_:)))
  3040. notesButton.translatesAutoresizingMaskIntoConstraints = false
  3041. notesButton.isBordered = false
  3042. notesButton.bezelStyle = .regularSquare
  3043. notesButton.wantsLayer = true
  3044. notesButton.layer?.cornerRadius = 16
  3045. notesButton.layer?.masksToBounds = true
  3046. notesButton.layer?.borderWidth = 1
  3047. notesButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
  3048. notesButton.contentTintColor = palette.primaryBlue
  3049. notesButton.alignment = .center
  3050. notesButton.setButtonType(.momentaryChange)
  3051. notesButton.focusRingType = .none
  3052. notesButton.image = NSImage(systemSymbolName: "note.text", accessibilityDescription: "Get Notes")
  3053. notesButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  3054. notesButton.imagePosition = .imageLeading
  3055. notesButton.imageHugsTitle = true
  3056. notesButton.heightAnchor.constraint(equalToConstant: 32).isActive = true
  3057. notesButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 112).isActive = true
  3058. let notesBaseFill = palette.primaryBlue.withAlphaComponent(darkModeEnabled ? 0.20 : 0.14)
  3059. let notesHoverFill = palette.primaryBlue.withAlphaComponent(darkModeEnabled ? 0.30 : 0.22)
  3060. let notesBaseBorder = palette.primaryBlue.withAlphaComponent(darkModeEnabled ? 0.65 : 0.52)
  3061. let notesHoverBorder = palette.primaryBlue.withAlphaComponent(darkModeEnabled ? 0.90 : 0.72)
  3062. notesButton.onHoverChanged = { [weak notesButton] hovering in
  3063. notesButton?.layer?.backgroundColor = (hovering ? notesHoverFill : notesBaseFill).cgColor
  3064. notesButton?.layer?.borderColor = (hovering ? notesHoverBorder : notesBaseBorder).cgColor
  3065. }
  3066. notesButton.onHoverChanged?(false)
  3067. aiCompanionNotesMeetingIdByView[ObjectIdentifier(notesButton)] = recording.id
  3068. let notesStatusLabel = textLabel(aiCompanionNotesStatusText(for: recording), font: typography.fieldLabel, color: palette.textMuted)
  3069. notesStatusLabel.alignment = .left
  3070. notesStatusLabel.maximumNumberOfLines = 2
  3071. notesStatusLabel.lineBreakMode = .byTruncatingTail
  3072. aiCompanionNotesStatusLabelByView[ObjectIdentifier(notesButton)] = notesStatusLabel
  3073. stack.addArrangedSubview(title)
  3074. stack.addArrangedSubview(dateLabel)
  3075. stack.addArrangedSubview(savedTimeLabel)
  3076. stack.addArrangedSubview(notesButton)
  3077. stack.addArrangedSubview(notesStatusLabel)
  3078. card.addSubview(stack)
  3079. NSLayoutConstraint.activate([
  3080. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  3081. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  3082. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  3083. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
  3084. ])
  3085. if !storeKitCoordinator.hasPremiumAccess {
  3086. // Keep the content present (so layout stays stable), but visually lock it and route clicks to the paywall.
  3087. notesButton.isEnabled = false
  3088. let lockOverlay = NSVisualEffectView()
  3089. lockOverlay.translatesAutoresizingMaskIntoConstraints = false
  3090. lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
  3091. lockOverlay.blendingMode = .withinWindow
  3092. lockOverlay.state = .active
  3093. lockOverlay.wantsLayer = true
  3094. lockOverlay.layer?.cornerRadius = 14
  3095. lockOverlay.layer?.masksToBounds = true
  3096. lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.30 : 0.12).cgColor
  3097. card.addSubview(lockOverlay)
  3098. let message = textLabel(
  3099. "Premium required. Click to unlock AI Companion notes.",
  3100. font: NSFont.systemFont(ofSize: 13, weight: .semibold),
  3101. color: darkModeEnabled ? .white : .black
  3102. )
  3103. message.alignment = .center
  3104. message.maximumNumberOfLines = 2
  3105. message.lineBreakMode = .byWordWrapping
  3106. lockOverlay.addSubview(message)
  3107. let hit = HoverTrackingView()
  3108. hit.translatesAutoresizingMaskIntoConstraints = false
  3109. hit.wantsLayer = true
  3110. hit.layer?.backgroundColor = NSColor.clear.cgColor
  3111. hit.onClick = { [weak self] in
  3112. self?.showPaywall()
  3113. }
  3114. hit.toolTip = "Premium required. Click to open paywall."
  3115. lockOverlay.addSubview(hit)
  3116. NSLayoutConstraint.activate([
  3117. lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor),
  3118. lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor),
  3119. lockOverlay.topAnchor.constraint(equalTo: card.topAnchor),
  3120. lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor),
  3121. message.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
  3122. message.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
  3123. message.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 18),
  3124. message.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -18),
  3125. hit.leadingAnchor.constraint(equalTo: lockOverlay.leadingAnchor),
  3126. hit.trailingAnchor.constraint(equalTo: lockOverlay.trailingAnchor),
  3127. hit.topAnchor.constraint(equalTo: lockOverlay.topAnchor),
  3128. hit.bottomAnchor.constraint(equalTo: lockOverlay.bottomAnchor)
  3129. ])
  3130. }
  3131. return card
  3132. }
  3133. @objc private func aiCompanionAudioTapped(_ sender: NSButton) {
  3134. let senderId = ObjectIdentifier(sender)
  3135. guard let url = aiCompanionAudioURLByView[senderId] else { return }
  3136. // Toggle play/pause if the same card is tapped.
  3137. if aiCompanionCurrentlyPlayingURL == url {
  3138. if aiCompanionIsUsingSpeech {
  3139. if aiCompanionSpeechSynthesizer.isSpeaking {
  3140. aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
  3141. aiCompanionIsUsingSpeech = false
  3142. sender.title = "Play Audio"
  3143. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Stopped"
  3144. } else {
  3145. aiCompanionStartSpeech(forButtonId: senderId)
  3146. sender.title = "Pause Audio"
  3147. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Speaking..."
  3148. }
  3149. return
  3150. } else if let player = aiCompanionAudioPlayer {
  3151. if player.rate != 0 {
  3152. player.pause()
  3153. sender.title = "Play Audio"
  3154. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
  3155. } else {
  3156. player.play()
  3157. sender.title = "Pause Audio"
  3158. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
  3159. }
  3160. return
  3161. } else if let player = aiCompanionLocalAudioPlayer {
  3162. if player.isPlaying {
  3163. player.pause()
  3164. sender.title = "Play Audio"
  3165. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
  3166. } else {
  3167. player.play()
  3168. sender.title = "Pause Audio"
  3169. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
  3170. }
  3171. return
  3172. }
  3173. }
  3174. // Stop any previous playback and remove observers.
  3175. aiCompanionAudioPlayer?.pause()
  3176. aiCompanionLocalAudioPlayer?.stop()
  3177. aiCompanionLocalAudioPlayer = nil
  3178. aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
  3179. aiCompanionIsUsingSpeech = false
  3180. aiCompanionNoProgressWorkItem?.cancel()
  3181. aiCompanionNoProgressWorkItem = nil
  3182. aiCompanionCurrentlySpeakingButtonId = nil
  3183. aiCompanionTimeControlObserver = nil
  3184. if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
  3185. if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
  3186. aiCompanionPlaybackEndObserver = nil
  3187. aiCompanionPlaybackFailedObserver = nil
  3188. // Revert previous playing UI (if any).
  3189. if let previousButton = aiCompanionCurrentlyPlayingButton {
  3190. let previousId = ObjectIdentifier(previousButton)
  3191. previousButton.title = "Play Audio"
  3192. aiCompanionAudioStatusLabelByView[previousId]?.stringValue = "Not playing"
  3193. }
  3194. aiCompanionCurrentlyPlayingURL = url
  3195. aiCompanionCurrentlyPlayingButton = sender
  3196. sender.title = "Pause Audio"
  3197. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Checking audio..."
  3198. aiCompanionAudioRequestID = UUID()
  3199. let requestID = aiCompanionAudioRequestID
  3200. let urlToCheck = url
  3201. let senderButton = sender
  3202. if urlToCheck.isFileURL {
  3203. guard FileManager.default.fileExists(atPath: urlToCheck.path) else {
  3204. senderButton.title = "Play Audio"
  3205. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Audio file missing"
  3206. return
  3207. }
  3208. do {
  3209. let player = try AVAudioPlayer(contentsOf: urlToCheck)
  3210. player.volume = 1.0
  3211. player.delegate = self
  3212. player.prepareToPlay()
  3213. let didPlay = player.play()
  3214. guard didPlay else {
  3215. senderButton.title = "Play Audio"
  3216. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Could not play recording"
  3217. return
  3218. }
  3219. aiCompanionLocalAudioPlayer = player
  3220. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
  3221. } catch {
  3222. senderButton.title = "Play Audio"
  3223. aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Failed: \(error.localizedDescription)"
  3224. }
  3225. return
  3226. }
  3227. var request = URLRequest(url: urlToCheck)
  3228. request.setValue("bytes=0-0", forHTTPHeaderField: "Range") // lightweight probe
  3229. request.timeoutInterval = 10
  3230. URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
  3231. guard let self else { return }
  3232. DispatchQueue.main.async {
  3233. guard self.aiCompanionAudioRequestID == requestID else { return } // stale tap
  3234. if let error {
  3235. senderButton.title = "Play Audio"
  3236. self.aiCompanionAudioTimeControlObserverResetForFailure()
  3237. self.aiCompanionStartSpeech(forButtonId: senderId)
  3238. return
  3239. }
  3240. guard let http = response as? HTTPURLResponse else {
  3241. senderButton.title = "Play Audio"
  3242. self.aiCompanionAudioTimeControlObserverResetForFailure()
  3243. self.aiCompanionStartSpeech(forButtonId: senderId)
  3244. return
  3245. }
  3246. let mime = http.mimeType?.lowercased() ?? ""
  3247. let okStatus = (200...299).contains(http.statusCode) || http.statusCode == 206
  3248. guard okStatus else {
  3249. senderButton.title = "Play Audio"
  3250. self.aiCompanionAudioTimeControlObserverResetForFailure()
  3251. self.aiCompanionStartSpeech(forButtonId: senderId)
  3252. return
  3253. }
  3254. if !mime.isEmpty && mime.hasPrefix("audio/") == false {
  3255. senderButton.title = "Play Audio"
  3256. self.aiCompanionAudioTimeControlObserverResetForFailure()
  3257. self.aiCompanionStartSpeech(forButtonId: senderId)
  3258. return
  3259. }
  3260. self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Loading..."
  3261. let player = AVPlayer(url: urlToCheck)
  3262. player.volume = 1.0
  3263. self.aiCompanionAudioPlayer = player
  3264. if let item = player.currentItem {
  3265. self.aiCompanionPlaybackEndObserver = NotificationCenter.default.addObserver(
  3266. forName: .AVPlayerItemDidPlayToEndTime,
  3267. object: item,
  3268. queue: .main
  3269. ) { [weak self] _ in
  3270. self?.aiCompanionAudioDidFinish()
  3271. }
  3272. self.aiCompanionPlaybackFailedObserver = NotificationCenter.default.addObserver(
  3273. forName: .AVPlayerItemFailedToPlayToEndTime,
  3274. object: item,
  3275. queue: .main
  3276. ) { [weak self] notification in
  3277. self?.aiCompanionAudioDidFail(notification: notification)
  3278. }
  3279. }
  3280. // Update the UI only when playback actually starts (helps diagnose "playing but no audio").
  3281. self.aiCompanionTimeControlObserver = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] p, _ in
  3282. guard let self else { return }
  3283. guard self.aiCompanionCurrentlyPlayingURL == urlToCheck else { return }
  3284. switch p.timeControlStatus {
  3285. case .playing:
  3286. self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
  3287. case .waitingToPlayAtSpecifiedRate:
  3288. self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Buffering..."
  3289. case .paused:
  3290. self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
  3291. @unknown default:
  3292. self.aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
  3293. }
  3294. }
  3295. // If the remote file is silent/unplayable, AVPlayer can still report "playing".
  3296. // After a short grace period, fall back to text-to-speech so you can always hear something.
  3297. let startSeconds = player.currentTime().seconds
  3298. player.play()
  3299. let playerRef = player
  3300. let urlRef = urlToCheck
  3301. self.aiCompanionNoProgressWorkItem?.cancel()
  3302. let workItem = DispatchWorkItem { [weak self] in
  3303. guard let self else { return }
  3304. guard self.aiCompanionAudioPlayer === playerRef else { return }
  3305. guard self.aiCompanionCurrentlyPlayingURL == urlRef else { return }
  3306. guard self.aiCompanionIsUsingSpeech == false else { return }
  3307. let nowSeconds = playerRef.currentTime().seconds
  3308. let progressed = startSeconds.isFinite && nowSeconds.isFinite && (nowSeconds - startSeconds) > 0.5
  3309. let actuallyPlaying = playerRef.timeControlStatus == .playing
  3310. if actuallyPlaying == false || progressed == false {
  3311. self.aiCompanionStartSpeech(forButtonId: senderId)
  3312. }
  3313. }
  3314. self.aiCompanionNoProgressWorkItem = workItem
  3315. DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem)
  3316. }
  3317. }.resume()
  3318. }
  3319. @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
  3320. let senderId = ObjectIdentifier(sender)
  3321. guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
  3322. guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
  3323. aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable (tap to retry)"
  3324. showSimpleAlert(title: "Transcript unavailable", message: "Could not find recording details for this meeting.")
  3325. return
  3326. }
  3327. if aiCompanionTranscriptStatus(for: recording) == .ready,
  3328. let cached = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines),
  3329. cached.isEmpty == false {
  3330. aiCompanionPresentTranscriptWindow(meetingTitle: recording.title, initialText: cached)
  3331. aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = aiCompanionTranscriptStatusText(for: recording)
  3332. return
  3333. }
  3334. aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript processing..."
  3335. let requestId = UUID()
  3336. aiCompanionTranscriptCurrentRequestId = requestId
  3337. aiCompanionPresentTranscriptWindow(meetingTitle: recording.title, initialText: "Transcript processing...")
  3338. aiCompanionStartTranscriptProcessing(forMeetingID: meetingId, requestId: requestId, interactiveAuth: true, forceRegenerate: true)
  3339. }
  3340. private func aiCompanionStartTranscriptProcessing(
  3341. forMeetingID meetingId: String,
  3342. requestId: UUID?,
  3343. interactiveAuth: Bool,
  3344. forceRegenerate: Bool
  3345. ) {
  3346. if !forceRegenerate,
  3347. let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }),
  3348. aiCompanionTranscriptStatus(for: recording) == .ready {
  3349. return
  3350. }
  3351. aiCompanionTranscriptTaskByMeetingId[meetingId]?.cancel()
  3352. _ = aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3353. recording.transcriptStatusRaw = MeetingTranscriptStatus.processing.rawValue
  3354. recording.transcriptErrorMessage = nil
  3355. if forceRegenerate {
  3356. recording.transcriptText = nil
  3357. recording.transcriptSourceRaw = nil
  3358. recording.notesStatusRaw = MeetingNotesStatus.notRequested.rawValue
  3359. recording.notesText = nil
  3360. recording.notesErrorMessage = nil
  3361. }
  3362. }
  3363. aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
  3364. if selectedSidebarPage == .aiCompanion {
  3365. pageCache[.aiCompanion] = nil
  3366. showSidebarPage(.aiCompanion)
  3367. }
  3368. let presentingWindow = view.window
  3369. let task = Task { [weak self] in
  3370. guard let self else { return }
  3371. defer { Task { @MainActor [weak self] in self?.aiCompanionTranscriptTaskByMeetingId[meetingId] = nil } }
  3372. do {
  3373. let result = try await self.aiCompanionFetchOrGenerateTranscript(
  3374. meetingId: meetingId,
  3375. interactiveAuth: interactiveAuth,
  3376. presentingWindow: presentingWindow
  3377. )
  3378. await MainActor.run {
  3379. guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
  3380. self.aiCompanionTranscriptProgressByMeetingId[meetingId] = nil
  3381. _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3382. recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
  3383. recording.transcriptSourceRaw = result.source.rawValue
  3384. recording.transcriptText = result.text
  3385. recording.transcriptSegmentsJSON = result.segmentsJSON
  3386. recording.transcriptErrorMessage = nil
  3387. }
  3388. self.aiCompanionTranscriptTextView?.string = result.text
  3389. self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
  3390. if self.selectedSidebarPage == .aiCompanion {
  3391. self.pageCache[.aiCompanion] = nil
  3392. self.showSidebarPage(.aiCompanion)
  3393. }
  3394. }
  3395. } catch {
  3396. await MainActor.run {
  3397. guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
  3398. self.aiCompanionTranscriptProgressByMeetingId[meetingId] = nil
  3399. let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
  3400. _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3401. recording.transcriptStatusRaw = MeetingTranscriptStatus.failed.rawValue
  3402. recording.transcriptErrorMessage = msg
  3403. }
  3404. self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
  3405. self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
  3406. if self.selectedSidebarPage == .aiCompanion {
  3407. self.pageCache[.aiCompanion] = nil
  3408. self.showSidebarPage(.aiCompanion)
  3409. }
  3410. }
  3411. }
  3412. }
  3413. aiCompanionTranscriptTaskByMeetingId[meetingId] = task
  3414. }
  3415. private func aiCompanionFetchOrGenerateTranscript(
  3416. meetingId: String,
  3417. interactiveAuth: Bool,
  3418. presentingWindow: NSWindow?
  3419. ) async throws -> (text: String, segmentsJSON: String?, source: MeetingTranscriptSource) {
  3420. guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
  3421. throw NSError(domain: "AiCompanionTranscript", code: 10, userInfo: [NSLocalizedDescriptionKey: "Recording not found."])
  3422. }
  3423. _ = interactiveAuth
  3424. _ = presentingWindow
  3425. let micURL: URL? = recording.microphoneAudioFilePath
  3426. .map { URL(fileURLWithPath: $0) }
  3427. .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
  3428. let systemURL: URL? = recording.systemAudioFilePath
  3429. .map { URL(fileURLWithPath: $0) }
  3430. .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
  3431. let hasPerChannel = micURL != nil || systemURL != nil
  3432. let mixedURL = URL(fileURLWithPath: recording.audioFilePath)
  3433. if hasPerChannel == false {
  3434. guard FileManager.default.fileExists(atPath: mixedURL.path) else {
  3435. throw NSError(domain: "AiCompanionTranscript", code: 12, userInfo: [NSLocalizedDescriptionKey: "Local meeting audio is missing."])
  3436. }
  3437. }
  3438. let preferredLocales = aiCompanionPreferredTranscriptionLocales()
  3439. let progressHandler: @Sendable (MeetingTranscriptionProgress) -> Void = { [weak self] progress in
  3440. Task { @MainActor [weak self] in
  3441. guard let self else { return }
  3442. let text: String
  3443. if progress.totalChunks > 0 {
  3444. text = "Transcribing \(progress.completedChunks)/\(progress.totalChunks) chunks..."
  3445. } else {
  3446. text = "Transcript processing..."
  3447. }
  3448. self.aiCompanionTranscriptProgressByMeetingId[meetingId] = text
  3449. self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
  3450. }
  3451. }
  3452. let segments: [TranscriptSegment]
  3453. let source: MeetingTranscriptSource
  3454. if hasPerChannel {
  3455. segments = try await meetingTranscriptionService.transcribeMeeting(
  3456. micURL: micURL,
  3457. systemURL: systemURL,
  3458. locales: preferredLocales,
  3459. onProgress: progressHandler
  3460. )
  3461. source = .localMultiChannelAppleSpeech
  3462. } else {
  3463. // Backward compatibility: old recordings only have a mixed file.
  3464. segments = try await meetingTranscriptionService.transcribeMeeting(
  3465. micURL: nil,
  3466. systemURL: mixedURL,
  3467. locales: preferredLocales,
  3468. onProgress: progressHandler
  3469. )
  3470. source = .localAudioAppleSpeech
  3471. }
  3472. let renderedText = segments.renderedTimelineText().trimmingCharacters(in: .whitespacesAndNewlines)
  3473. guard renderedText.isEmpty == false else {
  3474. throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
  3475. }
  3476. let encoder = JSONEncoder()
  3477. let segmentsJSON = (try? encoder.encode(segments)).flatMap { String(data: $0, encoding: .utf8) }
  3478. return (renderedText, segmentsJSON, source)
  3479. }
  3480. private func aiCompanionPreferredTranscriptionLocales() -> [Locale] {
  3481. let defaults = UserDefaults.standard
  3482. let selectedIdentifiers = [
  3483. defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
  3484. defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
  3485. ]
  3486. .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
  3487. .filter { $0.isEmpty == false }
  3488. var locales: [Locale] = []
  3489. var seen = Set<String>()
  3490. func appendIdentifier(_ identifier: String) {
  3491. let locale = Locale(identifier: identifier)
  3492. guard SFSpeechRecognizer(locale: locale)?.isAvailable == true else { return }
  3493. let normalized = identifier.replacingOccurrences(of: "_", with: "-").lowercased()
  3494. guard seen.contains(normalized) == false else { return }
  3495. seen.insert(normalized)
  3496. locales.append(locale)
  3497. }
  3498. for identifier in selectedIdentifiers {
  3499. appendIdentifier(identifier)
  3500. }
  3501. // Safe defaults for mixed-language meetings if user has not configured preferences yet.
  3502. appendIdentifier(Locale.current.identifier)
  3503. appendIdentifier("en-US")
  3504. if locales.isEmpty {
  3505. return [Locale(identifier: "en-US")]
  3506. }
  3507. return locales
  3508. }
  3509. private func aiCompanionSupportedSpeechLocaleOptions() -> [SpeechLocaleOption] {
  3510. let supported = SFSpeechRecognizer.supportedLocales()
  3511. let currentIdentifier = Locale.current.identifier
  3512. let englishUSIdentifier = "en-US"
  3513. var candidates = Set<String>(supported.map { $0.identifier })
  3514. for preferred in Locale.preferredLanguages {
  3515. candidates.insert(preferred)
  3516. }
  3517. candidates.insert(currentIdentifier)
  3518. candidates.insert(englishUSIdentifier)
  3519. return candidates
  3520. .map { identifier -> SpeechLocaleOption in
  3521. let locale = Locale(identifier: identifier)
  3522. let languageCode = locale.languageCode ?? Locale.components(fromIdentifier: identifier)[NSLocale.Key.languageCode.rawValue]
  3523. let languageName = Locale.current.localizedString(forIdentifier: identifier)
  3524. ?? languageCode.flatMap { Locale.current.localizedString(forLanguageCode: $0) }
  3525. ?? identifier
  3526. return SpeechLocaleOption(identifier: identifier, displayName: "\(languageName) (\(identifier))")
  3527. }
  3528. .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
  3529. }
  3530. private func updateAiCompanionPreferredSpeechLanguages(primary: String, secondary: String?) {
  3531. let normalizedPrimary = primary.trimmingCharacters(in: .whitespacesAndNewlines)
  3532. guard normalizedPrimary.isEmpty == false else { return }
  3533. UserDefaults.standard.set(normalizedPrimary, forKey: aiCompanionPreferredLanguage1DefaultsKey)
  3534. let cleanedSecondary = secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
  3535. if let cleanedSecondary, cleanedSecondary.isEmpty == false {
  3536. let normalizedPrimaryKey = normalizedPrimary.replacingOccurrences(of: "_", with: "-").lowercased()
  3537. let normalizedSecondaryKey = cleanedSecondary.replacingOccurrences(of: "_", with: "-").lowercased()
  3538. if normalizedPrimaryKey == normalizedSecondaryKey {
  3539. UserDefaults.standard.removeObject(forKey: aiCompanionPreferredLanguage2DefaultsKey)
  3540. } else {
  3541. UserDefaults.standard.set(cleanedSecondary, forKey: aiCompanionPreferredLanguage2DefaultsKey)
  3542. }
  3543. } else {
  3544. UserDefaults.standard.removeObject(forKey: aiCompanionPreferredLanguage2DefaultsKey)
  3545. }
  3546. }
  3547. @objc private func aiCompanionNotesTapped(_ sender: NSButton) {
  3548. guard storeKitCoordinator.hasPremiumAccess else {
  3549. showPaywall()
  3550. return
  3551. }
  3552. let senderId = ObjectIdentifier(sender)
  3553. guard let meetingId = aiCompanionNotesMeetingIdByView[senderId] else { return }
  3554. guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
  3555. aiCompanionNotesStatusLabelByView[senderId]?.stringValue = "Notes unavailable (tap to retry)"
  3556. showSimpleAlert(title: "Notes unavailable", message: "Could not find recording details for this meeting.")
  3557. return
  3558. }
  3559. if aiCompanionNotesStatus(for: recording) == .ready,
  3560. let cached = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
  3561. cached.isEmpty == false {
  3562. aiCompanionPresentNotesWindow(meetingTitle: recording.title, initialText: cached)
  3563. aiCompanionNotesStatusLabelByView[senderId]?.stringValue = aiCompanionNotesStatusText(for: recording)
  3564. return
  3565. }
  3566. aiCompanionNotesStatusLabelByView[senderId]?.stringValue = "Preparing notes, please wait..."
  3567. let requestId = UUID()
  3568. aiCompanionNotesCurrentRequestId = requestId
  3569. // Do NOT open the notes popup yet. It will be opened automatically
  3570. // once the notes are ready (or stay closed if generation fails).
  3571. aiCompanionStartNotesProcessing(forMeetingID: meetingId, requestId: requestId, autoPresentOnReady: true)
  3572. }
  3573. private func aiCompanionStartNotesProcessing(
  3574. forMeetingID meetingId: String,
  3575. requestId: UUID?,
  3576. autoPresentOnReady: Bool = false
  3577. ) {
  3578. aiCompanionNotesTaskByMeetingId[meetingId]?.cancel()
  3579. _ = aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3580. recording.notesStatusRaw = MeetingNotesStatus.processing.rawValue
  3581. recording.notesErrorMessage = nil
  3582. recording.notesText = nil
  3583. }
  3584. aiCompanionNotesProgressByMeetingId[meetingId] = "Preparing notes, please wait..."
  3585. aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
  3586. if selectedSidebarPage == .aiCompanion {
  3587. pageCache[.aiCompanion] = nil
  3588. showSidebarPage(.aiCompanion)
  3589. }
  3590. let presentingWindow = view.window
  3591. let task = Task { [weak self] in
  3592. guard let self else { return }
  3593. defer { Task { @MainActor [weak self] in self?.aiCompanionNotesTaskByMeetingId[meetingId] = nil } }
  3594. do {
  3595. let notes = try await self.aiCompanionGenerateNotesForMeeting(meetingId: meetingId, presentingWindow: presentingWindow)
  3596. await MainActor.run {
  3597. guard requestId == nil || self.aiCompanionNotesCurrentRequestId == requestId else { return }
  3598. self.aiCompanionNotesProgressByMeetingId[meetingId] = nil
  3599. _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3600. recording.notesStatusRaw = MeetingNotesStatus.ready.rawValue
  3601. recording.notesText = notes
  3602. recording.notesErrorMessage = nil
  3603. }
  3604. self.aiCompanionNotesTextView?.string = notes
  3605. self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
  3606. if self.selectedSidebarPage == .aiCompanion {
  3607. self.pageCache[.aiCompanion] = nil
  3608. self.showSidebarPage(.aiCompanion)
  3609. }
  3610. if autoPresentOnReady {
  3611. let title = self.aiCompanionLocalRecordings.first(where: { $0.id == meetingId })?.title ?? "Meeting"
  3612. self.aiCompanionPresentNotesWindow(meetingTitle: title, initialText: notes)
  3613. }
  3614. }
  3615. } catch {
  3616. await MainActor.run {
  3617. guard requestId == nil || self.aiCompanionNotesCurrentRequestId == requestId else { return }
  3618. self.aiCompanionNotesProgressByMeetingId[meetingId] = nil
  3619. let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
  3620. _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3621. recording.notesStatusRaw = MeetingNotesStatus.failed.rawValue
  3622. recording.notesErrorMessage = msg
  3623. }
  3624. self.aiCompanionNotesTextView?.string = "Notes unavailable.\n\n\(msg)"
  3625. self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
  3626. if self.selectedSidebarPage == .aiCompanion {
  3627. self.pageCache[.aiCompanion] = nil
  3628. self.showSidebarPage(.aiCompanion)
  3629. }
  3630. }
  3631. }
  3632. }
  3633. aiCompanionNotesTaskByMeetingId[meetingId] = task
  3634. }
  3635. private func aiCompanionGenerateNotesForMeeting(meetingId: String, presentingWindow: NSWindow?) async throws -> String {
  3636. let transcriptText: String
  3637. if let existing = aiCompanionLocalRecordings.first(where: { $0.id == meetingId })?.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines),
  3638. existing.isEmpty == false {
  3639. transcriptText = existing
  3640. } else {
  3641. let transcript = try await aiCompanionFetchOrGenerateTranscript(
  3642. meetingId: meetingId,
  3643. interactiveAuth: true,
  3644. presentingWindow: presentingWindow
  3645. )
  3646. await MainActor.run {
  3647. _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
  3648. recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
  3649. recording.transcriptSourceRaw = transcript.source.rawValue
  3650. recording.transcriptText = transcript.text
  3651. recording.transcriptSegmentsJSON = transcript.segmentsJSON
  3652. recording.transcriptErrorMessage = nil
  3653. }
  3654. self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
  3655. }
  3656. transcriptText = transcript.text
  3657. }
  3658. await MainActor.run {
  3659. self.aiCompanionNotesProgressByMeetingId[meetingId] = "Preparing notes, please wait..."
  3660. self.aiCompanionRefreshNotesStatusLabels(forMeetingID: meetingId)
  3661. }
  3662. let defaults = UserDefaults.standard
  3663. let preferred1 = defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
  3664. let preferred2 = defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
  3665. return try await meetingNotesService.generateNotes(
  3666. from: transcriptText,
  3667. preferredLanguageIdentifier1: preferred1,
  3668. preferredLanguageIdentifier2: preferred2
  3669. )
  3670. }
  3671. @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
  3672. finishActiveMeetingRecording()
  3673. }
  3674. private func aiCompanionAudioTimeControlObserverResetForFailure() {
  3675. aiCompanionAudioPlayer?.pause()
  3676. aiCompanionAudioPlayer = nil
  3677. aiCompanionLocalAudioPlayer?.stop()
  3678. aiCompanionLocalAudioPlayer = nil
  3679. aiCompanionTimeControlObserver = nil
  3680. if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
  3681. if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
  3682. aiCompanionPlaybackEndObserver = nil
  3683. aiCompanionPlaybackFailedObserver = nil
  3684. }
  3685. private func aiCompanionStartSpeech(forButtonId buttonId: ObjectIdentifier) {
  3686. // Stop any remote audio playback first.
  3687. aiCompanionAudioPlayer?.pause()
  3688. aiCompanionAudioPlayer = nil
  3689. aiCompanionTimeControlObserver = nil
  3690. if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
  3691. if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
  3692. aiCompanionPlaybackEndObserver = nil
  3693. aiCompanionPlaybackFailedObserver = nil
  3694. aiCompanionNoProgressWorkItem?.cancel()
  3695. aiCompanionNoProgressWorkItem = nil
  3696. aiCompanionIsUsingSpeech = true
  3697. aiCompanionCurrentlySpeakingButtonId = buttonId
  3698. if let button = aiCompanionCurrentlyPlayingButton, ObjectIdentifier(button) == buttonId {
  3699. button.title = "Pause Audio"
  3700. }
  3701. aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Speaking..."
  3702. let text = aiCompanionSpeechTextByView[buttonId] ?? "Ended meeting."
  3703. let utterance = AVSpeechUtterance(string: text)
  3704. utterance.rate = 0.5
  3705. utterance.volume = 1.0
  3706. aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
  3707. aiCompanionSpeechSynthesizer.speak(utterance)
  3708. }
  3709. private func aiCompanionAudioDidFinish() {
  3710. guard let button = aiCompanionCurrentlyPlayingButton else { return }
  3711. let buttonId = ObjectIdentifier(button)
  3712. aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
  3713. aiCompanionIsUsingSpeech = false
  3714. aiCompanionCurrentlySpeakingButtonId = nil
  3715. button.title = "Play Audio"
  3716. aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Finished"
  3717. aiCompanionAudioPlayer?.pause()
  3718. aiCompanionAudioPlayer = nil
  3719. aiCompanionLocalAudioPlayer?.stop()
  3720. aiCompanionLocalAudioPlayer = nil
  3721. aiCompanionCurrentlyPlayingURL = nil
  3722. aiCompanionCurrentlyPlayingButton = nil
  3723. if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
  3724. if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
  3725. aiCompanionPlaybackEndObserver = nil
  3726. aiCompanionPlaybackFailedObserver = nil
  3727. aiCompanionTimeControlObserver = nil
  3728. }
  3729. private func aiCompanionAudioDidFail(notification: Notification) {
  3730. guard let button = aiCompanionCurrentlyPlayingButton else { return }
  3731. let buttonId = ObjectIdentifier(button)
  3732. aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
  3733. aiCompanionIsUsingSpeech = false
  3734. aiCompanionCurrentlySpeakingButtonId = nil
  3735. button.title = "Play Audio"
  3736. var message = "Failed to play audio"
  3737. if let item = notification.object as? AVPlayerItem, let error = item.error {
  3738. message = "Failed: \(error.localizedDescription)"
  3739. }
  3740. aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = message
  3741. aiCompanionAudioPlayer?.pause()
  3742. aiCompanionAudioPlayer = nil
  3743. aiCompanionLocalAudioPlayer?.stop()
  3744. aiCompanionLocalAudioPlayer = nil
  3745. aiCompanionCurrentlyPlayingURL = nil
  3746. aiCompanionCurrentlyPlayingButton = nil
  3747. if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
  3748. if let token = aiCompanionPlaybackFailedObserver { NotificationCenter.default.removeObserver(token) }
  3749. aiCompanionPlaybackEndObserver = nil
  3750. aiCompanionPlaybackFailedObserver = nil
  3751. aiCompanionTimeControlObserver = nil
  3752. }
  3753. @MainActor
  3754. private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
  3755. if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
  3756. window.title = "Transcript - \(meetingTitle)"
  3757. textView.string = initialText
  3758. window.makeKeyAndOrderFront(nil)
  3759. NSApp.activate(ignoringOtherApps: true)
  3760. return
  3761. }
  3762. let windowWidth: CGFloat = 640
  3763. let windowHeight: CGFloat = 560
  3764. let window = NSWindow(
  3765. contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
  3766. styleMask: [.titled, .closable, .resizable],
  3767. backing: .buffered,
  3768. defer: false
  3769. )
  3770. window.isReleasedWhenClosed = false
  3771. window.title = "Transcript - \(meetingTitle)"
  3772. window.center()
  3773. let root = NSView()
  3774. root.translatesAutoresizingMaskIntoConstraints = false
  3775. let scroll = NSScrollView()
  3776. scroll.translatesAutoresizingMaskIntoConstraints = false
  3777. scroll.drawsBackground = false
  3778. scroll.hasVerticalScroller = true
  3779. let textView = NSTextView()
  3780. textView.isEditable = false
  3781. textView.isSelectable = true
  3782. textView.backgroundColor = .clear
  3783. textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  3784. textView.string = initialText
  3785. textView.textContainer?.widthTracksTextView = true
  3786. scroll.documentView = textView
  3787. root.addSubview(scroll)
  3788. NSLayoutConstraint.activate([
  3789. scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3790. scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3791. scroll.topAnchor.constraint(equalTo: root.topAnchor),
  3792. scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  3793. ])
  3794. window.contentView = root
  3795. window.makeKeyAndOrderFront(nil)
  3796. NSApp.activate(ignoringOtherApps: true)
  3797. aiCompanionTranscriptWindow = window
  3798. aiCompanionTranscriptTextView = textView
  3799. }
  3800. @MainActor
  3801. private func aiCompanionPresentNotesWindow(meetingTitle: String, initialText: String) {
  3802. guard let hostWindow = view.window else { return }
  3803. let hostIsDarkMode = darkModeEnabled
  3804. let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
  3805. let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
  3806. let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
  3807. if let panel = aiCompanionNotesPanel, let textView = aiCompanionNotesTextView {
  3808. panel.title = "Notes - \(meetingTitle)"
  3809. panel.appearance = NSAppearance(named: panelAppearanceName)
  3810. panel.backgroundColor = outerBackgroundColor
  3811. textView.string = initialText
  3812. aiCompanionNotesRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
  3813. aiCompanionNotesHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
  3814. aiCompanionNotesTitleLabel?.textColor = hostIsDarkMode ? .labelColor : .black
  3815. aiCompanionNotesSubtitleLabel?.stringValue = meetingTitle
  3816. if hostWindow.attachedSheet !== panel {
  3817. hostWindow.beginSheet(panel, completionHandler: nil)
  3818. }
  3819. aiCompanionStartNotesOutsideClickMonitor()
  3820. panel.makeKeyAndOrderFront(nil)
  3821. NSApp.activate(ignoringOtherApps: true)
  3822. return
  3823. }
  3824. let panelWidth: CGFloat = 720
  3825. let panelHeight: CGFloat = 540
  3826. let panel = NSPanel(
  3827. contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
  3828. styleMask: [.titled, .closable],
  3829. backing: .buffered,
  3830. defer: false
  3831. )
  3832. panel.isReleasedWhenClosed = false
  3833. panel.title = "Notes - \(meetingTitle)"
  3834. panel.isFloatingPanel = false
  3835. panel.hidesOnDeactivate = false
  3836. panel.delegate = self
  3837. panel.appearance = NSAppearance(named: panelAppearanceName)
  3838. panel.backgroundColor = outerBackgroundColor
  3839. panel.standardWindowButton(.zoomButton)?.isHidden = true
  3840. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  3841. let root = NSView()
  3842. root.translatesAutoresizingMaskIntoConstraints = false
  3843. root.wantsLayer = true
  3844. root.layer?.backgroundColor = outerBackgroundColor.cgColor
  3845. let header = NSView()
  3846. header.translatesAutoresizingMaskIntoConstraints = false
  3847. header.wantsLayer = true
  3848. header.layer?.backgroundColor = headerBackgroundColor.cgColor
  3849. header.layer?.cornerRadius = 10
  3850. let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
  3851. let titleLabel = textLabel("Meeting Notes", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: headingColor)
  3852. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  3853. let subtitleLabel = textLabel(meetingTitle, font: NSFont.systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
  3854. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  3855. subtitleLabel.lineBreakMode = .byTruncatingTail
  3856. let scroll = NSScrollView()
  3857. scroll.translatesAutoresizingMaskIntoConstraints = false
  3858. scroll.drawsBackground = true
  3859. scroll.backgroundColor = NSColor.textBackgroundColor
  3860. scroll.hasVerticalScroller = true
  3861. scroll.hasHorizontalScroller = false
  3862. scroll.borderType = .bezelBorder
  3863. let textView = NSTextView()
  3864. textView.isEditable = false
  3865. textView.isSelectable = true
  3866. textView.backgroundColor = NSColor.textBackgroundColor
  3867. textView.textColor = NSColor.labelColor
  3868. textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  3869. textView.string = initialText
  3870. textView.textContainer?.widthTracksTextView = true
  3871. textView.textContainerInset = NSSize(width: 10, height: 10)
  3872. let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionCopyNotesTapped(_:)))
  3873. copyButton.translatesAutoresizingMaskIntoConstraints = false
  3874. copyButton.bezelStyle = .rounded
  3875. let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionCloseNotesTapped(_:)))
  3876. closeButton.translatesAutoresizingMaskIntoConstraints = false
  3877. closeButton.bezelStyle = .rounded
  3878. scroll.documentView = textView
  3879. header.addSubview(titleLabel)
  3880. header.addSubview(subtitleLabel)
  3881. header.addSubview(copyButton)
  3882. header.addSubview(closeButton)
  3883. root.addSubview(header)
  3884. root.addSubview(scroll)
  3885. NSLayoutConstraint.activate([
  3886. header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  3887. header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  3888. header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  3889. titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
  3890. titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  3891. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  3892. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  3893. subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  3894. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  3895. subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
  3896. copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  3897. copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
  3898. closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  3899. closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
  3900. scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
  3901. scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  3902. scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  3903. scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
  3904. ])
  3905. panel.contentView = root
  3906. hostWindow.beginSheet(panel, completionHandler: nil)
  3907. aiCompanionStartNotesOutsideClickMonitor()
  3908. panel.makeKeyAndOrderFront(nil)
  3909. NSApp.activate(ignoringOtherApps: true)
  3910. aiCompanionNotesPanel = panel
  3911. aiCompanionNotesRootView = root
  3912. aiCompanionNotesHeaderView = header
  3913. aiCompanionNotesTitleLabel = titleLabel
  3914. aiCompanionNotesSubtitleLabel = subtitleLabel
  3915. aiCompanionNotesTextView = textView
  3916. aiCompanionNotesCopyButton = copyButton
  3917. aiCompanionNotesCloseButton = closeButton
  3918. }
  3919. @objc private func aiCompanionCopyNotesTapped(_ sender: NSButton) {
  3920. guard let notes = aiCompanionNotesTextView?.string, notes.isEmpty == false else { return }
  3921. NSPasteboard.general.clearContents()
  3922. NSPasteboard.general.setString(notes, forType: .string)
  3923. sender.title = "Copied"
  3924. DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
  3925. guard self?.aiCompanionNotesCopyButton === sender else { return }
  3926. sender.title = "Copy"
  3927. }
  3928. }
  3929. @objc private func aiCompanionCloseNotesTapped(_ sender: NSButton) {
  3930. aiCompanionCloseNotesPanel()
  3931. }
  3932. private func aiCompanionStartNotesOutsideClickMonitor() {
  3933. guard aiCompanionNotesOutsideClickMonitor == nil else { return }
  3934. aiCompanionNotesOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(
  3935. matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]
  3936. ) { [weak self] event in
  3937. guard let self, let panel = self.aiCompanionNotesPanel else { return event }
  3938. if event.window === panel { return event }
  3939. self.aiCompanionCloseNotesPanel()
  3940. return event
  3941. }
  3942. }
  3943. private func aiCompanionStopNotesOutsideClickMonitor() {
  3944. guard let monitor = aiCompanionNotesOutsideClickMonitor else { return }
  3945. NSEvent.removeMonitor(monitor)
  3946. aiCompanionNotesOutsideClickMonitor = nil
  3947. }
  3948. private func aiCompanionCloseNotesPanel() {
  3949. guard let panel = aiCompanionNotesPanel else { return }
  3950. aiCompanionStopNotesOutsideClickMonitor()
  3951. if let hostWindow = view.window, hostWindow.attachedSheet === panel {
  3952. hostWindow.endSheet(panel)
  3953. }
  3954. panel.orderOut(nil)
  3955. }
  3956. private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
  3957. // Typical: https://meet.google.com/abc-defg-hij
  3958. // But scheduled events can include trailing slashes and extra segments/query params.
  3959. guard let host = meetURL.host?.lowercased(),
  3960. host == "meet.google.com" || host.hasSuffix(".meet.google.com") else { return nil }
  3961. // Prefer path segments (ignoring the "/" components that URL.pathComponents includes).
  3962. let rawSegments = meetURL.pathComponents
  3963. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  3964. .filter { $0.isEmpty == false && $0 != "/" }
  3965. for segment in rawSegments {
  3966. if isValidMeetMeetingCode(segment) {
  3967. return segment.lowercased()
  3968. }
  3969. }
  3970. // Fallback: handle weird path formats by scanning the trimmed path.
  3971. let trimmedPath = meetURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  3972. if let first = trimmedPath.split(separator: "/").first.map(String.init),
  3973. isValidMeetMeetingCode(first) {
  3974. return first.lowercased()
  3975. }
  3976. return nil
  3977. }
  3978. private func aiCompanionSelectConferenceRecord(for meeting: ScheduledMeeting, from records: [ConferenceRecord]) -> ConferenceRecord? {
  3979. // Prefer a record whose time window overlaps the calendar meeting.
  3980. let overlapping = records.filter { r in
  3981. guard let start = r.startTime, let end = r.endTime else { return false }
  3982. return start <= meeting.endDate && end >= meeting.startDate
  3983. }
  3984. if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
  3985. return best
  3986. }
  3987. // Fallback: choose the most recent one we can.
  3988. return records.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
  3989. }
  3990. private func aiCompanionSelectTranscript(for meeting: ScheduledMeeting, from transcripts: [Transcript]) -> Transcript? {
  3991. let overlapping = transcripts.filter { t in
  3992. guard let start = t.startTime, let end = t.endTime else { return false }
  3993. return start <= meeting.endDate && end >= meeting.startDate
  3994. }
  3995. if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
  3996. return best
  3997. }
  3998. return transcripts.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
  3999. }
  4000. private func aiCompanionFormatTranscriptText(entries: [TranscriptEntry]) -> String {
  4001. let lines = entries.compactMap { entry -> String? in
  4002. guard let raw = entry.text else { return nil }
  4003. let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  4004. guard t.isEmpty == false else { return nil }
  4005. let speaker = entry.participant?.trimmingCharacters(in: .whitespacesAndNewlines)
  4006. guard let speaker, speaker.isEmpty == false else { return t }
  4007. return "\(speaker): \(t)"
  4008. }
  4009. if lines.isEmpty { return "(No transcript entries found.)" }
  4010. return lines.joined(separator: "\n")
  4011. }
  4012. private func aiCompanionFetchTranscriptText(for meeting: ScheduledMeeting, accessToken: String) async throws -> String {
  4013. guard let meetingCode = aiCompanionMeetMeetingCode(from: meeting.meetURL) else {
  4014. throw NSError(
  4015. domain: "AiCompanionTranscript",
  4016. code: 1,
  4017. userInfo: [NSLocalizedDescriptionKey: "Couldn't determine Meet meeting code from URL."]
  4018. )
  4019. }
  4020. // Resolve `spaces/{meetingCode}` into a stable space resource name (`spaces/{spaceId}`).
  4021. let space = try await googleMeetClient.getSpace(accessToken: accessToken, spaceNameOrMeetingCode: "spaces/\(meetingCode)")
  4022. let spaceName = space.name ?? "spaces/\(meetingCode)"
  4023. let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceName)
  4024. guard let conferenceRecord = aiCompanionSelectConferenceRecord(for: meeting, from: records),
  4025. let conferenceRecordName = conferenceRecord.name else {
  4026. throw NSError(
  4027. domain: "AiCompanionTranscript",
  4028. code: 2,
  4029. userInfo: [NSLocalizedDescriptionKey: "No conference record found for this meeting."]
  4030. )
  4031. }
  4032. let transcripts = try await googleMeetClient.listTranscripts(accessToken: accessToken, conferenceRecordName: conferenceRecordName)
  4033. guard let transcript = aiCompanionSelectTranscript(for: meeting, from: transcripts),
  4034. let transcriptName = transcript.name else {
  4035. throw NSError(
  4036. domain: "AiCompanionTranscript",
  4037. code: 3,
  4038. userInfo: [NSLocalizedDescriptionKey: "No transcript found for this meeting."]
  4039. )
  4040. }
  4041. let entries = try await googleMeetClient.listTranscriptEntries(accessToken: accessToken, transcriptName: transcriptName)
  4042. return aiCompanionFormatTranscriptText(entries: entries)
  4043. }
  4044. private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
  4045. let panel = NSView()
  4046. panel.translatesAutoresizingMaskIntoConstraints = false
  4047. let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary)
  4048. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  4049. let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary)
  4050. sub.translatesAutoresizingMaskIntoConstraints = false
  4051. panel.addSubview(titleLabel)
  4052. panel.addSubview(sub)
  4053. NSLayoutConstraint.activate([
  4054. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  4055. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor),
  4056. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4057. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  4058. ])
  4059. return panel
  4060. }
  4061. private func makeSettingsPageContent() -> NSView {
  4062. let panel = NSView()
  4063. panel.translatesAutoresizingMaskIntoConstraints = false
  4064. let scroll = NSScrollView()
  4065. scroll.translatesAutoresizingMaskIntoConstraints = false
  4066. scroll.drawsBackground = false
  4067. scroll.hasHorizontalScroller = false
  4068. scroll.hasVerticalScroller = true
  4069. scroll.autohidesScrollers = true
  4070. scroll.borderType = .noBorder
  4071. scroll.scrollerStyle = .overlay
  4072. scroll.automaticallyAdjustsContentInsets = false
  4073. let clip = TopAlignedClipView()
  4074. clip.drawsBackground = false
  4075. scroll.contentView = clip
  4076. panel.addSubview(scroll)
  4077. let content = NSView()
  4078. content.translatesAutoresizingMaskIntoConstraints = false
  4079. scroll.documentView = content
  4080. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  4081. card.translatesAutoresizingMaskIntoConstraints = false
  4082. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  4083. content.addSubview(card)
  4084. let stack = NSStackView()
  4085. stack.translatesAutoresizingMaskIntoConstraints = false
  4086. stack.orientation = .vertical
  4087. stack.spacing = 18
  4088. stack.alignment = .leading
  4089. card.addSubview(stack)
  4090. let pageTitle = textLabel("Settings", font: typography.pageTitle, color: palette.textPrimary)
  4091. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: typography.fieldLabel, color: palette.textSecondary)
  4092. stack.addArrangedSubview(pageTitle)
  4093. stack.addArrangedSubview(pageSubtitle)
  4094. stack.setCustomSpacing(24, after: pageSubtitle)
  4095. let appearanceTitle = textLabel("Appearance", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4096. stack.addArrangedSubview(appearanceTitle)
  4097. let darkModeRow = makeSettingsDarkModeRow()
  4098. stack.addArrangedSubview(darkModeRow)
  4099. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4100. stack.setCustomSpacing(24, after: darkModeRow)
  4101. let aiCompanionTitle = textLabel("AI Companion", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4102. stack.addArrangedSubview(aiCompanionTitle)
  4103. let language1Row = makeSettingsSpeechLanguageRow(title: "Preferred Language 1", isPrimary: true)
  4104. stack.addArrangedSubview(language1Row)
  4105. language1Row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4106. let language2Row = makeSettingsSpeechLanguageRow(title: "Preferred Language 2", isPrimary: false)
  4107. stack.addArrangedSubview(language2Row)
  4108. language2Row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4109. stack.setCustomSpacing(24, after: language2Row)
  4110. let accountTitle = textLabel("Account", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4111. stack.addArrangedSubview(accountTitle)
  4112. let googleAccountRow = makeSettingsGoogleAccountRow()
  4113. stack.addArrangedSubview(googleAccountRow)
  4114. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4115. stack.setCustomSpacing(24, after: googleAccountRow)
  4116. let appTitle = textLabel("App", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4117. stack.addArrangedSubview(appTitle)
  4118. if shouldShowRateUsInSettings {
  4119. let rateButton = makeSettingsActionButton(icon: "★", title: "Rate Us", action: .rateUs)
  4120. stack.addArrangedSubview(rateButton)
  4121. rateButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4122. }
  4123. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  4124. stack.addArrangedSubview(shareButton)
  4125. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4126. if storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess {
  4127. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  4128. stack.addArrangedSubview(upgradeButton)
  4129. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4130. stack.setCustomSpacing(24, after: upgradeButton)
  4131. } else {
  4132. stack.setCustomSpacing(24, after: shareButton)
  4133. }
  4134. if storeKitCoordinator.hasPremiumAccess {
  4135. let notificationsTitle = textLabel("Notifications", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4136. stack.addArrangedSubview(notificationsTitle)
  4137. let remindersSection = makeSettingsRemindersSection()
  4138. stack.addArrangedSubview(remindersSection)
  4139. remindersSection.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4140. stack.setCustomSpacing(24, after: remindersSection)
  4141. }
  4142. let legalTitle = textLabel("Help & Legal", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4143. stack.addArrangedSubview(legalTitle)
  4144. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  4145. stack.addArrangedSubview(privacyButton)
  4146. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4147. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  4148. stack.addArrangedSubview(supportButton)
  4149. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4150. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  4151. stack.addArrangedSubview(termsButton)
  4152. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4153. NSLayoutConstraint.activate([
  4154. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  4155. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  4156. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  4157. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  4158. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  4159. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  4160. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  4161. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  4162. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  4163. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  4164. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 36),
  4165. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  4166. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  4167. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  4168. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  4169. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  4170. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  4171. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  4172. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  4173. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  4174. ])
  4175. return panel
  4176. }
  4177. private func makeSettingsDarkModeRow() -> NSView {
  4178. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4179. row.translatesAutoresizingMaskIntoConstraints = false
  4180. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  4181. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4182. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: palette.textPrimary)
  4183. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  4184. let toggle = NSSwitch()
  4185. toggle.translatesAutoresizingMaskIntoConstraints = false
  4186. toggle.state = darkModeEnabled ? .on : .off
  4187. toggle.target = self
  4188. toggle.action = #selector(settingsPageDarkModeToggled(_:))
  4189. row.addSubview(icon)
  4190. row.addSubview(title)
  4191. row.addSubview(toggle)
  4192. NSLayoutConstraint.activate([
  4193. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4194. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4195. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  4196. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4197. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4198. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4199. ])
  4200. return row
  4201. }
  4202. private func makeSettingsSpeechLanguageRow(title: String, isPrimary: Bool) -> NSView {
  4203. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4204. row.translatesAutoresizingMaskIntoConstraints = false
  4205. row.heightAnchor.constraint(equalToConstant: 72).isActive = true
  4206. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4207. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
  4208. let popup = NSPopUpButton(frame: .zero, pullsDown: false)
  4209. popup.translatesAutoresizingMaskIntoConstraints = false
  4210. popup.target = self
  4211. popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
  4212. let options = aiCompanionSupportedSpeechLocaleOptions()
  4213. if isPrimary == false {
  4214. popup.addItem(withTitle: "None")
  4215. popup.lastItem?.representedObject = ""
  4216. }
  4217. for option in options {
  4218. popup.addItem(withTitle: option.displayName)
  4219. popup.lastItem?.representedObject = option.identifier
  4220. }
  4221. if isPrimary {
  4222. let selected = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
  4223. ?? options.first?.identifier
  4224. ?? Locale.current.identifier
  4225. selectSettingsPageLanguage(identifier: selected, in: popup)
  4226. settingsPagePrimaryLanguagePopup = popup
  4227. } else {
  4228. if let selected = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
  4229. selected.isEmpty == false {
  4230. selectSettingsPageLanguage(identifier: selected, in: popup)
  4231. } else {
  4232. popup.selectItem(at: 0)
  4233. }
  4234. settingsPageSecondaryLanguagePopup = popup
  4235. }
  4236. row.addSubview(titleLabel)
  4237. row.addSubview(popup)
  4238. NSLayoutConstraint.activate([
  4239. titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4240. titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  4241. titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4242. popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4243. popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4244. popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  4245. popup.heightAnchor.constraint(equalToConstant: 28)
  4246. ])
  4247. return row
  4248. }
  4249. private func selectSettingsPageLanguage(identifier: String, in popup: NSPopUpButton) {
  4250. for item in popup.itemArray {
  4251. if let value = item.representedObject as? String, value == identifier {
  4252. popup.select(item)
  4253. return
  4254. }
  4255. }
  4256. }
  4257. @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
  4258. guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
  4259. primary.isEmpty == false else { return }
  4260. var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
  4261. if secondary?.isEmpty == true {
  4262. secondary = nil
  4263. }
  4264. if let secondaryValue = secondary,
  4265. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  4266. secondary = nil
  4267. settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
  4268. }
  4269. updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
  4270. }
  4271. private func makeSettingsRemindersSection() -> NSView {
  4272. let container = NSStackView()
  4273. container.translatesAutoresizingMaskIntoConstraints = false
  4274. container.orientation = .vertical
  4275. container.spacing = 1
  4276. container.alignment = .leading
  4277. let masterRow = makeReminderToggleRow(
  4278. icon: "🔔",
  4279. title: "Reminders",
  4280. state: ReminderPreferences.remindersEnabled,
  4281. action: #selector(settingsReminderMasterToggled(_:)),
  4282. isSubRow: false
  4283. )
  4284. container.addArrangedSubview(masterRow)
  4285. masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4286. let subAlpha: CGFloat = ReminderPreferences.remindersEnabled ? 1.0 : 0.4
  4287. let day1Row = makeReminderToggleRow(
  4288. icon: "📅",
  4289. title: "1 Day Before",
  4290. state: ReminderPreferences.remind1Day,
  4291. action: #selector(settingsReminder1DayToggled(_:)),
  4292. isSubRow: true
  4293. )
  4294. day1Row.alphaValue = subAlpha
  4295. container.addArrangedSubview(day1Row)
  4296. day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4297. let hours12Row = makeReminderToggleRow(
  4298. icon: "🕛",
  4299. title: "12 Hours Before",
  4300. state: ReminderPreferences.remind12Hours,
  4301. action: #selector(settingsReminder12HoursToggled(_:)),
  4302. isSubRow: true
  4303. )
  4304. hours12Row.alphaValue = subAlpha
  4305. container.addArrangedSubview(hours12Row)
  4306. hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4307. let hour1Row = makeReminderToggleRow(
  4308. icon: "⏰",
  4309. title: "1 Hour Before",
  4310. state: ReminderPreferences.remind1Hour,
  4311. action: #selector(settingsReminder1HourToggled(_:)),
  4312. isSubRow: true
  4313. )
  4314. hour1Row.alphaValue = subAlpha
  4315. container.addArrangedSubview(hour1Row)
  4316. hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4317. settingsReminderMasterSwitch = masterRow.subviews.compactMap { $0 as? NSSwitch }.first
  4318. settingsReminder1DaySwitch = day1Row.subviews.compactMap { $0 as? NSSwitch }.first
  4319. settingsReminder12HoursSwitch = hours12Row.subviews.compactMap { $0 as? NSSwitch }.first
  4320. settingsReminder1HourSwitch = hour1Row.subviews.compactMap { $0 as? NSSwitch }.first
  4321. return container
  4322. }
  4323. private func makeReminderToggleRow(icon: String, title: String, state: Bool, action: Selector, isSubRow: Bool) -> NSView {
  4324. let row = roundedContainer(cornerRadius: 10, color: isSubRow ? palette.inputBackground.withAlphaComponent(0.6) : palette.inputBackground)
  4325. row.translatesAutoresizingMaskIntoConstraints = false
  4326. row.heightAnchor.constraint(equalToConstant: 48).isActive = true
  4327. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4328. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: isSubRow ? 15 : 17, weight: .medium), color: palette.textPrimary)
  4329. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: isSubRow ? 14 : 15, weight: isSubRow ? .regular : .semibold), color: palette.textPrimary)
  4330. if isSubRow {
  4331. titleLabel.textColor = palette.textSecondary
  4332. }
  4333. let toggle = NSSwitch()
  4334. toggle.translatesAutoresizingMaskIntoConstraints = false
  4335. toggle.state = state ? .on : .off
  4336. toggle.target = self
  4337. toggle.action = action
  4338. row.addSubview(iconLabel)
  4339. row.addSubview(titleLabel)
  4340. row.addSubview(toggle)
  4341. NSLayoutConstraint.activate([
  4342. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: isSubRow ? 30 : 14),
  4343. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4344. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4345. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4346. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4347. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4348. ])
  4349. return row
  4350. }
  4351. @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
  4352. let enabled = sender.state == .on
  4353. ReminderPreferences.remindersEnabled = enabled
  4354. let subAlpha: CGFloat = enabled ? 1.0 : 0.4
  4355. settingsReminder1DaySwitch?.superview?.alphaValue = subAlpha
  4356. settingsReminder12HoursSwitch?.superview?.alphaValue = subAlpha
  4357. settingsReminder1HourSwitch?.superview?.alphaValue = subAlpha
  4358. if enabled {
  4359. MeetingReminderManager.shared.requestPermissionIfNeeded { [weak self] _ in
  4360. guard let self else { return }
  4361. MeetingReminderManager.shared.scheduleReminders(for: self.scheduleCachedMeetings)
  4362. }
  4363. } else {
  4364. MeetingReminderManager.shared.cancelAllReminders()
  4365. }
  4366. }
  4367. @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
  4368. ReminderPreferences.remind1Day = sender.state == .on
  4369. MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
  4370. }
  4371. @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
  4372. ReminderPreferences.remind12Hours = sender.state == .on
  4373. MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
  4374. }
  4375. @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
  4376. ReminderPreferences.remind1Hour = sender.state == .on
  4377. MeetingReminderManager.shared.scheduleReminders(for: scheduleCachedMeetings)
  4378. }
  4379. private func makeSettingsGoogleAccountRow() -> NSView {
  4380. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4381. row.translatesAutoresizingMaskIntoConstraints = false
  4382. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4383. let signedIn = hasGoogleSessionAvailable()
  4384. let titleText = signedIn ? (scheduleCurrentProfile?.name ?? "Google account connected") : "Google account not connected"
  4385. let subtitleText = signedIn ? (scheduleCurrentProfile?.email ?? "Signed in") : "Sign in to sync your meetings and calendar."
  4386. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  4387. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: palette.textSecondary)
  4388. subtitle.maximumNumberOfLines = 2
  4389. subtitle.lineBreakMode = .byTruncatingTail
  4390. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  4391. actionButton.translatesAutoresizingMaskIntoConstraints = false
  4392. actionButton.bezelStyle = .rounded
  4393. actionButton.controlSize = .regular
  4394. row.addSubview(title)
  4395. row.addSubview(subtitle)
  4396. row.addSubview(actionButton)
  4397. NSLayoutConstraint.activate([
  4398. row.heightAnchor.constraint(equalToConstant: 78),
  4399. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4400. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  4401. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  4402. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  4403. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  4404. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4405. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4406. ])
  4407. return row
  4408. }
  4409. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  4410. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  4411. button.translatesAutoresizingMaskIntoConstraints = false
  4412. button.isBordered = false
  4413. button.wantsLayer = true
  4414. button.layer?.cornerRadius = 10
  4415. button.layer?.backgroundColor = palette.inputBackground.cgColor
  4416. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4417. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  4418. button.tag = action.rawValue
  4419. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: palette.textPrimary)
  4420. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
  4421. button.addSubview(iconLabel)
  4422. button.addSubview(titleLabel)
  4423. NSLayoutConstraint.activate([
  4424. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  4425. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  4426. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4427. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  4428. ])
  4429. return button
  4430. }
  4431. @objc private func settingsPageDarkModeToggled(_ sender: NSSwitch) {
  4432. setDarkMode(sender.state == .on)
  4433. }
  4434. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  4435. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  4436. let clickPoint: NSPoint?
  4437. if let event = NSApp.currentEvent {
  4438. let pointInWindow = event.locationInWindow
  4439. clickPoint = sender.convert(pointInWindow, from: nil)
  4440. } else {
  4441. clickPoint = nil
  4442. }
  4443. handleSettingsAction(action, sourceView: sender, clickLocationInSourceView: clickPoint)
  4444. }
  4445. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  4446. if hasGoogleSessionAvailable() == false {
  4447. scheduleConnectClicked()
  4448. } else {
  4449. performGoogleSignOut()
  4450. }
  4451. }
  4452. func makeBrowseWebContent() -> NSView {
  4453. let panel = NSView()
  4454. panel.translatesAutoresizingMaskIntoConstraints = false
  4455. let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
  4456. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  4457. let sub = textLabel(
  4458. "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
  4459. font: typography.fieldLabel,
  4460. color: palette.textSecondary
  4461. )
  4462. sub.translatesAutoresizingMaskIntoConstraints = false
  4463. sub.maximumNumberOfLines = 0
  4464. sub.lineBreakMode = .byWordWrapping
  4465. let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  4466. fieldShell.translatesAutoresizingMaskIntoConstraints = false
  4467. fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
  4468. styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4469. let field = NSTextField(string: "")
  4470. field.translatesAutoresizingMaskIntoConstraints = false
  4471. field.isEditable = true
  4472. field.isBordered = false
  4473. field.drawsBackground = false
  4474. field.focusRingType = .none
  4475. field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
  4476. field.textColor = palette.textPrimary
  4477. field.placeholderString = "https://example.com or example.com"
  4478. field.delegate = self
  4479. browseAddressField = field
  4480. fieldShell.addSubview(field)
  4481. let openBtn = meetActionButton(
  4482. title: "Open in app browser",
  4483. color: palette.primaryBlue,
  4484. textColor: .white,
  4485. width: 220,
  4486. action: #selector(browseOpenAddressClicked(_:))
  4487. )
  4488. let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
  4489. quickTitle.translatesAutoresizingMaskIntoConstraints = false
  4490. let quickRow = NSStackView()
  4491. quickRow.translatesAutoresizingMaskIntoConstraints = false
  4492. quickRow.orientation = .horizontal
  4493. quickRow.spacing = 10
  4494. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
  4495. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
  4496. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
  4497. panel.addSubview(titleLabel)
  4498. panel.addSubview(sub)
  4499. panel.addSubview(fieldShell)
  4500. panel.addSubview(openBtn)
  4501. panel.addSubview(quickTitle)
  4502. panel.addSubview(quickRow)
  4503. NSLayoutConstraint.activate([
  4504. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  4505. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  4506. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  4507. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4508. sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  4509. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  4510. fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4511. fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  4512. fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
  4513. field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
  4514. field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
  4515. field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
  4516. openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4517. openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
  4518. openBtn.heightAnchor.constraint(equalToConstant: 36),
  4519. quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4520. quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
  4521. quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4522. quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
  4523. ])
  4524. return panel
  4525. }
  4526. private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
  4527. let b = NSButton(title: title, target: self, action: action)
  4528. b.translatesAutoresizingMaskIntoConstraints = false
  4529. b.bezelStyle = .rounded
  4530. b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
  4531. return b
  4532. }
  4533. private func applyWindowTitle(for page: SidebarPage) {
  4534. let title: String
  4535. switch page {
  4536. case .joinMeetings:
  4537. title = "App for Google Meet"
  4538. case .photo:
  4539. title = "Schedule"
  4540. case .video:
  4541. title = "Calendar"
  4542. case .widgets:
  4543. title = "Widgets"
  4544. case .settings:
  4545. title = "Settings"
  4546. case .aiCompanion:
  4547. title = "AI Companion"
  4548. }
  4549. view.window?.title = title
  4550. }
  4551. private func configureMainWindowChrome(_ window: NSWindow) {
  4552. window.styleMask.insert(.fullSizeContentView)
  4553. window.titleVisibility = .hidden
  4554. window.titlebarAppearsTransparent = true
  4555. window.isMovableByWindowBackground = true
  4556. }
  4557. private func updateSidebarAppearance() {
  4558. for (page, row) in sidebarRowViews {
  4559. applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page))
  4560. }
  4561. }
  4562. private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
  4563. switch page {
  4564. case .photo: return false
  4565. case .joinMeetings, .video, .widgets, .settings, .aiCompanion: return true
  4566. }
  4567. }
  4568. func makeSidebar() -> NSView {
  4569. let sidebar = NSView()
  4570. sidebar.translatesAutoresizingMaskIntoConstraints = false
  4571. sidebar.wantsLayer = true
  4572. sidebar.layer?.backgroundColor = palette.sidebarBackground.cgColor
  4573. sidebar.layer?.borderColor = palette.separator.cgColor
  4574. sidebar.layer?.borderWidth = 1
  4575. sidebar.layer?.shadowColor = NSColor.black.cgColor
  4576. sidebar.layer?.shadowOpacity = 0.18
  4577. sidebar.layer?.shadowOffset = CGSize(width: 2, height: 0)
  4578. sidebar.layer?.shadowRadius = 10
  4579. sidebar.widthAnchor.constraint(equalToConstant: 210).isActive = true
  4580. let sidebarTopInset: CGFloat = 44
  4581. let appIconView = NSImageView()
  4582. if let headerLogo = NSImage(named: "HeaderLogo") {
  4583. headerLogo.isTemplate = false
  4584. appIconView.image = headerLogo
  4585. } else if let appIconImage = NSApplication.shared.applicationIconImage {
  4586. appIconImage.isTemplate = false
  4587. appIconView.image = appIconImage
  4588. }
  4589. appIconView.translatesAutoresizingMaskIntoConstraints = false
  4590. appIconView.imageScaling = NSImageScaling.scaleProportionallyDown
  4591. appIconView.imageAlignment = NSImageAlignment.alignCenter
  4592. appIconView.contentTintColor = nil
  4593. appIconView.widthAnchor.constraint(equalToConstant: 44).isActive = true
  4594. appIconView.heightAnchor.constraint(equalToConstant: 44).isActive = true
  4595. let titleRow = NSStackView(views: [
  4596. appIconView,
  4597. textLabel("Meetings", font: typography.sidebarBrand, color: palette.textPrimary)
  4598. ])
  4599. titleRow.translatesAutoresizingMaskIntoConstraints = false
  4600. titleRow.orientation = NSUserInterfaceLayoutOrientation.horizontal
  4601. titleRow.alignment = NSLayoutConstraint.Attribute.centerY
  4602. titleRow.spacing = 16
  4603. let menuStack = NSStackView()
  4604. menuStack.translatesAutoresizingMaskIntoConstraints = false
  4605. menuStack.orientation = .vertical
  4606. menuStack.alignment = .leading
  4607. menuStack.spacing = 10
  4608. menuStack.addArrangedSubview(sidebarSectionTitle("Meetings"))
  4609. let joinRow = sidebarItem("Join Meetings", icon: "􀉣", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0)
  4610. menuStack.addArrangedSubview(joinRow)
  4611. sidebarRowViews[.joinMeetings] = joinRow
  4612. menuStack.addArrangedSubview(sidebarSectionTitle("Planning"))
  4613. let photoRow = sidebarItem("Schedule", icon: "􀏂", page: .photo, systemSymbolName: "clock.badge.checkmark")
  4614. menuStack.addArrangedSubview(photoRow)
  4615. sidebarRowViews[.photo] = photoRow
  4616. let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
  4617. menuStack.addArrangedSubview(videoRow)
  4618. sidebarRowViews[.video] = videoRow
  4619. let widgetsRow = sidebarItem("Widgets", icon: "􀏅", page: .widgets, systemSymbolName: "square.grid.2x2.fill")
  4620. menuStack.addArrangedSubview(widgetsRow)
  4621. sidebarRowViews[.widgets] = widgetsRow
  4622. let aiCompanionRow = sidebarItem("AI Companion", icon: "􀁚", page: .aiCompanion, systemSymbolName: "waveform")
  4623. menuStack.addArrangedSubview(aiCompanionRow)
  4624. sidebarRowViews[.aiCompanion] = aiCompanionRow
  4625. menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
  4626. let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, systemSymbolName: "gearshape.fill", logoHeightMultiplier: 1, showsDisclosure: true)
  4627. menuStack.addArrangedSubview(settingsRow)
  4628. sidebarRowViews[.settings] = settingsRow
  4629. let premiumButton = sidebarPremiumButton()
  4630. sidebar.addSubview(titleRow)
  4631. sidebar.addSubview(menuStack)
  4632. sidebar.addSubview(premiumButton)
  4633. NSLayoutConstraint.activate([
  4634. titleRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 16),
  4635. titleRow.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: sidebarTopInset),
  4636. titleRow.trailingAnchor.constraint(lessThanOrEqualTo: sidebar.trailingAnchor, constant: -16),
  4637. menuStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  4638. menuStack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  4639. menuStack.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 20),
  4640. menuStack.bottomAnchor.constraint(lessThanOrEqualTo: premiumButton.topAnchor, constant: -16),
  4641. premiumButton.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  4642. premiumButton.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  4643. premiumButton.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -14)
  4644. ])
  4645. for subview in menuStack.arrangedSubviews {
  4646. subview.widthAnchor.constraint(equalTo: menuStack.widthAnchor).isActive = true
  4647. }
  4648. return sidebar
  4649. }
  4650. func sidebarPremiumButton() -> NSView {
  4651. let button = HoverTrackingView()
  4652. button.translatesAutoresizingMaskIntoConstraints = false
  4653. button.wantsLayer = true
  4654. button.layer?.cornerRadius = 17
  4655. button.layer?.backgroundColor = palette.primaryBlue.cgColor
  4656. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  4657. styleSurface(button, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  4658. let icon = NSImageView()
  4659. icon.translatesAutoresizingMaskIntoConstraints = false
  4660. icon.imageScaling = .scaleProportionallyUpOrDown
  4661. icon.contentTintColor = .white
  4662. icon.image = premiumButtonSymbolImage(named: "star.fill")
  4663. let title = textLabel("Get Premium", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: .white)
  4664. button.addSubview(icon)
  4665. button.addSubview(title)
  4666. NSLayoutConstraint.activate([
  4667. icon.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
  4668. icon.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  4669. icon.widthAnchor.constraint(equalToConstant: 14),
  4670. icon.heightAnchor.constraint(equalToConstant: 14),
  4671. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
  4672. title.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  4673. title.trailingAnchor.constraint(lessThanOrEqualTo: button.trailingAnchor, constant: -12)
  4674. ])
  4675. button.onHoverChanged = { [weak self, weak button] hovering in
  4676. guard let self, let button else { return }
  4677. let isPremium = self.storeKitCoordinator.hasPremiumAccess
  4678. let baseColor = isPremium
  4679. ? NSColor(calibratedRed: 0.93, green: 0.73, blue: 0.16, alpha: 1.0)
  4680. : self.palette.primaryBlue
  4681. let borderColor = isPremium
  4682. ? NSColor(calibratedRed: 0.78, green: 0.56, blue: 0.10, alpha: 1.0)
  4683. : self.palette.primaryBlueBorder
  4684. let hoverColor: NSColor
  4685. let hoverBorderColor: NSColor
  4686. if isPremium {
  4687. // Darker rich-gold hover for stronger premium feedback.
  4688. hoverColor = NSColor(calibratedRed: 0.84, green: 0.62, blue: 0.11, alpha: 1.0)
  4689. hoverBorderColor = NSColor(calibratedRed: 0.67, green: 0.46, blue: 0.07, alpha: 1.0)
  4690. } else {
  4691. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  4692. hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  4693. hoverBorderColor = borderColor
  4694. }
  4695. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  4696. button.layer?.shadowColor = NSColor.black.cgColor
  4697. button.layer?.shadowOpacity = hovering ? (isPremium ? 0.30 : 0.20) : 0.14
  4698. button.layer?.shadowOffset = CGSize(width: 0, height: -1)
  4699. button.layer?.shadowRadius = hovering ? (isPremium ? 8 : 6) : 4
  4700. self.styleSurface(button, borderColor: (hovering ? hoverBorderColor : borderColor), borderWidth: hovering ? 1.5 : 1, shadow: false)
  4701. }
  4702. button.onHoverChanged?(false)
  4703. sidebarPremiumTitleLabel = title
  4704. sidebarPremiumIconView = icon
  4705. sidebarPremiumButtonView = button
  4706. refreshSidebarPremiumButton()
  4707. let click = NSClickGestureRecognizer(target: self, action: #selector(premiumButtonClicked(_:)))
  4708. button.addGestureRecognizer(click)
  4709. return button
  4710. }
  4711. func makeMainPanel() -> NSView {
  4712. let panel = NSView()
  4713. panel.translatesAutoresizingMaskIntoConstraints = false
  4714. panel.wantsLayer = true
  4715. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  4716. let authBar = scheduleTopAuthRow()
  4717. authBar.translatesAutoresizingMaskIntoConstraints = false
  4718. panel.addSubview(authBar)
  4719. let host = NSView()
  4720. host.translatesAutoresizingMaskIntoConstraints = false
  4721. panel.addSubview(host)
  4722. let hostTopToAuth = host.topAnchor.constraint(equalTo: authBar.bottomAnchor, constant: 20)
  4723. let hostTopToPanel = host.topAnchor.constraint(equalTo: panel.topAnchor)
  4724. hostTopToPanel.isActive = false
  4725. NSLayoutConstraint.activate([
  4726. authBar.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  4727. authBar.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  4728. authBar.topAnchor.constraint(equalTo: panel.safeAreaLayoutGuide.topAnchor, constant: 26),
  4729. host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  4730. host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  4731. hostTopToAuth,
  4732. host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
  4733. ])
  4734. mainContentHost = host
  4735. mainPanelAuthBar = authBar
  4736. mainContentHostTopToAuthConstraint = hostTopToAuth
  4737. mainContentHostTopToPanelConstraint = hostTopToPanel
  4738. if hasGoogleSessionAvailable(), let profile = scheduleCurrentProfile {
  4739. applyGoogleProfile(profile)
  4740. }
  4741. // Preserve the currently selected page during rebuilds (e.g. dark mode toggle).
  4742. showSidebarPage(selectedSidebarPage)
  4743. return panel
  4744. }
  4745. func makeJoinMeetingsContent() -> NSView {
  4746. let panel = NSView()
  4747. panel.translatesAutoresizingMaskIntoConstraints = false
  4748. let contentStack = NSStackView()
  4749. contentStack.translatesAutoresizingMaskIntoConstraints = false
  4750. contentStack.orientation = .vertical
  4751. contentStack.spacing = 14
  4752. contentStack.alignment = .leading
  4753. let joinActions = meetJoinActionsRow()
  4754. contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
  4755. contentStack.addArrangedSubview(meetJoinSectionRow())
  4756. contentStack.addArrangedSubview(joinActions)
  4757. contentStack.setCustomSpacing(26, after: joinActions)
  4758. let scheduleHeaderView = scheduleHeader()
  4759. contentStack.addArrangedSubview(scheduleHeaderView)
  4760. contentStack.setCustomSpacing(joinPageScheduleHeaderToDateSpacing, after: scheduleHeaderView)
  4761. let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
  4762. scheduleDateHeadingLabel = dateHeading
  4763. contentStack.addArrangedSubview(dateHeading)
  4764. contentStack.setCustomSpacing(joinPageDateToMeetingCardsSpacing, after: dateHeading)
  4765. let cardsRow = scheduleCardsRow(meetings: [])
  4766. contentStack.addArrangedSubview(cardsRow)
  4767. panel.addSubview(contentStack)
  4768. NSLayoutConstraint.activate([
  4769. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  4770. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  4771. contentStack.topAnchor.constraint(equalTo: panel.topAnchor)
  4772. ])
  4773. Task { [weak self] in
  4774. await self?.loadSchedule()
  4775. }
  4776. return panel
  4777. }
  4778. func makeSchedulePageContent() -> NSView {
  4779. let panel = NSView()
  4780. panel.translatesAutoresizingMaskIntoConstraints = false
  4781. panel.userInterfaceLayoutDirection = .leftToRight
  4782. let contentStack = NSStackView()
  4783. contentStack.translatesAutoresizingMaskIntoConstraints = false
  4784. contentStack.userInterfaceLayoutDirection = .leftToRight
  4785. contentStack.orientation = .vertical
  4786. contentStack.spacing = schedulePageStackSpacing
  4787. contentStack.alignment = .width
  4788. contentStack.distribution = .fill
  4789. let header = schedulePageHeader()
  4790. header.setContentHuggingPriority(.required, for: .vertical)
  4791. header.setContentCompressionResistancePriority(.required, for: .vertical)
  4792. contentStack.addArrangedSubview(header)
  4793. contentStack.setCustomSpacing(schedulePageHeaderToDateSpacing, after: header)
  4794. let heading = textLabel(schedulePageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
  4795. heading.alignment = .left
  4796. heading.setContentHuggingPriority(.required, for: .vertical)
  4797. heading.setContentCompressionResistancePriority(.required, for: .vertical)
  4798. schedulePageDateHeadingLabel = heading
  4799. contentStack.addArrangedSubview(heading)
  4800. let rangeError = textLabel("", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .systemRed)
  4801. rangeError.alignment = .left
  4802. rangeError.isHidden = true
  4803. rangeError.setContentHuggingPriority(.required, for: .vertical)
  4804. rangeError.setContentCompressionResistancePriority(.required, for: .vertical)
  4805. schedulePageRangeErrorLabel = rangeError
  4806. contentStack.addArrangedSubview(rangeError)
  4807. let cardsContainer = makeSchedulePageCardsContainer()
  4808. cardsContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  4809. contentStack.addArrangedSubview(cardsContainer)
  4810. panel.addSubview(contentStack)
  4811. NSLayoutConstraint.activate([
  4812. contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: schedulePageLeadingInset),
  4813. contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -schedulePageTrailingInset),
  4814. contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
  4815. contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
  4816. header.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  4817. heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  4818. rangeError.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  4819. cardsContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
  4820. ])
  4821. Task { [weak self] in
  4822. await self?.loadSchedule()
  4823. }
  4824. return panel
  4825. }
  4826. func makeCalendarPageContent() -> NSView {
  4827. let panel = NSView()
  4828. panel.translatesAutoresizingMaskIntoConstraints = false
  4829. panel.userInterfaceLayoutDirection = .leftToRight
  4830. let contentStack = NSStackView()
  4831. contentStack.translatesAutoresizingMaskIntoConstraints = false
  4832. contentStack.userInterfaceLayoutDirection = .leftToRight
  4833. contentStack.orientation = .vertical
  4834. contentStack.spacing = 14
  4835. contentStack.alignment = .width
  4836. contentStack.distribution = .fill
  4837. let titleRow = NSStackView()
  4838. titleRow.translatesAutoresizingMaskIntoConstraints = false
  4839. titleRow.userInterfaceLayoutDirection = .leftToRight
  4840. titleRow.orientation = .horizontal
  4841. titleRow.alignment = .centerY
  4842. titleRow.distribution = .fill
  4843. titleRow.spacing = 10
  4844. let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary)
  4845. titleLabel.alignment = .left
  4846. titleLabel.maximumNumberOfLines = 1
  4847. titleLabel.lineBreakMode = .byTruncatingTail
  4848. titleLabel.setContentHuggingPriority(.required, for: .horizontal)
  4849. titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  4850. let spacer = NSView()
  4851. spacer.translatesAutoresizingMaskIntoConstraints = false
  4852. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  4853. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  4854. let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:)))
  4855. prevButton.widthAnchor.constraint(equalToConstant: 46).isActive = true
  4856. let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:)))
  4857. nextButton.widthAnchor.constraint(equalToConstant: 46).isActive = true
  4858. let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary)
  4859. monthLabel.alignment = .right
  4860. monthLabel.maximumNumberOfLines = 1
  4861. monthLabel.lineBreakMode = .byTruncatingTail
  4862. monthLabel.setContentHuggingPriority(.required, for: .horizontal)
  4863. monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  4864. calendarPageMonthLabel = monthLabel
  4865. let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:)))
  4866. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  4867. refreshButton.isBordered = false
  4868. refreshButton.bezelStyle = .regularSquare
  4869. refreshButton.wantsLayer = true
  4870. refreshButton.layer?.cornerRadius = 15
  4871. refreshButton.layer?.masksToBounds = true
  4872. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  4873. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  4874. refreshButton.layer?.borderWidth = 1
  4875. refreshButton.setButtonType(.momentaryChange)
  4876. refreshButton.contentTintColor = palette.textSecondary
  4877. refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar")
  4878. refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  4879. refreshButton.imagePosition = .imageOnly
  4880. refreshButton.imageScaling = .scaleProportionallyDown
  4881. refreshButton.focusRingType = .none
  4882. refreshButton.heightAnchor.constraint(equalToConstant: 32).isActive = true
  4883. refreshButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
  4884. titleRow.addArrangedSubview(titleLabel)
  4885. titleRow.addArrangedSubview(spacer)
  4886. titleRow.addArrangedSubview(prevButton)
  4887. titleRow.addArrangedSubview(nextButton)
  4888. titleRow.addArrangedSubview(monthLabel)
  4889. titleRow.addArrangedSubview(refreshButton)
  4890. let weekdayRow = NSStackView()
  4891. weekdayRow.translatesAutoresizingMaskIntoConstraints = false
  4892. weekdayRow.userInterfaceLayoutDirection = .leftToRight
  4893. weekdayRow.orientation = .horizontal
  4894. weekdayRow.alignment = .centerY
  4895. weekdayRow.distribution = .fillEqually
  4896. weekdayRow.spacing = 12
  4897. for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() {
  4898. let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted)
  4899. label.alignment = .center
  4900. label.maximumNumberOfLines = 1
  4901. label.lineBreakMode = .byClipping
  4902. weekdayRow.addArrangedSubview(label)
  4903. }
  4904. let gridStack = NSStackView()
  4905. gridStack.translatesAutoresizingMaskIntoConstraints = false
  4906. gridStack.userInterfaceLayoutDirection = .leftToRight
  4907. gridStack.orientation = .vertical
  4908. gridStack.alignment = .width
  4909. gridStack.distribution = .fillEqually
  4910. gridStack.spacing = 10
  4911. calendarPageGridStack = gridStack
  4912. let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
  4913. gridCard.translatesAutoresizingMaskIntoConstraints = false
  4914. styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4915. let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 360)
  4916. gridHeightConstraint.isActive = true
  4917. calendarPageGridHeightConstraint = gridHeightConstraint
  4918. gridCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 360).isActive = true
  4919. gridCard.addSubview(gridStack)
  4920. NSLayoutConstraint.activate([
  4921. gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 16),
  4922. gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -16),
  4923. gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 16),
  4924. gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -16)
  4925. ])
  4926. let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary)
  4927. daySummary.alignment = .left
  4928. daySummary.maximumNumberOfLines = 2
  4929. daySummary.lineBreakMode = .byWordWrapping
  4930. calendarPageDaySummaryLabel = daySummary
  4931. contentStack.addArrangedSubview(titleRow)
  4932. contentStack.setCustomSpacing(16, after: titleRow)
  4933. contentStack.addArrangedSubview(weekdayRow)
  4934. contentStack.setCustomSpacing(10, after: weekdayRow)
  4935. contentStack.addArrangedSubview(gridCard)
  4936. contentStack.setCustomSpacing(16, after: gridCard)
  4937. contentStack.addArrangedSubview(daySummary)
  4938. panel.addSubview(contentStack)
  4939. NSLayoutConstraint.activate([
  4940. contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
  4941. contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
  4942. contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
  4943. contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16),
  4944. titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  4945. weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  4946. gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  4947. daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
  4948. ])
  4949. if !storeKitCoordinator.hasPremiumAccess {
  4950. let lockOverlay = NSVisualEffectView()
  4951. lockOverlay.translatesAutoresizingMaskIntoConstraints = false
  4952. lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
  4953. lockOverlay.blendingMode = .withinWindow
  4954. lockOverlay.state = .active
  4955. lockOverlay.wantsLayer = true
  4956. lockOverlay.layer?.cornerRadius = 14
  4957. lockOverlay.layer?.masksToBounds = true
  4958. lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.30 : 0.12).cgColor
  4959. panel.addSubview(lockOverlay)
  4960. let message = textLabel("Premium required. Get Premium now to unlock Calendar.", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: darkModeEnabled ? .white : .black)
  4961. message.alignment = .center
  4962. message.maximumNumberOfLines = 2
  4963. message.lineBreakMode = .byWordWrapping
  4964. lockOverlay.addSubview(message)
  4965. let hit = HoverTrackingView()
  4966. hit.translatesAutoresizingMaskIntoConstraints = false
  4967. hit.wantsLayer = true
  4968. hit.layer?.backgroundColor = NSColor.clear.cgColor
  4969. hit.onClick = { [weak self] in
  4970. self?.showPaywall()
  4971. }
  4972. hit.toolTip = "Premium required. Click to open paywall."
  4973. lockOverlay.addSubview(hit)
  4974. NSLayoutConstraint.activate([
  4975. lockOverlay.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor),
  4976. lockOverlay.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor),
  4977. lockOverlay.topAnchor.constraint(equalTo: contentStack.topAnchor),
  4978. lockOverlay.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor),
  4979. message.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
  4980. message.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
  4981. message.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 22),
  4982. message.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -22),
  4983. hit.leadingAnchor.constraint(equalTo: lockOverlay.leadingAnchor),
  4984. hit.trailingAnchor.constraint(equalTo: lockOverlay.trailingAnchor),
  4985. hit.topAnchor.constraint(equalTo: lockOverlay.topAnchor),
  4986. hit.bottomAnchor.constraint(equalTo: lockOverlay.bottomAnchor)
  4987. ])
  4988. }
  4989. let calendar = Calendar.current
  4990. calendarPageMonthAnchor = calendarStartOfMonth(for: Date())
  4991. calendarPageSelectedDate = calendar.startOfDay(for: Date())
  4992. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  4993. renderCalendarMonthGrid()
  4994. renderCalendarSelectedDay()
  4995. Task { [weak self] in
  4996. await self?.loadSchedule()
  4997. }
  4998. return panel
  4999. }
  5000. func meetJoinSectionRow() -> NSView {
  5001. let row = NSStackView()
  5002. row.translatesAutoresizingMaskIntoConstraints = false
  5003. row.orientation = .horizontal
  5004. row.spacing = 12
  5005. row.alignment = .top
  5006. row.distribution = .fillEqually
  5007. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  5008. row.heightAnchor.constraint(equalToConstant: 140).isActive = true
  5009. let instant = HoverSurfaceView()
  5010. instant.translatesAutoresizingMaskIntoConstraints = false
  5011. instant.wantsLayer = true
  5012. instant.layer?.cornerRadius = 14
  5013. instant.layer?.backgroundColor = palette.sectionCard.cgColor
  5014. styleSurface(instant, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5015. let iconWrap = roundedContainer(cornerRadius: 12, color: NSColor.clear)
  5016. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  5017. iconWrap.widthAnchor.constraint(equalToConstant: 58).isActive = true
  5018. iconWrap.heightAnchor.constraint(equalToConstant: 58).isActive = true
  5019. iconWrap.layer?.borderWidth = 0
  5020. let meetLogoImage = NSImage(named: "MeetLogo") ?? NSImage()
  5021. meetLogoImage.isTemplate = false
  5022. let meetLogo = NSImageView(image: meetLogoImage)
  5023. meetLogo.translatesAutoresizingMaskIntoConstraints = false
  5024. meetLogo.imageScaling = .scaleProportionallyDown
  5025. meetLogo.contentTintColor = nil
  5026. iconWrap.addSubview(meetLogo)
  5027. let instantTitle = textLabel("New Instant Meet", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  5028. let instantSub = textLabel("Start instant Meet in more section with\nGoogle Meet meet.", font: NSFont.systemFont(ofSize: 16 / 2, weight: .medium), color: palette.textSecondary)
  5029. instantSub.maximumNumberOfLines = 2
  5030. instant.addSubview(iconWrap)
  5031. instant.addSubview(instantTitle)
  5032. instant.addSubview(instantSub)
  5033. let codeCard = HoverSurfaceView()
  5034. codeCard.translatesAutoresizingMaskIntoConstraints = false
  5035. codeCard.wantsLayer = true
  5036. codeCard.layer?.cornerRadius = 14
  5037. codeCard.layer?.backgroundColor = palette.sectionCard.cgColor
  5038. styleSurface(codeCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5039. let codeTitle = textLabel("Join with Link", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  5040. let codeInputShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  5041. codeInputShell.translatesAutoresizingMaskIntoConstraints = false
  5042. codeInputShell.heightAnchor.constraint(equalToConstant: 52).isActive = true
  5043. styleSurface(codeInputShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5044. let codeField = NSTextField(string: "")
  5045. codeField.translatesAutoresizingMaskIntoConstraints = false
  5046. codeField.isEditable = true
  5047. codeField.isBordered = false
  5048. codeField.drawsBackground = false
  5049. codeField.focusRingType = .none
  5050. codeField.font = NSFont.systemFont(ofSize: 36 / 2, weight: .regular)
  5051. codeField.textColor = palette.textPrimary
  5052. codeField.placeholderString = "Code or meet.google.com/…"
  5053. codeInputShell.addSubview(codeField)
  5054. meetLinkField = codeField
  5055. codeCard.addSubview(codeTitle)
  5056. codeCard.addSubview(codeInputShell)
  5057. NSLayoutConstraint.activate([
  5058. meetLogo.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  5059. meetLogo.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor),
  5060. meetLogo.widthAnchor.constraint(equalToConstant: 46),
  5061. meetLogo.heightAnchor.constraint(equalToConstant: 46),
  5062. iconWrap.leadingAnchor.constraint(equalTo: instant.leadingAnchor, constant: 18),
  5063. iconWrap.topAnchor.constraint(equalTo: instant.topAnchor, constant: 22),
  5064. instantTitle.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 14),
  5065. instantTitle.topAnchor.constraint(equalTo: instant.topAnchor, constant: 24),
  5066. instantSub.leadingAnchor.constraint(equalTo: instantTitle.leadingAnchor),
  5067. instantSub.topAnchor.constraint(equalTo: instantTitle.bottomAnchor, constant: 6),
  5068. instantSub.trailingAnchor.constraint(lessThanOrEqualTo: instant.trailingAnchor, constant: -16),
  5069. codeTitle.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  5070. codeTitle.topAnchor.constraint(equalTo: codeCard.topAnchor, constant: 22),
  5071. codeInputShell.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  5072. codeInputShell.trailingAnchor.constraint(equalTo: codeCard.trailingAnchor, constant: -18),
  5073. codeInputShell.topAnchor.constraint(equalTo: codeTitle.bottomAnchor, constant: 12),
  5074. codeField.leadingAnchor.constraint(equalTo: codeInputShell.leadingAnchor, constant: 14),
  5075. codeField.trailingAnchor.constraint(equalTo: codeInputShell.trailingAnchor, constant: -14),
  5076. codeField.centerYAnchor.constraint(equalTo: codeInputShell.centerYAnchor)
  5077. ])
  5078. let baseColor = palette.sectionCard
  5079. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5080. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  5081. instant.onHoverChanged = { [weak self] hovering in
  5082. guard let self else { return }
  5083. instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  5084. }
  5085. codeCard.onHoverChanged = { [weak self] hovering in
  5086. guard let self else { return }
  5087. codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  5088. }
  5089. instant.onHoverChanged?(false)
  5090. codeCard.onHoverChanged?(false)
  5091. let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
  5092. instant.addGestureRecognizer(instantClick)
  5093. let joinWithLinkClick = NSClickGestureRecognizer(target: self, action: #selector(joinWithLinkCardClicked(_:)))
  5094. codeCard.addGestureRecognizer(joinWithLinkClick)
  5095. instantMeetCardView = instant
  5096. instantMeetTitleLabel = instantTitle
  5097. instantMeetSubtitleLabel = instantSub
  5098. joinWithLinkCardView = codeCard
  5099. joinWithLinkTitleLabel = codeTitle
  5100. refreshInstantMeetPremiumState()
  5101. row.addArrangedSubview(instant)
  5102. row.addArrangedSubview(codeCard)
  5103. return row
  5104. }
  5105. func meetJoinActionsRow() -> NSView {
  5106. let row = NSStackView()
  5107. row.translatesAutoresizingMaskIntoConstraints = false
  5108. row.orientation = .horizontal
  5109. row.spacing = 12
  5110. row.alignment = .centerY
  5111. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  5112. let spacer = NSView()
  5113. spacer.translatesAutoresizingMaskIntoConstraints = false
  5114. row.addArrangedSubview(spacer)
  5115. row.addArrangedSubview(meetActionButton(
  5116. title: "Cancel",
  5117. color: palette.cancelButton,
  5118. textColor: palette.textSecondary,
  5119. width: 110,
  5120. action: #selector(cancelMeetJoinClicked(_:))
  5121. ))
  5122. let joinButton = meetActionButton(
  5123. title: "Join",
  5124. color: palette.primaryBlue,
  5125. textColor: .white,
  5126. width: 116,
  5127. action: #selector(joinMeetClicked(_:))
  5128. )
  5129. joinMeetPrimaryButton = joinButton
  5130. row.addArrangedSubview(joinButton)
  5131. refreshInstantMeetPremiumState()
  5132. return row
  5133. }
  5134. func meetActionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat, action: Selector) -> NSButton {
  5135. let button = HoverButton(title: title, target: self, action: action)
  5136. button.translatesAutoresizingMaskIntoConstraints = false
  5137. button.isBordered = false
  5138. button.bezelStyle = .regularSquare
  5139. button.wantsLayer = true
  5140. button.layer?.cornerRadius = 9
  5141. let baseBackground = color
  5142. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5143. let hoverBackground = baseBackground.blended(withFraction: 0.10, of: hoverBlend) ?? baseBackground
  5144. let baseBorder = (title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder)
  5145. let hoverBorder = baseBorder.blended(withFraction: 0.18, of: hoverBlend) ?? baseBorder
  5146. button.layer?.backgroundColor = baseBackground.cgColor
  5147. button.layer?.borderColor = baseBorder.cgColor
  5148. button.layer?.borderWidth = 1
  5149. button.font = typography.buttonText
  5150. button.contentTintColor = textColor
  5151. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  5152. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5153. button.onHoverChanged = { [weak self, weak button] hovering in
  5154. guard let self, let button else { return }
  5155. button.layer?.backgroundColor = (hovering ? hoverBackground : baseBackground).cgColor
  5156. button.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  5157. if title == "Cancel" {
  5158. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : textColor
  5159. }
  5160. }
  5161. button.onHoverChanged?(false)
  5162. return button
  5163. }
  5164. func makePaywallContent() -> NSView {
  5165. paywallPlanViews.removeAll()
  5166. premiumPlanByView.removeAll()
  5167. paywallPriceLabels.removeAll()
  5168. paywallSubtitleLabels.removeAll()
  5169. paywallBadgeLabels.removeAll()
  5170. paywallStrikePriceLabels.removeAll()
  5171. let panel = NSView()
  5172. panel.translatesAutoresizingMaskIntoConstraints = false
  5173. panel.wantsLayer = true
  5174. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  5175. let contentStack = NSStackView()
  5176. contentStack.translatesAutoresizingMaskIntoConstraints = false
  5177. contentStack.orientation = .vertical
  5178. contentStack.spacing = 12
  5179. contentStack.distribution = .fill
  5180. contentStack.alignment = .centerX
  5181. panel.addSubview(contentStack)
  5182. let paywallLayoutWidth: CGFloat = 980
  5183. let topRow = NSStackView()
  5184. topRow.translatesAutoresizingMaskIntoConstraints = false
  5185. topRow.orientation = .horizontal
  5186. topRow.alignment = .centerY
  5187. topRow.distribution = .fill
  5188. topRow.spacing = 10
  5189. topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 27, weight: .bold), color: palette.textPrimary))
  5190. var closeButton: HoverButton?
  5191. if storeKitCoordinator.hasPremiumAccess {
  5192. let button = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  5193. button.translatesAutoresizingMaskIntoConstraints = false
  5194. button.isBordered = false
  5195. button.bezelStyle = .regularSquare
  5196. button.wantsLayer = true
  5197. button.layer?.cornerRadius = 14
  5198. button.layer?.backgroundColor = palette.inputBackground.cgColor
  5199. button.layer?.borderColor = palette.inputBorder.cgColor
  5200. button.layer?.borderWidth = 1
  5201. button.font = typography.iconButton
  5202. button.contentTintColor = palette.textSecondary
  5203. button.widthAnchor.constraint(equalToConstant: 28).isActive = true
  5204. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  5205. button.onHoverChanged = { [weak button, weak self] hovering in
  5206. guard let button, let self else { return }
  5207. let base = self.palette.inputBackground
  5208. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  5209. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  5210. button.layer?.backgroundColor = (hovering ? hover : base).cgColor
  5211. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  5212. }
  5213. panel.addSubview(button)
  5214. closeButton = button
  5215. }
  5216. contentStack.addArrangedSubview(topRow)
  5217. topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5218. let hero = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  5219. hero.translatesAutoresizingMaskIntoConstraints = false
  5220. styleSurface(hero, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5221. contentStack.addArrangedSubview(hero)
  5222. hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5223. let heroBadge = textLabel("PREMIUM EXPERIENCE", font: NSFont.systemFont(ofSize: 10, weight: .bold), color: NSColor(calibratedRed: 0.42, green: 0.93, blue: 0.70, alpha: 1))
  5224. hero.addSubview(heroBadge)
  5225. let heroTitle = textLabel("Elevate your meetings workflow", font: NSFont.systemFont(ofSize: 22, weight: .bold), color: palette.textPrimary)
  5226. let heroSubtitle = textLabel("Unlock automation, premium meeting tools, and priority support for every session.", font: NSFont.systemFont(ofSize: 13, weight: .medium), color: palette.textSecondary)
  5227. heroTitle.maximumNumberOfLines = 2
  5228. heroSubtitle.maximumNumberOfLines = 3
  5229. hero.addSubview(heroTitle)
  5230. hero.addSubview(heroSubtitle)
  5231. NSLayoutConstraint.activate([
  5232. heroBadge.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
  5233. heroBadge.topAnchor.constraint(equalTo: hero.topAnchor, constant: 14),
  5234. heroTitle.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: 16),
  5235. heroTitle.trailingAnchor.constraint(lessThanOrEqualTo: hero.trailingAnchor, constant: -16),
  5236. heroTitle.topAnchor.constraint(equalTo: heroBadge.bottomAnchor, constant: 10),
  5237. heroSubtitle.leadingAnchor.constraint(equalTo: heroTitle.leadingAnchor),
  5238. heroSubtitle.trailingAnchor.constraint(equalTo: hero.trailingAnchor, constant: -16),
  5239. heroSubtitle.topAnchor.constraint(equalTo: heroTitle.bottomAnchor, constant: 8),
  5240. heroSubtitle.bottomAnchor.constraint(equalTo: hero.bottomAnchor, constant: -16)
  5241. ])
  5242. let benefitsRow = NSStackView()
  5243. benefitsRow.translatesAutoresizingMaskIntoConstraints = false
  5244. benefitsRow.orientation = .horizontal
  5245. benefitsRow.spacing = 8
  5246. benefitsRow.distribution = .fillEqually
  5247. benefitsRow.alignment = .centerY
  5248. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  5249. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🧠", text: "Smart scheduling"))
  5250. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
  5251. benefitsRow.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
  5252. contentStack.addArrangedSubview(benefitsRow)
  5253. benefitsRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5254. let plansRow = NSStackView()
  5255. plansRow.translatesAutoresizingMaskIntoConstraints = false
  5256. plansRow.orientation = .horizontal
  5257. plansRow.spacing = 10
  5258. plansRow.distribution = .fillEqually
  5259. plansRow.alignment = .centerY
  5260. let weeklyCard = paywallPlanCard(
  5261. title: "Weekly",
  5262. price: fallbackPriceText(for: .weekly),
  5263. badge: "Basic",
  5264. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5265. subtitle: nil,
  5266. plan: .weekly,
  5267. strikePrice: nil
  5268. )
  5269. let monthlyCard = paywallPlanCard(
  5270. title: "Monthly",
  5271. price: fallbackPriceText(for: .monthly),
  5272. badge: "Popular",
  5273. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  5274. subtitle: nil,
  5275. plan: .monthly,
  5276. strikePrice: nil
  5277. )
  5278. let yearlyCard = paywallPlanCard(
  5279. title: "Yearly",
  5280. price: fallbackPriceText(for: .yearly),
  5281. badge: yearlySavingsBadgeText(),
  5282. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5283. subtitle: "3 days free trial",
  5284. plan: .yearly,
  5285. strikePrice: nil
  5286. )
  5287. let lifetimeCard = paywallPlanCard(
  5288. title: "Lifetime",
  5289. price: fallbackPriceText(for: .lifetime),
  5290. badge: "Best Value",
  5291. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5292. subtitle: nil,
  5293. plan: .lifetime,
  5294. strikePrice: nil
  5295. )
  5296. plansRow.addArrangedSubview(weeklyCard)
  5297. plansRow.addArrangedSubview(monthlyCard)
  5298. plansRow.addArrangedSubview(yearlyCard)
  5299. plansRow.addArrangedSubview(lifetimeCard)
  5300. contentStack.addArrangedSubview(plansRow)
  5301. plansRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5302. updatePaywallPlanSelection()
  5303. let trustRow = NSStackView()
  5304. trustRow.translatesAutoresizingMaskIntoConstraints = false
  5305. trustRow.orientation = .horizontal
  5306. trustRow.spacing = 8
  5307. trustRow.distribution = .fillEqually
  5308. trustRow.alignment = .centerY
  5309. trustRow.addArrangedSubview(paywallMetaItem(title: "Cancel anytime", subtitle: "No lock-in"))
  5310. trustRow.addArrangedSubview(paywallMetaItem(title: "Instant access", subtitle: "Unlock all tools"))
  5311. trustRow.addArrangedSubview(paywallMetaItem(title: "Secure billing", subtitle: "Handled by Apple"))
  5312. contentStack.addArrangedSubview(trustRow)
  5313. trustRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5314. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
  5315. offer.alignment = .center
  5316. paywallOfferLabel = offer
  5317. let offerWrap = NSView()
  5318. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  5319. offerWrap.addSubview(offer)
  5320. contentStack.addArrangedSubview(offerWrap)
  5321. offerWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5322. NSLayoutConstraint.activate([
  5323. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  5324. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 4),
  5325. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  5326. ])
  5327. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  5328. continueButton.translatesAutoresizingMaskIntoConstraints = false
  5329. continueButton.isBordered = false
  5330. continueButton.bezelStyle = .regularSquare
  5331. continueButton.wantsLayer = true
  5332. continueButton.layer?.cornerRadius = 12
  5333. continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
  5334. continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5335. styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
  5336. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 14, weight: .bold), color: .white)
  5337. continueButton.addSubview(continueLabel)
  5338. NSLayoutConstraint.activate([
  5339. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  5340. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  5341. ])
  5342. let baseBlue = palette.primaryBlue
  5343. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5344. let hoverBlue = baseBlue.blended(withFraction: 0.10, of: hoverBlend) ?? baseBlue
  5345. continueButton.onHoverChanged = { hovering in
  5346. continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
  5347. }
  5348. continueButton.onHoverChanged?(false)
  5349. paywallContinueButton = continueButton
  5350. paywallContinueLabel = continueLabel
  5351. contentStack.addArrangedSubview(continueButton)
  5352. continueButton.widthAnchor.constraint(equalToConstant: 360).isActive = true
  5353. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
  5354. secure.alignment = .center
  5355. let secureWrap = NSView()
  5356. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  5357. secureWrap.addSubview(secure)
  5358. contentStack.addArrangedSubview(secureWrap)
  5359. secureWrap.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5360. NSLayoutConstraint.activate([
  5361. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  5362. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 2),
  5363. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -4)
  5364. ])
  5365. let footer = paywallFooterLinks()
  5366. contentStack.addArrangedSubview(footer)
  5367. footer.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5368. var panelConstraints: [NSLayoutConstraint] = [
  5369. contentStack.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
  5370. contentStack.widthAnchor.constraint(equalToConstant: paywallLayoutWidth),
  5371. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 80),
  5372. contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12)
  5373. ]
  5374. if let closeButton {
  5375. panelConstraints.append(closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 30))
  5376. panelConstraints.append(closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -30))
  5377. }
  5378. NSLayoutConstraint.activate(panelConstraints)
  5379. refreshPaywallStoreUI()
  5380. return panel
  5381. }
  5382. func paywallPlanCard(
  5383. title: String,
  5384. price: String,
  5385. badge: String,
  5386. badgeColor: NSColor,
  5387. subtitle: String?,
  5388. plan: PremiumPlan,
  5389. strikePrice: String?
  5390. ) -> NSView {
  5391. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  5392. wrapper.translatesAutoresizingMaskIntoConstraints = false
  5393. wrapper.isBordered = false
  5394. wrapper.bezelStyle = .regularSquare
  5395. wrapper.wantsLayer = true
  5396. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  5397. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
  5398. wrapper.heightAnchor.constraint(equalToConstant: 162).isActive = true
  5399. wrapper.tag = plan.rawValue
  5400. let card = HoverTrackingView()
  5401. card.translatesAutoresizingMaskIntoConstraints = false
  5402. card.wantsLayer = true
  5403. card.layer?.cornerRadius = 16
  5404. card.layer?.backgroundColor = palette.sectionCard.cgColor
  5405. card.heightAnchor.constraint(equalToConstant: 150).isActive = true
  5406. wrapper.addSubview(card)
  5407. NSLayoutConstraint.activate([
  5408. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5409. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5410. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
  5411. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  5412. ])
  5413. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5414. let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
  5415. paywallBadgeLabels[plan] = badgeLabel
  5416. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  5417. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  5418. badgeWrap.wantsLayer = true
  5419. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  5420. badgeWrap.layer?.borderWidth = 1
  5421. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  5422. badgeWrap.layer?.shadowOpacity = 0.20
  5423. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  5424. badgeWrap.layer?.shadowRadius = 3
  5425. badgeWrap.addSubview(badgeLabel)
  5426. NSLayoutConstraint.activate([
  5427. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  5428. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  5429. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  5430. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  5431. ])
  5432. wrapper.addSubview(badgeWrap)
  5433. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue)
  5434. card.addSubview(titleLabel)
  5435. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
  5436. card.addSubview(priceLabel)
  5437. paywallPriceLabels[plan] = priceLabel
  5438. NSLayoutConstraint.activate([
  5439. badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
  5440. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  5441. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
  5442. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
  5443. priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  5444. priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  5445. priceLabel.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  5446. ])
  5447. if let subtitle {
  5448. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
  5449. card.addSubview(sub)
  5450. paywallSubtitleLabels[plan] = sub
  5451. NSLayoutConstraint.activate([
  5452. sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  5453. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
  5454. sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  5455. ])
  5456. }
  5457. if let strikePrice {
  5458. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  5459. card.addSubview(strike)
  5460. paywallStrikePriceLabels[plan] = strike
  5461. NSLayoutConstraint.activate([
  5462. strike.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  5463. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4),
  5464. strike.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  5465. ])
  5466. }
  5467. paywallPlanViews[plan] = card
  5468. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  5469. guard let self, let card else { return }
  5470. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  5471. }
  5472. wrapper.onHoverChanged?(false)
  5473. return wrapper
  5474. }
  5475. func paywallFooterLinks() -> NSView {
  5476. let wrap = NSView()
  5477. wrap.translatesAutoresizingMaskIntoConstraints = false
  5478. wrap.heightAnchor.constraint(equalToConstant: 40).isActive = true
  5479. paywallFooterActionByView.removeAll()
  5480. let row = NSStackView()
  5481. row.translatesAutoresizingMaskIntoConstraints = false
  5482. row.orientation = .horizontal
  5483. row.distribution = .fillEqually
  5484. row.alignment = .centerY
  5485. row.spacing = 0
  5486. wrap.addSubview(row)
  5487. if storeKitCoordinator.hasPremiumAccess {
  5488. row.addArrangedSubview(footerLink("Manage Subscription", action: .manageSubscription))
  5489. row.addArrangedSubview(footerLink("Restore Purchase", action: .restorePurchase))
  5490. } else {
  5491. row.addArrangedSubview(footerLink("Continue with free plan", action: .continueWithFreePlan))
  5492. }
  5493. row.addArrangedSubview(footerLink("Privacy Policy", action: .privacyPolicy))
  5494. row.addArrangedSubview(footerLink("Support", action: .support))
  5495. row.addArrangedSubview(footerLink("Terms of Services", action: .termsOfServices))
  5496. NSLayoutConstraint.activate([
  5497. row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
  5498. row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
  5499. row.topAnchor.constraint(equalTo: wrap.topAnchor),
  5500. row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
  5501. ])
  5502. return wrap
  5503. }
  5504. private func footerLink(_ title: String, action: PaywallFooterAction) -> NSView {
  5505. let button = HoverButton(title: title, target: self, action: #selector(paywallFooterButtonPressed(_:)))
  5506. button.translatesAutoresizingMaskIntoConstraints = false
  5507. button.isBordered = false
  5508. button.bezelStyle = .regularSquare
  5509. button.focusRingType = .none
  5510. button.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  5511. button.alignment = .center
  5512. button.setButtonType(.momentaryChange)
  5513. button.contentTintColor = palette.textSecondary
  5514. paywallFooterActionByView[ObjectIdentifier(button)] = action
  5515. button.onHoverChanged = { [weak self, weak button] hovering in
  5516. guard let self, let button else { return }
  5517. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  5518. }
  5519. button.onHoverChanged?(false)
  5520. return button
  5521. }
  5522. func paywallMetaItem(title: String, subtitle: String) -> NSView {
  5523. let card = HoverTrackingView()
  5524. card.translatesAutoresizingMaskIntoConstraints = false
  5525. card.wantsLayer = true
  5526. card.layer?.cornerRadius = 10
  5527. card.layer?.backgroundColor = palette.inputBackground.cgColor
  5528. card.heightAnchor.constraint(equalToConstant: 44).isActive = true
  5529. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5530. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
  5531. let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .medium), color: palette.textSecondary)
  5532. card.addSubview(titleLabel)
  5533. card.addSubview(subtitleLabel)
  5534. NSLayoutConstraint.activate([
  5535. titleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  5536. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 7),
  5537. subtitleLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  5538. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 1),
  5539. subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -6)
  5540. ])
  5541. let base = palette.inputBackground
  5542. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5543. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  5544. card.onHoverChanged = { [weak card] hovering in
  5545. card?.layer?.backgroundColor = (hovering ? hover : base).cgColor
  5546. }
  5547. card.onHoverChanged?(false)
  5548. return card
  5549. }
  5550. func paywallBenefitsSection() -> NSView {
  5551. let stack = NSStackView()
  5552. stack.translatesAutoresizingMaskIntoConstraints = false
  5553. stack.orientation = .vertical
  5554. stack.spacing = 8
  5555. stack.alignment = .leading
  5556. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  5557. let rowOne = NSStackView()
  5558. rowOne.translatesAutoresizingMaskIntoConstraints = false
  5559. rowOne.orientation = .horizontal
  5560. rowOne.spacing = 10
  5561. rowOne.distribution = .fillEqually
  5562. rowOne.alignment = .centerY
  5563. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  5564. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  5565. let rowTwo = NSStackView()
  5566. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  5567. rowTwo.orientation = .horizontal
  5568. rowTwo.spacing = 10
  5569. rowTwo.distribution = .fillEqually
  5570. rowTwo.alignment = .centerY
  5571. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  5572. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  5573. stack.addArrangedSubview(rowOne)
  5574. stack.addArrangedSubview(rowTwo)
  5575. return stack
  5576. }
  5577. func paywallBenefitItem(icon: String, text: String) -> NSView {
  5578. let card = HoverTrackingView()
  5579. card.translatesAutoresizingMaskIntoConstraints = false
  5580. card.wantsLayer = true
  5581. card.layer?.cornerRadius = 10
  5582. card.layer?.backgroundColor = palette.inputBackground.cgColor
  5583. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5584. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5585. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  5586. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  5587. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  5588. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  5589. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5590. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue)
  5591. iconWrap.addSubview(iconLabel)
  5592. NSLayoutConstraint.activate([
  5593. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  5594. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  5595. ])
  5596. let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary)
  5597. card.addSubview(iconWrap)
  5598. card.addSubview(title)
  5599. NSLayoutConstraint.activate([
  5600. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  5601. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  5602. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  5603. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  5604. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  5605. ])
  5606. let base = palette.inputBackground
  5607. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5608. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  5609. let hoverBorder = palette.primaryBlueBorder.withAlphaComponent(0.55)
  5610. card.onHoverChanged = { [weak card, weak iconWrap] hovering in
  5611. guard let card else { return }
  5612. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  5613. card.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  5614. iconWrap?.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  5615. }
  5616. card.onHoverChanged?(false)
  5617. return card
  5618. }
  5619. func zoomJoinModeTabs() -> NSView {
  5620. let row = NSStackView()
  5621. row.translatesAutoresizingMaskIntoConstraints = false
  5622. row.orientation = .horizontal
  5623. row.alignment = .centerY
  5624. row.spacing = 28
  5625. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  5626. let idTab = joinModeTab("Join with ID", mode: .id)
  5627. let urlTab = joinModeTab("Join with URL", mode: .url)
  5628. row.addArrangedSubview(idTab)
  5629. row.addArrangedSubview(urlTab)
  5630. let spacer = NSView()
  5631. spacer.translatesAutoresizingMaskIntoConstraints = false
  5632. row.addArrangedSubview(spacer)
  5633. zoomJoinModeViews[.id] = idTab
  5634. zoomJoinModeViews[.url] = urlTab
  5635. updateZoomJoinModeAppearance()
  5636. return row
  5637. }
  5638. func joinModeTab(_ title: String, mode: ZoomJoinMode) -> NSView {
  5639. let tab = HoverTrackingView()
  5640. tab.translatesAutoresizingMaskIntoConstraints = false
  5641. tab.wantsLayer = true
  5642. tab.layer?.cornerRadius = 6
  5643. tab.layer?.backgroundColor = NSColor.clear.cgColor
  5644. tab.heightAnchor.constraint(equalToConstant: 30).isActive = true
  5645. zoomJoinModeByView[ObjectIdentifier(tab)] = mode
  5646. let label = textLabel(title, font: NSFont.systemFont(ofSize: 33 / 2, weight: .medium), color: palette.textPrimary)
  5647. tab.addSubview(label)
  5648. NSLayoutConstraint.activate([
  5649. label.leadingAnchor.constraint(equalTo: tab.leadingAnchor, constant: 4),
  5650. label.trailingAnchor.constraint(equalTo: tab.trailingAnchor, constant: -4),
  5651. label.topAnchor.constraint(equalTo: tab.topAnchor, constant: 4),
  5652. label.bottomAnchor.constraint(equalTo: tab.bottomAnchor, constant: -6)
  5653. ])
  5654. let click = NSClickGestureRecognizer(target: self, action: #selector(zoomJoinModeClicked(_:)))
  5655. tab.addGestureRecognizer(click)
  5656. return tab
  5657. }
  5658. func updateZoomJoinModeAppearance() {
  5659. for (mode, tab) in zoomJoinModeViews {
  5660. let selected = (mode == selectedZoomJoinMode)
  5661. let textColor = selected ? palette.textPrimary : palette.textSecondary
  5662. let label = tab.subviews.first { $0 is NSTextField } as? NSTextField
  5663. label?.textColor = textColor
  5664. // Keep the active tab visually underlined like the reference.
  5665. if selected {
  5666. if tab.subviews.contains(where: { $0.identifier?.rawValue == "modeUnderline" }) == false {
  5667. let underline = NSView()
  5668. underline.identifier = NSUserInterfaceItemIdentifier("modeUnderline")
  5669. underline.translatesAutoresizingMaskIntoConstraints = false
  5670. underline.wantsLayer = true
  5671. underline.layer?.backgroundColor = palette.primaryBlue.cgColor
  5672. tab.addSubview(underline)
  5673. NSLayoutConstraint.activate([
  5674. underline.leadingAnchor.constraint(equalTo: tab.leadingAnchor),
  5675. underline.trailingAnchor.constraint(equalTo: tab.trailingAnchor),
  5676. underline.bottomAnchor.constraint(equalTo: tab.bottomAnchor),
  5677. underline.heightAnchor.constraint(equalToConstant: 2)
  5678. ])
  5679. }
  5680. } else {
  5681. tab.subviews
  5682. .filter { $0.identifier?.rawValue == "modeUnderline" }
  5683. .forEach { $0.removeFromSuperview() }
  5684. }
  5685. }
  5686. }
  5687. func joinWithIDHeading() -> NSView {
  5688. let container = NSView()
  5689. container.translatesAutoresizingMaskIntoConstraints = false
  5690. let title = textLabel("Join with ID", font: typography.joinWithURLTitle, color: palette.textPrimary)
  5691. title.alignment = .left
  5692. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  5693. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  5694. let bar = NSView()
  5695. bar.translatesAutoresizingMaskIntoConstraints = false
  5696. bar.wantsLayer = true
  5697. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  5698. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  5699. container.addSubview(title)
  5700. container.addSubview(bar)
  5701. NSLayoutConstraint.activate([
  5702. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  5703. title.topAnchor.constraint(equalTo: container.topAnchor),
  5704. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  5705. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  5706. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  5707. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  5708. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  5709. ])
  5710. return container
  5711. }
  5712. func zoomMeetingIDSection() -> NSView {
  5713. let wrapper = NSView()
  5714. wrapper.translatesAutoresizingMaskIntoConstraints = false
  5715. let fieldsRow = NSStackView()
  5716. fieldsRow.translatesAutoresizingMaskIntoConstraints = false
  5717. fieldsRow.orientation = .horizontal
  5718. fieldsRow.alignment = .top
  5719. fieldsRow.distribution = .fillEqually
  5720. fieldsRow.spacing = 12
  5721. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting ID", placeholder: "Enter meeting ID..."))
  5722. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting Passcode", placeholder: "Enter meeting passcode..."))
  5723. let actions = NSStackView()
  5724. actions.orientation = .horizontal
  5725. actions.spacing = 10
  5726. actions.translatesAutoresizingMaskIntoConstraints = false
  5727. actions.alignment = .centerY
  5728. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  5729. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  5730. wrapper.addSubview(fieldsRow)
  5731. wrapper.addSubview(actions)
  5732. NSLayoutConstraint.activate([
  5733. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  5734. fieldsRow.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5735. fieldsRow.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5736. fieldsRow.topAnchor.constraint(equalTo: wrapper.topAnchor),
  5737. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5738. actions.topAnchor.constraint(equalTo: fieldsRow.bottomAnchor, constant: 14),
  5739. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  5740. ])
  5741. return wrapper
  5742. }
  5743. func zoomInputField(title: String, placeholder: String) -> NSView {
  5744. let wrapper = NSView()
  5745. wrapper.translatesAutoresizingMaskIntoConstraints = false
  5746. let heading = textLabel(title, font: typography.fieldLabel, color: palette.textPrimary)
  5747. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  5748. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  5749. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  5750. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5751. let field = NSTextField(string: "")
  5752. field.translatesAutoresizingMaskIntoConstraints = false
  5753. field.isEditable = true
  5754. field.isSelectable = true
  5755. field.isBordered = false
  5756. field.drawsBackground = false
  5757. field.placeholderString = placeholder
  5758. field.font = typography.inputPlaceholder
  5759. field.textColor = palette.textPrimary
  5760. field.focusRingType = .none
  5761. textFieldContainer.addSubview(field)
  5762. wrapper.addSubview(heading)
  5763. wrapper.addSubview(textFieldContainer)
  5764. NSLayoutConstraint.activate([
  5765. heading.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5766. heading.topAnchor.constraint(equalTo: wrapper.topAnchor),
  5767. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5768. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5769. textFieldContainer.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 10),
  5770. textFieldContainer.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
  5771. field.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  5772. field.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  5773. field.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor)
  5774. ])
  5775. return wrapper
  5776. }
  5777. func joinWithURLHeading() -> NSView {
  5778. let container = NSView()
  5779. container.translatesAutoresizingMaskIntoConstraints = false
  5780. let title = textLabel("Join with URL", font: typography.joinWithURLTitle, color: palette.textPrimary)
  5781. title.alignment = .left
  5782. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  5783. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  5784. let bar = NSView()
  5785. bar.translatesAutoresizingMaskIntoConstraints = false
  5786. bar.wantsLayer = true
  5787. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  5788. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  5789. container.addSubview(title)
  5790. container.addSubview(bar)
  5791. NSLayoutConstraint.activate([
  5792. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  5793. title.topAnchor.constraint(equalTo: container.topAnchor),
  5794. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  5795. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  5796. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  5797. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  5798. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  5799. ])
  5800. return container
  5801. }
  5802. func meetingUrlSection() -> NSView {
  5803. let wrapper = NSView()
  5804. wrapper.translatesAutoresizingMaskIntoConstraints = false
  5805. let title = textLabel("Meeting URL", font: typography.fieldLabel, color: palette.textSecondary)
  5806. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  5807. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  5808. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  5809. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5810. let urlField = NSTextField(string: "")
  5811. urlField.translatesAutoresizingMaskIntoConstraints = false
  5812. urlField.isEditable = true
  5813. urlField.isSelectable = true
  5814. urlField.isBordered = false
  5815. urlField.drawsBackground = false
  5816. urlField.placeholderString = "Enter meeting URL..."
  5817. urlField.font = typography.inputPlaceholder
  5818. urlField.textColor = palette.textPrimary
  5819. urlField.focusRingType = .none
  5820. textFieldContainer.addSubview(urlField)
  5821. let actions = NSStackView()
  5822. actions.orientation = .horizontal
  5823. actions.spacing = 10
  5824. actions.translatesAutoresizingMaskIntoConstraints = false
  5825. actions.alignment = .centerY
  5826. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  5827. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  5828. wrapper.addSubview(title)
  5829. wrapper.addSubview(textFieldContainer)
  5830. wrapper.addSubview(actions)
  5831. NSLayoutConstraint.activate([
  5832. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  5833. title.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5834. title.topAnchor.constraint(equalTo: wrapper.topAnchor),
  5835. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5836. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5837. textFieldContainer.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10),
  5838. urlField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  5839. urlField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  5840. urlField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor),
  5841. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5842. actions.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 14),
  5843. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  5844. ])
  5845. return wrapper
  5846. }
  5847. func scheduleHeader() -> NSView {
  5848. let row = NSStackView()
  5849. row.translatesAutoresizingMaskIntoConstraints = false
  5850. row.orientation = .horizontal
  5851. row.alignment = .centerY
  5852. row.distribution = .fill
  5853. row.spacing = 12
  5854. row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
  5855. let spacer = NSView()
  5856. spacer.translatesAutoresizingMaskIntoConstraints = false
  5857. row.addArrangedSubview(spacer)
  5858. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  5859. row.addArrangedSubview(makeScheduleRefreshButton())
  5860. row.addArrangedSubview(makeScheduleFilterDropdown())
  5861. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  5862. return row
  5863. }
  5864. private func schedulePageHeader() -> NSView {
  5865. let container = NSStackView()
  5866. container.translatesAutoresizingMaskIntoConstraints = false
  5867. container.userInterfaceLayoutDirection = .leftToRight
  5868. container.orientation = .vertical
  5869. container.spacing = 8
  5870. container.alignment = .width
  5871. let titleRow = NSStackView()
  5872. titleRow.translatesAutoresizingMaskIntoConstraints = false
  5873. titleRow.userInterfaceLayoutDirection = .leftToRight
  5874. titleRow.orientation = .horizontal
  5875. titleRow.alignment = .centerY
  5876. titleRow.distribution = .fill
  5877. titleRow.spacing = 0
  5878. let titleLabel = textLabel("Schedule", font: typography.pageTitle, color: palette.textPrimary)
  5879. titleLabel.alignment = .left
  5880. titleLabel.userInterfaceLayoutDirection = .leftToRight
  5881. titleLabel.maximumNumberOfLines = 1
  5882. titleLabel.lineBreakMode = .byTruncatingTail
  5883. titleLabel.setContentHuggingPriority(.required, for: .horizontal)
  5884. titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  5885. let titleRowSpacer = NSView()
  5886. titleRowSpacer.translatesAutoresizingMaskIntoConstraints = false
  5887. titleRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  5888. titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  5889. titleRow.addArrangedSubview(titleLabel)
  5890. if hasGoogleSessionAvailable() && storeKitCoordinator.hasPremiumAccess {
  5891. titleRow.addArrangedSubview(makeSchedulePageAddButton())
  5892. titleRow.setCustomSpacing(12, after: titleLabel)
  5893. }
  5894. titleRow.addArrangedSubview(titleRowSpacer)
  5895. container.addArrangedSubview(titleRow)
  5896. let filterRow = NSStackView()
  5897. filterRow.translatesAutoresizingMaskIntoConstraints = false
  5898. filterRow.userInterfaceLayoutDirection = .leftToRight
  5899. filterRow.orientation = .horizontal
  5900. filterRow.alignment = .centerY
  5901. filterRow.spacing = 18
  5902. filterRow.distribution = .fill
  5903. let filterDropdown = makeSchedulePageFilterDropdown()
  5904. schedulePageFilterDropdown = filterDropdown
  5905. filterRow.addArrangedSubview(filterDropdown)
  5906. filterRow.setCustomSpacing(20, after: filterDropdown)
  5907. let (fromShell, fromPicker) = makeScheduleDatePicker(date: schedulePageFromDate)
  5908. schedulePageFromDatePicker = fromPicker
  5909. filterRow.addArrangedSubview(fromShell)
  5910. filterRow.setCustomSpacing(16, after: fromShell)
  5911. let (toShell, toPicker) = makeScheduleDatePicker(date: schedulePageToDate)
  5912. schedulePageToDatePicker = toPicker
  5913. filterRow.addArrangedSubview(toShell)
  5914. NSLayoutConstraint.activate([
  5915. fromShell.widthAnchor.constraint(equalTo: toShell.widthAnchor),
  5916. fromShell.widthAnchor.constraint(greaterThanOrEqualToConstant: 152)
  5917. ])
  5918. let filterRowSpacer = NSView()
  5919. filterRowSpacer.translatesAutoresizingMaskIntoConstraints = false
  5920. filterRowSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  5921. filterRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  5922. filterRow.addArrangedSubview(filterRowSpacer)
  5923. let applyButton = makeSchedulePagePillButton(title: "Apply", action: #selector(schedulePageApplyDateRangePressed(_:)))
  5924. filterRow.addArrangedSubview(applyButton)
  5925. filterRow.setCustomSpacing(22, after: applyButton)
  5926. let resetButton = makeSchedulePagePillButton(title: "Reset", action: #selector(schedulePageResetFiltersPressed(_:)))
  5927. filterRow.addArrangedSubview(resetButton)
  5928. filterRow.setCustomSpacing(22, after: resetButton)
  5929. filterRow.addArrangedSubview(makeScheduleRefreshButton())
  5930. container.addArrangedSubview(filterRow)
  5931. NSLayoutConstraint.activate([
  5932. titleRow.widthAnchor.constraint(equalTo: container.widthAnchor),
  5933. filterRow.widthAnchor.constraint(equalTo: container.widthAnchor)
  5934. ])
  5935. refreshSchedulePageDateFilterUI()
  5936. return container
  5937. }
  5938. private func makeSchedulePageFilterDropdown() -> NSPopUpButton {
  5939. let button = HoverPopUpButton(frame: .zero, pullsDown: false)
  5940. button.translatesAutoresizingMaskIntoConstraints = false
  5941. button.autoenablesItems = false
  5942. button.isBordered = false
  5943. button.bezelStyle = .regularSquare
  5944. button.wantsLayer = true
  5945. button.layer?.cornerRadius = 8
  5946. button.layer?.masksToBounds = true
  5947. button.layer?.backgroundColor = palette.inputBackground.cgColor
  5948. button.layer?.borderColor = palette.inputBorder.cgColor
  5949. button.layer?.borderWidth = 1
  5950. button.font = typography.filterText
  5951. button.contentTintColor = palette.textSecondary
  5952. button.target = self
  5953. button.action = #selector(schedulePageFilterDropdownChanged(_:))
  5954. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  5955. button.widthAnchor.constraint(equalToConstant: 228).isActive = true
  5956. button.removeAllItems()
  5957. button.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
  5958. button.selectItem(at: schedulePageFilter.rawValue)
  5959. if let menu = button.menu {
  5960. for (index, item) in menu.items.enumerated() {
  5961. item.tag = index
  5962. }
  5963. }
  5964. let baseColor = palette.inputBackground
  5965. let baseBorder = palette.inputBorder
  5966. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5967. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  5968. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  5969. button.onHoverChanged = { [weak button] hovering in
  5970. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  5971. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  5972. }
  5973. button.onHoverChanged?(false)
  5974. return button
  5975. }
  5976. /// Rounded shell matching `makeSchedulePageFilterDropdown` (34pt, 8pt corner, border + hover). Both pickers use the same field+stepper style inside the shell.
  5977. private func makeFilterStyleDatePicker(date: Date, changeAction: Selector) -> (NSView, NSDatePicker) {
  5978. let shell = HoverSurfaceView()
  5979. shell.translatesAutoresizingMaskIntoConstraints = false
  5980. shell.wantsLayer = true
  5981. shell.layer?.cornerRadius = 8
  5982. shell.layer?.masksToBounds = true
  5983. shell.layer?.borderWidth = 1
  5984. let baseColor = palette.inputBackground
  5985. let baseBorder = palette.inputBorder
  5986. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  5987. let hoverBackground = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  5988. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  5989. func applyShellIdleAppearance() {
  5990. shell.layer?.backgroundColor = baseColor.cgColor
  5991. shell.layer?.borderColor = baseBorder.cgColor
  5992. }
  5993. applyShellIdleAppearance()
  5994. shell.onHoverChanged = { [weak shell] hovering in
  5995. guard let shell else { return }
  5996. shell.layer?.backgroundColor = (hovering ? hoverBackground : baseColor).cgColor
  5997. shell.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  5998. }
  5999. let picker = NSDatePicker()
  6000. picker.translatesAutoresizingMaskIntoConstraints = false
  6001. picker.isBordered = false
  6002. picker.drawsBackground = false
  6003. picker.focusRingType = .none
  6004. picker.datePickerStyle = .textFieldAndStepper
  6005. picker.datePickerElements = [.yearMonthDay]
  6006. picker.dateValue = date
  6007. picker.font = typography.filterText
  6008. picker.textColor = palette.textSecondary
  6009. picker.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6010. picker.target = self
  6011. picker.action = changeAction
  6012. shell.addSubview(picker)
  6013. NSLayoutConstraint.activate([
  6014. shell.heightAnchor.constraint(equalToConstant: 34),
  6015. picker.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: 8),
  6016. picker.trailingAnchor.constraint(equalTo: shell.trailingAnchor, constant: -8),
  6017. picker.centerYAnchor.constraint(equalTo: shell.centerYAnchor)
  6018. ])
  6019. return (shell, picker)
  6020. }
  6021. private func makeScheduleDatePicker(date: Date) -> (NSView, NSDatePicker) {
  6022. makeFilterStyleDatePicker(date: date, changeAction: #selector(schedulePageDatePickerChanged(_:)))
  6023. }
  6024. private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton {
  6025. let button = makeSchedulePillButton(title: title)
  6026. button.target = self
  6027. button.action = action
  6028. button.widthAnchor.constraint(equalToConstant: 100).isActive = true
  6029. return button
  6030. }
  6031. private func makeSchedulePageCardsContainer() -> NSView {
  6032. if let observer = schedulePageScrollObservation {
  6033. NotificationCenter.default.removeObserver(observer)
  6034. }
  6035. schedulePageScrollObservation = nil
  6036. let wrapper = NSView()
  6037. wrapper.translatesAutoresizingMaskIntoConstraints = false
  6038. wrapper.userInterfaceLayoutDirection = .leftToRight
  6039. let scroll = NSScrollView()
  6040. scroll.translatesAutoresizingMaskIntoConstraints = false
  6041. scroll.userInterfaceLayoutDirection = .leftToRight
  6042. scroll.drawsBackground = false
  6043. scroll.hasHorizontalScroller = false
  6044. scroll.hasVerticalScroller = true
  6045. scroll.autohidesScrollers = true
  6046. scroll.borderType = .noBorder
  6047. scroll.scrollerStyle = .overlay
  6048. scroll.automaticallyAdjustsContentInsets = false
  6049. let clip = TopAlignedClipView()
  6050. clip.drawsBackground = false
  6051. clip.postsBoundsChangedNotifications = true
  6052. scroll.contentView = clip
  6053. schedulePageCardsScrollView = scroll
  6054. wrapper.addSubview(scroll)
  6055. let stack = NSStackView()
  6056. stack.translatesAutoresizingMaskIntoConstraints = false
  6057. stack.userInterfaceLayoutDirection = .leftToRight
  6058. stack.orientation = .vertical
  6059. stack.spacing = schedulePageCardSpacing
  6060. stack.alignment = .width
  6061. schedulePageCardsStack = stack
  6062. scroll.documentView = stack
  6063. NSLayoutConstraint.activate([
  6064. scroll.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  6065. scroll.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  6066. scroll.topAnchor.constraint(equalTo: wrapper.topAnchor),
  6067. scroll.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
  6068. scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420),
  6069. stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  6070. stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  6071. stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  6072. stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
  6073. ])
  6074. scroll.contentView.postsBoundsChangedNotifications = true
  6075. schedulePageScrollObservation = NotificationCenter.default.addObserver(
  6076. forName: NSView.boundsDidChangeNotification,
  6077. object: scroll.contentView,
  6078. queue: .main
  6079. ) { [weak self] _ in
  6080. self?.schedulePageScrolled()
  6081. }
  6082. renderSchedulePageCards()
  6083. return wrapper
  6084. }
  6085. private func scheduleTopAuthRow() -> NSView {
  6086. let row = NSStackView()
  6087. row.translatesAutoresizingMaskIntoConstraints = false
  6088. row.orientation = .horizontal
  6089. row.alignment = .centerY
  6090. row.spacing = 10
  6091. let spacer = NSView()
  6092. spacer.translatesAutoresizingMaskIntoConstraints = false
  6093. row.addArrangedSubview(spacer)
  6094. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6095. let host = GoogleProfileAuthHostView()
  6096. host.translatesAutoresizingMaskIntoConstraints = false
  6097. let authButton = makeGoogleAuthButton()
  6098. host.authButton = authButton
  6099. scheduleGoogleAuthHostView = host
  6100. scheduleGoogleAuthButton = authButton
  6101. host.addSubview(authButton)
  6102. NSLayoutConstraint.activate([
  6103. authButton.centerXAnchor.constraint(equalTo: host.centerXAnchor),
  6104. authButton.centerYAnchor.constraint(equalTo: host.centerYAnchor)
  6105. ])
  6106. let hostPadW = host.widthAnchor.constraint(equalTo: authButton.widthAnchor, constant: 0)
  6107. let hostPadH = host.heightAnchor.constraint(equalTo: authButton.heightAnchor, constant: 0)
  6108. hostPadW.isActive = true
  6109. hostPadH.isActive = true
  6110. scheduleGoogleAuthHostPadWidthConstraint = hostPadW
  6111. scheduleGoogleAuthHostPadHeightConstraint = hostPadH
  6112. updateGoogleAuthButtonTitle()
  6113. row.addArrangedSubview(host)
  6114. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  6115. return row
  6116. }
  6117. private func makeScheduleFilterDropdown() -> NSPopUpButton {
  6118. let button = HoverPopUpButton(frame: .zero, pullsDown: false)
  6119. button.translatesAutoresizingMaskIntoConstraints = false
  6120. button.autoenablesItems = false
  6121. button.isBordered = false
  6122. button.bezelStyle = .regularSquare
  6123. button.wantsLayer = true
  6124. button.layer?.cornerRadius = 8
  6125. button.layer?.masksToBounds = true
  6126. button.layer?.backgroundColor = palette.inputBackground.cgColor
  6127. button.layer?.borderColor = palette.inputBorder.cgColor
  6128. button.layer?.borderWidth = 1
  6129. button.font = typography.filterText
  6130. button.contentTintColor = palette.textSecondary
  6131. button.target = self
  6132. button.action = #selector(scheduleFilterDropdownChanged(_:))
  6133. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  6134. button.widthAnchor.constraint(equalToConstant: 156).isActive = true
  6135. button.removeAllItems()
  6136. button.addItems(withTitles: ["All", "Today", "This week"])
  6137. button.selectItem(at: scheduleFilter.rawValue)
  6138. if let menu = button.menu {
  6139. for (index, item) in menu.items.enumerated() {
  6140. item.tag = index
  6141. }
  6142. }
  6143. let baseColor = palette.inputBackground
  6144. let baseBorder = palette.inputBorder
  6145. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  6146. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  6147. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  6148. button.onHoverChanged = { [weak button] hovering in
  6149. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  6150. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  6151. }
  6152. button.onHoverChanged?(false)
  6153. scheduleFilterDropdown = button
  6154. return button
  6155. }
  6156. private func makeSchedulePillButton(title: String) -> NSButton {
  6157. let button = NSButton(title: title, target: nil, action: nil)
  6158. button.translatesAutoresizingMaskIntoConstraints = false
  6159. button.isBordered = false
  6160. button.bezelStyle = .regularSquare
  6161. button.wantsLayer = true
  6162. button.layer?.cornerRadius = 8
  6163. button.layer?.backgroundColor = palette.inputBackground.cgColor
  6164. button.layer?.borderColor = palette.inputBorder.cgColor
  6165. button.layer?.borderWidth = 1
  6166. button.font = typography.filterText
  6167. button.contentTintColor = palette.textSecondary
  6168. button.setButtonType(.momentaryChange)
  6169. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  6170. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
  6171. return button
  6172. }
  6173. private func makeGoogleAuthButton() -> NSButton {
  6174. let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
  6175. button.translatesAutoresizingMaskIntoConstraints = false
  6176. button.isBordered = false
  6177. button.bezelStyle = .regularSquare
  6178. button.wantsLayer = true
  6179. button.layer?.cornerRadius = 16
  6180. button.layer?.borderWidth = 1
  6181. button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
  6182. button.imagePosition = .imageLeading
  6183. button.alignment = .center
  6184. button.imageHugsTitle = true
  6185. button.lineBreakMode = .byTruncatingTail
  6186. button.contentTintColor = palette.textPrimary
  6187. button.imageScaling = .scaleNone
  6188. button.layer?.masksToBounds = true
  6189. let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42)
  6190. heightConstraint.isActive = true
  6191. scheduleGoogleAuthButtonHeightConstraint = heightConstraint
  6192. let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
  6193. widthConstraint.isActive = true
  6194. scheduleGoogleAuthButtonWidthConstraint = widthConstraint
  6195. button.onHoverChanged = { [weak self] hovering in
  6196. self?.scheduleGoogleAuthHovering = hovering
  6197. self?.scheduleGoogleAuthHostView?.setProfileHoverActive(hovering)
  6198. self?.applyGoogleAuthButtonSurface()
  6199. }
  6200. button.onHoverChanged?(false)
  6201. return button
  6202. }
  6203. private func makeSchedulePageAddButton() -> NSButton {
  6204. let diameter: CGFloat = 30
  6205. let button = HoverButton(title: "", target: self, action: #selector(schedulePageAddMeetingPressed(_:)))
  6206. button.translatesAutoresizingMaskIntoConstraints = false
  6207. button.isBordered = false
  6208. button.bezelStyle = .regularSquare
  6209. button.wantsLayer = true
  6210. button.layer?.cornerRadius = diameter / 2
  6211. button.layer?.masksToBounds = true
  6212. button.layer?.backgroundColor = palette.inputBackground.cgColor
  6213. button.layer?.borderColor = palette.inputBorder.cgColor
  6214. button.layer?.borderWidth = 1
  6215. button.setButtonType(.momentaryChange)
  6216. button.contentTintColor = palette.textSecondary
  6217. button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Schedule meeting")
  6218. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  6219. button.imagePosition = .imageOnly
  6220. button.imageScaling = .scaleProportionallyDown
  6221. button.focusRingType = .none
  6222. button.heightAnchor.constraint(equalToConstant: diameter).isActive = true
  6223. button.widthAnchor.constraint(equalToConstant: diameter).isActive = true
  6224. let baseColor = palette.inputBackground
  6225. let baseBorder = palette.inputBorder
  6226. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  6227. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  6228. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  6229. button.onHoverChanged = { [weak button] hovering in
  6230. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  6231. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  6232. }
  6233. button.onHoverChanged?(false)
  6234. return button
  6235. }
  6236. private func makeScheduleRefreshButton(
  6237. action: Selector = #selector(scheduleReloadButtonPressed(_:)),
  6238. accessibilityDescription: String = "Refresh meetings"
  6239. ) -> NSButton {
  6240. let diameter: CGFloat = 30
  6241. let button = HoverButton(title: "", target: self, action: action)
  6242. button.translatesAutoresizingMaskIntoConstraints = false
  6243. button.isBordered = false
  6244. button.bezelStyle = .regularSquare
  6245. button.wantsLayer = true
  6246. button.layer?.cornerRadius = diameter / 2
  6247. button.layer?.masksToBounds = true
  6248. button.layer?.backgroundColor = palette.inputBackground.cgColor
  6249. button.layer?.borderColor = palette.inputBorder.cgColor
  6250. button.layer?.borderWidth = 1
  6251. button.setButtonType(.momentaryChange)
  6252. button.contentTintColor = palette.textSecondary
  6253. button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: accessibilityDescription)
  6254. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  6255. button.imagePosition = .imageOnly
  6256. button.imageScaling = .scaleProportionallyDown
  6257. button.focusRingType = .none
  6258. button.heightAnchor.constraint(equalToConstant: diameter).isActive = true
  6259. button.widthAnchor.constraint(equalToConstant: diameter).isActive = true
  6260. let baseColor = palette.inputBackground
  6261. let baseBorder = palette.inputBorder
  6262. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  6263. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  6264. let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
  6265. button.onHoverChanged = { [weak button] hovering in
  6266. button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  6267. button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  6268. }
  6269. button.onHoverChanged?(false)
  6270. return button
  6271. }
  6272. func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
  6273. let cardWidth: CGFloat = 240
  6274. let cardsPerViewport: CGFloat = 3
  6275. let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
  6276. let wrapper = NSStackView()
  6277. wrapper.translatesAutoresizingMaskIntoConstraints = false
  6278. wrapper.orientation = .horizontal
  6279. wrapper.alignment = .centerY
  6280. wrapper.spacing = 10
  6281. let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
  6282. scheduleScrollLeftButton = leftButton
  6283. wrapper.addArrangedSubview(leftButton)
  6284. let scroll = NSScrollView()
  6285. scheduleCardsScrollView = scroll
  6286. scroll.translatesAutoresizingMaskIntoConstraints = false
  6287. scroll.drawsBackground = false
  6288. scroll.hasHorizontalScroller = false
  6289. scroll.hasVerticalScroller = false
  6290. scroll.horizontalScrollElasticity = .allowed
  6291. scroll.verticalScrollElasticity = .none
  6292. scroll.autohidesScrollers = false
  6293. scroll.borderType = .noBorder
  6294. scroll.automaticallyAdjustsContentInsets = false
  6295. scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
  6296. scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
  6297. let row = NSStackView()
  6298. row.translatesAutoresizingMaskIntoConstraints = false
  6299. row.orientation = .horizontal
  6300. row.spacing = 12
  6301. row.alignment = .top
  6302. row.distribution = .gravityAreas
  6303. row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  6304. row.heightAnchor.constraint(equalToConstant: 150).isActive = true
  6305. scheduleCardsStack = row
  6306. scroll.documentView = row
  6307. scroll.contentView.postsBoundsChangedNotifications = true
  6308. // Pin top/leading/trailing only; avoid bottom == clip so the horizontal stack is not stretched vertically inside the clip (same as Schedule cards scroll).
  6309. NSLayoutConstraint.activate([
  6310. row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  6311. row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
  6312. row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  6313. row.heightAnchor.constraint(equalToConstant: 150)
  6314. ])
  6315. renderScheduleCards(into: row, meetings: meetings)
  6316. wrapper.addArrangedSubview(scroll)
  6317. let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
  6318. scheduleScrollRightButton = rightButton
  6319. wrapper.addArrangedSubview(rightButton)
  6320. scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6321. scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6322. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  6323. return wrapper
  6324. }
  6325. func scheduleCard(meeting: ScheduledMeeting, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView {
  6326. let cardWidth: CGFloat = 240
  6327. let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
  6328. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  6329. card.translatesAutoresizingMaskIntoConstraints = false
  6330. card.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true
  6331. if useFlexibleWidth {
  6332. card.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6333. card.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6334. } else {
  6335. card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  6336. card.setContentHuggingPriority(.required, for: .horizontal)
  6337. card.setContentCompressionResistancePriority(.required, for: .horizontal)
  6338. }
  6339. let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
  6340. icon.translatesAutoresizingMaskIntoConstraints = false
  6341. icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
  6342. icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
  6343. let iconView = NSImageView()
  6344. iconView.translatesAutoresizingMaskIntoConstraints = false
  6345. iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
  6346. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  6347. iconView.contentTintColor = .white
  6348. icon.addSubview(iconView)
  6349. NSLayoutConstraint.activate([
  6350. iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
  6351. iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
  6352. ])
  6353. let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
  6354. title.lineBreakMode = .byTruncatingTail
  6355. title.maximumNumberOfLines = 1
  6356. title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6357. let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
  6358. let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
  6359. let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
  6360. let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
  6361. dayChip.translatesAutoresizingMaskIntoConstraints = false
  6362. dayChip.layer?.borderWidth = 1
  6363. dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
  6364. let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
  6365. dayText.translatesAutoresizingMaskIntoConstraints = false
  6366. dayChip.addSubview(dayText)
  6367. NSLayoutConstraint.activate([
  6368. dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
  6369. dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
  6370. dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
  6371. dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
  6372. ])
  6373. card.addSubview(icon)
  6374. card.addSubview(dayChip)
  6375. card.addSubview(title)
  6376. card.addSubview(subtitle)
  6377. card.addSubview(time)
  6378. card.addSubview(duration)
  6379. var titleConstraints: [NSLayoutConstraint] = [
  6380. icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  6381. icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
  6382. dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
  6383. dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  6384. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
  6385. title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  6386. title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8)
  6387. ]
  6388. if !useFlexibleWidth {
  6389. titleConstraints.append(title.widthAnchor.constraint(lessThanOrEqualToConstant: 130))
  6390. }
  6391. NSLayoutConstraint.activate(titleConstraints + [
  6392. subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  6393. subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
  6394. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  6395. time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  6396. time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
  6397. time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  6398. duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  6399. duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
  6400. duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
  6401. ])
  6402. let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
  6403. hit.translatesAutoresizingMaskIntoConstraints = false
  6404. hit.isBordered = false
  6405. hit.bezelStyle = .regularSquare
  6406. hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
  6407. hit.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true
  6408. if useFlexibleWidth {
  6409. hit.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6410. hit.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6411. } else {
  6412. hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  6413. hit.setContentHuggingPriority(.required, for: .horizontal)
  6414. hit.setContentCompressionResistancePriority(.required, for: .horizontal)
  6415. }
  6416. hit.addSubview(card)
  6417. NSLayoutConstraint.activate([
  6418. card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
  6419. card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
  6420. card.topAnchor.constraint(equalTo: hit.topAnchor),
  6421. card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
  6422. ])
  6423. hit.onHoverChanged = { [weak self] hovering in
  6424. guard let self else { return }
  6425. let base = self.palette.sectionCard
  6426. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  6427. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  6428. if self.storeKitCoordinator.hasPremiumAccess {
  6429. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  6430. } else {
  6431. card.layer?.backgroundColor = base.cgColor
  6432. }
  6433. }
  6434. hit.onHoverChanged?(false)
  6435. if !storeKitCoordinator.hasPremiumAccess {
  6436. let lockOverlay = NSVisualEffectView()
  6437. lockOverlay.translatesAutoresizingMaskIntoConstraints = false
  6438. lockOverlay.material = darkModeEnabled ? .hudWindow : .popover
  6439. lockOverlay.blendingMode = .withinWindow
  6440. lockOverlay.state = .active
  6441. lockOverlay.wantsLayer = true
  6442. lockOverlay.layer?.cornerRadius = 12
  6443. lockOverlay.layer?.masksToBounds = true
  6444. lockOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(darkModeEnabled ? 0.28 : 0.12).cgColor
  6445. let lockLabel = textLabel("Get Premium to see events", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: darkModeEnabled ? .white : .black)
  6446. lockLabel.alignment = .center
  6447. card.addSubview(lockOverlay)
  6448. lockOverlay.addSubview(lockLabel)
  6449. NSLayoutConstraint.activate([
  6450. lockOverlay.leadingAnchor.constraint(equalTo: card.leadingAnchor),
  6451. lockOverlay.trailingAnchor.constraint(equalTo: card.trailingAnchor),
  6452. lockOverlay.topAnchor.constraint(equalTo: card.topAnchor),
  6453. lockOverlay.bottomAnchor.constraint(equalTo: card.bottomAnchor),
  6454. lockLabel.centerXAnchor.constraint(equalTo: lockOverlay.centerXAnchor),
  6455. lockLabel.centerYAnchor.constraint(equalTo: lockOverlay.centerYAnchor),
  6456. lockLabel.leadingAnchor.constraint(greaterThanOrEqualTo: lockOverlay.leadingAnchor, constant: 10),
  6457. lockLabel.trailingAnchor.constraint(lessThanOrEqualTo: lockOverlay.trailingAnchor, constant: -10)
  6458. ])
  6459. hit.toolTip = "Premium required. Click to open paywall."
  6460. }
  6461. return hit
  6462. }
  6463. private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
  6464. let button = NSButton(title: "", target: self, action: action)
  6465. button.translatesAutoresizingMaskIntoConstraints = false
  6466. button.isBordered = false
  6467. button.bezelStyle = .regularSquare
  6468. button.wantsLayer = true
  6469. button.layer?.cornerRadius = 16
  6470. button.layer?.backgroundColor = palette.inputBackground.cgColor
  6471. button.layer?.borderColor = palette.inputBorder.cgColor
  6472. button.layer?.borderWidth = 1
  6473. button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
  6474. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  6475. button.imagePosition = .imageOnly
  6476. button.imageScaling = .scaleProportionallyDown
  6477. button.contentTintColor = palette.textSecondary
  6478. button.focusRingType = .none
  6479. button.heightAnchor.constraint(equalToConstant: 32).isActive = true
  6480. button.widthAnchor.constraint(equalToConstant: 32).isActive = true
  6481. return button
  6482. }
  6483. }
  6484. private extension PremiumPlan {
  6485. var displayName: String {
  6486. switch self {
  6487. case .weekly: return "Weekly"
  6488. case .monthly: return "Monthly"
  6489. case .yearly: return "Yearly"
  6490. case .lifetime: return "Lifetime"
  6491. }
  6492. }
  6493. }
  6494. extension ViewController: NSTextFieldDelegate {
  6495. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  6496. if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  6497. browseOpenAddressClicked(nil)
  6498. return true
  6499. }
  6500. return false
  6501. }
  6502. }
  6503. extension ViewController: AVSpeechSynthesizerDelegate {
  6504. func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
  6505. DispatchQueue.main.async { [weak self] in
  6506. guard let self else { return }
  6507. guard self.aiCompanionSpeechSynthesizer === synthesizer else { return }
  6508. guard let buttonId = self.aiCompanionCurrentlySpeakingButtonId else { return }
  6509. self.aiCompanionIsUsingSpeech = false
  6510. self.aiCompanionCurrentlySpeakingButtonId = nil
  6511. if let button = self.aiCompanionCurrentlyPlayingButton, ObjectIdentifier(button) == buttonId {
  6512. button.title = "Play Audio"
  6513. }
  6514. self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Finished"
  6515. }
  6516. }
  6517. func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
  6518. DispatchQueue.main.async { [weak self] in
  6519. guard let self else { return }
  6520. guard self.aiCompanionSpeechSynthesizer === synthesizer else { return }
  6521. guard let buttonId = self.aiCompanionCurrentlySpeakingButtonId else { return }
  6522. self.aiCompanionIsUsingSpeech = false
  6523. self.aiCompanionCurrentlySpeakingButtonId = nil
  6524. if let button = self.aiCompanionCurrentlyPlayingButton, ObjectIdentifier(button) == buttonId {
  6525. button.title = "Play Audio"
  6526. }
  6527. self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Stopped"
  6528. }
  6529. }
  6530. }
  6531. extension ViewController: AVAudioPlayerDelegate {
  6532. func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
  6533. DispatchQueue.main.async { [weak self] in
  6534. guard let self else { return }
  6535. guard self.aiCompanionLocalAudioPlayer === player else { return }
  6536. self.aiCompanionAudioDidFinish()
  6537. }
  6538. }
  6539. func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
  6540. DispatchQueue.main.async { [weak self] in
  6541. guard let self else { return }
  6542. guard self.aiCompanionLocalAudioPlayer === player else { return }
  6543. if let button = self.aiCompanionCurrentlyPlayingButton {
  6544. let buttonId = ObjectIdentifier(button)
  6545. button.title = "Play Audio"
  6546. self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Failed: \(error?.localizedDescription ?? "decode error")"
  6547. }
  6548. self.aiCompanionAudioPlayer?.pause()
  6549. self.aiCompanionAudioPlayer = nil
  6550. self.aiCompanionLocalAudioPlayer?.stop()
  6551. self.aiCompanionLocalAudioPlayer = nil
  6552. self.aiCompanionCurrentlyPlayingURL = nil
  6553. self.aiCompanionCurrentlyPlayingButton = nil
  6554. self.aiCompanionTimeControlObserver = nil
  6555. }
  6556. }
  6557. }
  6558. extension ViewController: NSWindowDelegate {
  6559. func windowWillClose(_ notification: Notification) {
  6560. guard let closingWindow = notification.object as? NSWindow else { return }
  6561. if closingWindow === aiCompanionNotesPanel {
  6562. aiCompanionStopNotesOutsideClickMonitor()
  6563. aiCompanionNotesPanel = nil
  6564. aiCompanionNotesRootView = nil
  6565. aiCompanionNotesHeaderView = nil
  6566. aiCompanionNotesTitleLabel = nil
  6567. aiCompanionNotesSubtitleLabel = nil
  6568. aiCompanionNotesTextView = nil
  6569. aiCompanionNotesCopyButton = nil
  6570. aiCompanionNotesCloseButton = nil
  6571. return
  6572. }
  6573. if closingWindow === paywallWindow {
  6574. paywallWindow = nil
  6575. paywallUpgradeFlowEnabled = false
  6576. }
  6577. }
  6578. }
  6579. @available(macOS 13.0, *)
  6580. private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
  6581. private let outputURL: URL
  6582. private var stream: SCStream?
  6583. private var writer: AVAssetWriter?
  6584. private var writerInput: AVAssetWriterInput?
  6585. private let outputQueue = DispatchQueue(label: "meeting.system.audio.capture")
  6586. private var didStartSession = false
  6587. init(outputURL: URL) {
  6588. self.outputURL = outputURL
  6589. }
  6590. func start() async throws {
  6591. try? FileManager.default.removeItem(at: outputURL)
  6592. let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
  6593. guard let display = content.displays.first else {
  6594. throw NSError(domain: "MeetingSystemAudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available for system audio capture."])
  6595. }
  6596. let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
  6597. let inputSettings: [String: Any] = [
  6598. AVFormatIDKey: kAudioFormatMPEG4AAC,
  6599. AVSampleRateKey: 48_000,
  6600. AVNumberOfChannelsKey: 2,
  6601. AVEncoderBitRateKey: 128_000
  6602. ]
  6603. let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
  6604. input.expectsMediaDataInRealTime = true
  6605. guard writer.canAdd(input) else {
  6606. throw NSError(domain: "MeetingSystemAudioRecorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio writer input."])
  6607. }
  6608. writer.add(input)
  6609. self.writer = writer
  6610. self.writerInput = input
  6611. didStartSession = false
  6612. let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
  6613. let config = SCStreamConfiguration()
  6614. config.width = 2
  6615. config.height = 2
  6616. config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
  6617. config.queueDepth = 1
  6618. config.capturesAudio = true
  6619. config.sampleRate = 48_000
  6620. config.channelCount = 2
  6621. if #available(macOS 13.0, *) {
  6622. config.excludesCurrentProcessAudio = true
  6623. }
  6624. let stream = SCStream(filter: filter, configuration: config, delegate: self)
  6625. self.stream = stream
  6626. try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
  6627. try await stream.startCapture()
  6628. }
  6629. func stop() async throws {
  6630. if let stream {
  6631. try? await stream.stopCapture()
  6632. self.stream = nil
  6633. }
  6634. writerInput?.markAsFinished()
  6635. guard let writer else { return }
  6636. await withCheckedContinuation { continuation in
  6637. writer.finishWriting {
  6638. continuation.resume()
  6639. }
  6640. }
  6641. self.writer = nil
  6642. self.writerInput = nil
  6643. }
  6644. func stream(_ stream: SCStream, didStopWithError error: Error) {
  6645. // Capture may stop on permission changes or display changes; caller handles lifecycle.
  6646. }
  6647. func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
  6648. guard outputType == .audio else { return }
  6649. guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
  6650. guard let writer = writer, let input = writerInput else { return }
  6651. if writer.status == .unknown {
  6652. writer.startWriting()
  6653. }
  6654. if writer.status == .writing {
  6655. let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
  6656. if !didStartSession {
  6657. writer.startSession(atSourceTime: pts)
  6658. didStartSession = true
  6659. }
  6660. if input.isReadyForMoreMediaData {
  6661. input.append(sampleBuffer)
  6662. }
  6663. }
  6664. }
  6665. }
  6666. /// 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.
  6667. private final class TopAlignedClipView: NSClipView {
  6668. override var isFlipped: Bool { true }
  6669. }
  6670. /// Wraps the Google auth control and draws a circular accent ring with a light pulse while the signed-in avatar is hovered.
  6671. private final class GoogleProfileAuthHostView: NSView {
  6672. weak var authButton: NSButton? {
  6673. didSet { needsLayout = true }
  6674. }
  6675. private let ringLayer = CAShapeLayer()
  6676. private var avatarRingMode = false
  6677. private static let ringLineWidth: CGFloat = 2.25
  6678. override init(frame frameRect: NSRect) {
  6679. super.init(frame: frameRect)
  6680. wantsLayer = true
  6681. layer?.masksToBounds = false
  6682. ringLayer.fillColor = nil
  6683. ringLayer.strokeColor = NSColor.clear.cgColor
  6684. ringLayer.lineWidth = Self.ringLineWidth
  6685. ringLayer.lineCap = .round
  6686. ringLayer.opacity = 0
  6687. ringLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
  6688. layer?.insertSublayer(ringLayer, at: 0)
  6689. }
  6690. @available(*, unavailable)
  6691. required init?(coder: NSCoder) {
  6692. nil
  6693. }
  6694. func setAvatarRingMode(_ enabled: Bool) {
  6695. avatarRingMode = enabled
  6696. if enabled == false {
  6697. ringLayer.removeAllAnimations()
  6698. ringLayer.opacity = 0
  6699. ringLayer.lineWidth = Self.ringLineWidth
  6700. }
  6701. needsLayout = true
  6702. }
  6703. func updateRingAppearance(isDark: Bool, accent: NSColor) {
  6704. let stroke = isDark
  6705. ? accent.blended(withFraction: 0.22, of: NSColor.white) ?? accent
  6706. : accent
  6707. CATransaction.begin()
  6708. CATransaction.setDisableActions(true)
  6709. ringLayer.strokeColor = stroke.withAlphaComponent(0.95).cgColor
  6710. CATransaction.commit()
  6711. }
  6712. func setProfileHoverActive(_ active: Bool) {
  6713. guard avatarRingMode else { return }
  6714. ringLayer.removeAnimation(forKey: "pulse")
  6715. if active {
  6716. layoutRingPathIfNeeded()
  6717. CATransaction.begin()
  6718. CATransaction.setAnimationDuration(0.22)
  6719. ringLayer.opacity = 1
  6720. CATransaction.commit()
  6721. let pulse = CABasicAnimation(keyPath: "lineWidth")
  6722. pulse.fromValue = Self.ringLineWidth * 0.88
  6723. pulse.toValue = Self.ringLineWidth * 1.45
  6724. pulse.duration = 0.72
  6725. pulse.autoreverses = true
  6726. pulse.repeatCount = .infinity
  6727. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  6728. ringLayer.add(pulse, forKey: "pulse")
  6729. } else {
  6730. CATransaction.begin()
  6731. CATransaction.setAnimationDuration(0.18)
  6732. ringLayer.opacity = 0
  6733. CATransaction.commit()
  6734. ringLayer.lineWidth = Self.ringLineWidth
  6735. }
  6736. }
  6737. private func layoutRingPathIfNeeded() {
  6738. guard avatarRingMode, let btn = authButton else { return }
  6739. let f = btn.frame
  6740. guard f.width > 1, f.height > 1 else { return }
  6741. let center = CGPoint(x: f.midX, y: f.midY)
  6742. let avatarR = min(f.width, f.height) / 2
  6743. let gap: CGFloat = 3.5
  6744. let ringRadius = avatarR + gap
  6745. let d = ringRadius * 2
  6746. CATransaction.begin()
  6747. CATransaction.setDisableActions(true)
  6748. ringLayer.bounds = CGRect(x: 0, y: 0, width: d, height: d)
  6749. ringLayer.position = center
  6750. ringLayer.path = CGPath(ellipseIn: CGRect(origin: .zero, size: CGSize(width: d, height: d)), transform: nil)
  6751. CATransaction.commit()
  6752. }
  6753. override func layout() {
  6754. super.layout()
  6755. layoutRingPathIfNeeded()
  6756. }
  6757. }
  6758. /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
  6759. private class RowHitTestView: NSView {
  6760. override func hitTest(_ point: NSPoint) -> NSView? {
  6761. return bounds.contains(point) ? self : nil
  6762. }
  6763. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  6764. true
  6765. }
  6766. }
  6767. private final class HoverTrackingView: RowHitTestView {
  6768. var onHoverChanged: ((Bool) -> Void)?
  6769. var onClick: (() -> Void)?
  6770. var showsHandCursor = true
  6771. private var trackingAreaRef: NSTrackingArea?
  6772. private var isHovering = false {
  6773. didSet {
  6774. guard isHovering != oldValue else { return }
  6775. onHoverChanged?(isHovering)
  6776. }
  6777. }
  6778. override func updateTrackingAreas() {
  6779. super.updateTrackingAreas()
  6780. if let trackingAreaRef {
  6781. removeTrackingArea(trackingAreaRef)
  6782. }
  6783. let options: NSTrackingArea.Options = [
  6784. .activeInKeyWindow,
  6785. .inVisibleRect,
  6786. .mouseEnteredAndExited
  6787. ]
  6788. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  6789. addTrackingArea(area)
  6790. trackingAreaRef = area
  6791. }
  6792. override func mouseEntered(with event: NSEvent) {
  6793. super.mouseEntered(with: event)
  6794. isHovering = true
  6795. }
  6796. override func mouseExited(with event: NSEvent) {
  6797. super.mouseExited(with: event)
  6798. isHovering = false
  6799. }
  6800. override func resetCursorRects() {
  6801. super.resetCursorRects()
  6802. guard showsHandCursor else { return }
  6803. addCursorRect(bounds, cursor: .pointingHand)
  6804. }
  6805. override func mouseUp(with event: NSEvent) {
  6806. super.mouseUp(with: event)
  6807. guard event.type == .leftMouseUp else { return }
  6808. onClick?()
  6809. }
  6810. }
  6811. /// Hover tracking without overriding hit-testing; keeps controls like text fields interactive.
  6812. private final class HoverSurfaceView: NSView {
  6813. var onHoverChanged: ((Bool) -> Void)?
  6814. private var trackingAreaRef: NSTrackingArea?
  6815. private var isHovering = false {
  6816. didSet {
  6817. guard isHovering != oldValue else { return }
  6818. onHoverChanged?(isHovering)
  6819. }
  6820. }
  6821. override func updateTrackingAreas() {
  6822. super.updateTrackingAreas()
  6823. if let trackingAreaRef {
  6824. removeTrackingArea(trackingAreaRef)
  6825. }
  6826. let options: NSTrackingArea.Options = [
  6827. .activeInKeyWindow,
  6828. .inVisibleRect,
  6829. .mouseEnteredAndExited
  6830. ]
  6831. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  6832. addTrackingArea(area)
  6833. trackingAreaRef = area
  6834. }
  6835. override func mouseEntered(with event: NSEvent) {
  6836. super.mouseEntered(with: event)
  6837. isHovering = true
  6838. }
  6839. override func mouseExited(with event: NSEvent) {
  6840. super.mouseExited(with: event)
  6841. isHovering = false
  6842. }
  6843. }
  6844. private final class HoverButton: NSButton {
  6845. var onHoverChanged: ((Bool) -> Void)?
  6846. var showsHandCursor = true
  6847. private var trackingAreaRef: NSTrackingArea?
  6848. private var isHovering = false {
  6849. didSet {
  6850. guard isHovering != oldValue else { return }
  6851. onHoverChanged?(isHovering)
  6852. }
  6853. }
  6854. override func updateTrackingAreas() {
  6855. super.updateTrackingAreas()
  6856. if let trackingAreaRef {
  6857. removeTrackingArea(trackingAreaRef)
  6858. }
  6859. let options: NSTrackingArea.Options = [
  6860. .activeInKeyWindow,
  6861. .inVisibleRect,
  6862. .mouseEnteredAndExited
  6863. ]
  6864. let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  6865. addTrackingArea(tracking)
  6866. trackingAreaRef = tracking
  6867. }
  6868. override func mouseEntered(with event: NSEvent) {
  6869. super.mouseEntered(with: event)
  6870. if showsHandCursor {
  6871. NSCursor.pointingHand.set()
  6872. }
  6873. isHovering = true
  6874. }
  6875. override func mouseExited(with event: NSEvent) {
  6876. super.mouseExited(with: event)
  6877. isHovering = false
  6878. }
  6879. }
  6880. private final class HoverPopUpButton: NSPopUpButton {
  6881. var onHoverChanged: ((Bool) -> Void)?
  6882. private var trackingAreaRef: NSTrackingArea?
  6883. private var isHovering = false {
  6884. didSet {
  6885. guard isHovering != oldValue else { return }
  6886. onHoverChanged?(isHovering)
  6887. }
  6888. }
  6889. override func updateTrackingAreas() {
  6890. super.updateTrackingAreas()
  6891. if let trackingAreaRef {
  6892. removeTrackingArea(trackingAreaRef)
  6893. }
  6894. let options: NSTrackingArea.Options = [
  6895. .activeInKeyWindow,
  6896. .inVisibleRect,
  6897. .mouseEnteredAndExited
  6898. ]
  6899. let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  6900. addTrackingArea(tracking)
  6901. trackingAreaRef = tracking
  6902. }
  6903. override func mouseEntered(with event: NSEvent) {
  6904. super.mouseEntered(with: event)
  6905. isHovering = true
  6906. }
  6907. override func mouseExited(with event: NSEvent) {
  6908. super.mouseExited(with: event)
  6909. isHovering = false
  6910. }
  6911. }
  6912. private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  6913. let size = NSSize(width: diameter, height: diameter)
  6914. let result = NSImage(size: size)
  6915. result.lockFocus()
  6916. if let ctx = NSGraphicsContext.current {
  6917. ctx.imageInterpolation = .high
  6918. }
  6919. let rect = NSRect(origin: .zero, size: size)
  6920. let path = NSBezierPath(ovalIn: rect)
  6921. path.addClip()
  6922. let src = image.size.width > 0 && image.size.height > 0
  6923. ? NSRect(origin: .zero, size: image.size)
  6924. : NSRect(origin: .zero, size: size)
  6925. image.draw(in: rect, from: src, operation: .copy, fraction: 1.0)
  6926. result.unlockFocus()
  6927. result.isTemplate = false
  6928. return result
  6929. }
  6930. private final class GoogleAccountMenuViewController: NSViewController {
  6931. private let palette: Palette
  6932. private let darkModeEnabled: Bool
  6933. private let displayName: String
  6934. private let email: String
  6935. private let avatar: NSImage?
  6936. private let onSignOut: () -> Void
  6937. init(
  6938. palette: Palette,
  6939. darkModeEnabled: Bool,
  6940. displayName: String,
  6941. email: String,
  6942. avatar: NSImage?,
  6943. onSignOut: @escaping () -> Void
  6944. ) {
  6945. self.palette = palette
  6946. self.darkModeEnabled = darkModeEnabled
  6947. self.displayName = displayName
  6948. self.email = email
  6949. self.avatar = avatar
  6950. self.onSignOut = onSignOut
  6951. super.init(nibName: nil, bundle: nil)
  6952. view = makeContentView()
  6953. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  6954. preferredContentSize = NSSize(width: 300, height: 158)
  6955. }
  6956. @available(*, unavailable)
  6957. required init?(coder: NSCoder) {
  6958. nil
  6959. }
  6960. private func makeContentView() -> NSView {
  6961. let root = NSView()
  6962. root.translatesAutoresizingMaskIntoConstraints = false
  6963. let card = NSView()
  6964. card.translatesAutoresizingMaskIntoConstraints = false
  6965. card.wantsLayer = true
  6966. card.layer?.cornerRadius = 14
  6967. card.layer?.backgroundColor = palette.sectionCard.cgColor
  6968. card.layer?.borderColor = palette.inputBorder.cgColor
  6969. card.layer?.borderWidth = 1
  6970. card.layer?.shadowColor = NSColor.black.cgColor
  6971. card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2
  6972. card.layer?.shadowOffset = CGSize(width: 0, height: 6)
  6973. card.layer?.shadowRadius = 18
  6974. card.layer?.masksToBounds = false
  6975. root.addSubview(card)
  6976. NSLayoutConstraint.activate([
  6977. card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  6978. card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  6979. card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  6980. card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  6981. root.widthAnchor.constraint(equalToConstant: 300)
  6982. ])
  6983. let inner = NSStackView()
  6984. inner.translatesAutoresizingMaskIntoConstraints = false
  6985. inner.orientation = .vertical
  6986. inner.spacing = 0
  6987. inner.alignment = .leading
  6988. card.addSubview(inner)
  6989. NSLayoutConstraint.activate([
  6990. inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  6991. inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  6992. inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18),
  6993. inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
  6994. ])
  6995. let headerRow = NSView()
  6996. headerRow.translatesAutoresizingMaskIntoConstraints = false
  6997. let avatarBox = NSView()
  6998. avatarBox.translatesAutoresizingMaskIntoConstraints = false
  6999. avatarBox.wantsLayer = true
  7000. avatarBox.layer?.cornerRadius = 24
  7001. avatarBox.layer?.masksToBounds = true
  7002. avatarBox.layer?.borderColor = palette.inputBorder.cgColor
  7003. avatarBox.layer?.borderWidth = 1
  7004. let avatarView = NSImageView()
  7005. avatarView.translatesAutoresizingMaskIntoConstraints = false
  7006. avatarView.imageScaling = .scaleAxesIndependently
  7007. avatarView.image = resolvedAvatarImage()
  7008. avatarBox.addSubview(avatarView)
  7009. NSLayoutConstraint.activate([
  7010. avatarBox.widthAnchor.constraint(equalToConstant: 48),
  7011. avatarBox.heightAnchor.constraint(equalToConstant: 48),
  7012. avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor),
  7013. avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor),
  7014. avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor),
  7015. avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor)
  7016. ])
  7017. let textColumn = NSStackView()
  7018. textColumn.translatesAutoresizingMaskIntoConstraints = false
  7019. textColumn.orientation = .vertical
  7020. textColumn.spacing = 3
  7021. textColumn.alignment = .leading
  7022. let nameField = NSTextField(labelWithString: displayName)
  7023. nameField.translatesAutoresizingMaskIntoConstraints = false
  7024. nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  7025. nameField.textColor = palette.textPrimary
  7026. nameField.lineBreakMode = .byTruncatingTail
  7027. nameField.maximumNumberOfLines = 1
  7028. nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  7029. let emailField = NSTextField(labelWithString: email)
  7030. emailField.translatesAutoresizingMaskIntoConstraints = false
  7031. emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular)
  7032. emailField.textColor = palette.textTertiary
  7033. emailField.lineBreakMode = .byTruncatingTail
  7034. emailField.maximumNumberOfLines = 1
  7035. emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  7036. textColumn.addArrangedSubview(nameField)
  7037. textColumn.addArrangedSubview(emailField)
  7038. headerRow.addSubview(avatarBox)
  7039. headerRow.addSubview(textColumn)
  7040. NSLayoutConstraint.activate([
  7041. avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor),
  7042. avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor),
  7043. avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor),
  7044. textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14),
  7045. textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor),
  7046. textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor),
  7047. textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor),
  7048. textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor)
  7049. ])
  7050. inner.addArrangedSubview(headerRow)
  7051. headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  7052. inner.setCustomSpacing(14, after: headerRow)
  7053. let separator = NSView()
  7054. separator.translatesAutoresizingMaskIntoConstraints = false
  7055. separator.wantsLayer = true
  7056. separator.layer?.backgroundColor = palette.separator.cgColor
  7057. separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
  7058. inner.addArrangedSubview(separator)
  7059. separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  7060. inner.setCustomSpacing(6, after: separator)
  7061. let signOutRow = HoverTrackingView()
  7062. signOutRow.translatesAutoresizingMaskIntoConstraints = false
  7063. signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true
  7064. signOutRow.wantsLayer = true
  7065. signOutRow.layer?.cornerRadius = 10
  7066. let signOutIcon = NSImageView()
  7067. signOutIcon.translatesAutoresizingMaskIntoConstraints = false
  7068. signOutIcon.imageScaling = .scaleProportionallyDown
  7069. if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) {
  7070. signOutIcon.image = sym
  7071. signOutIcon.contentTintColor = palette.textSecondary
  7072. signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  7073. }
  7074. let signOutLabel = NSTextField(labelWithString: "Log out")
  7075. signOutLabel.translatesAutoresizingMaskIntoConstraints = false
  7076. signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
  7077. signOutLabel.textColor = palette.textPrimary
  7078. signOutRow.addSubview(signOutIcon)
  7079. signOutRow.addSubview(signOutLabel)
  7080. NSLayoutConstraint.activate([
  7081. signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10),
  7082. signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  7083. signOutIcon.widthAnchor.constraint(equalToConstant: 20),
  7084. signOutIcon.heightAnchor.constraint(equalToConstant: 20),
  7085. signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10),
  7086. signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  7087. signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10)
  7088. ])
  7089. let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked))
  7090. signOutRow.addGestureRecognizer(signOutClick)
  7091. signOutRow.onHoverChanged = { [weak self] hovering in
  7092. guard let self else { return }
  7093. signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  7094. }
  7095. signOutRow.onHoverChanged?(false)
  7096. inner.addArrangedSubview(signOutRow)
  7097. signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  7098. return root
  7099. }
  7100. private func resolvedAvatarImage() -> NSImage {
  7101. if let a = avatar {
  7102. return circularNSImage(a, diameter: 48)
  7103. }
  7104. return initialLetterAvatar()
  7105. }
  7106. private func initialLetterAvatar() -> NSImage {
  7107. let d: CGFloat = 48
  7108. let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?"
  7109. let img = NSImage(size: NSSize(width: d, height: d))
  7110. img.lockFocus()
  7111. palette.primaryBlue.setFill()
  7112. NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
  7113. let attrs: [NSAttributedString.Key: Any] = [
  7114. .font: NSFont.systemFont(ofSize: 20, weight: .semibold),
  7115. .foregroundColor: NSColor.white
  7116. ]
  7117. let sz = (letter as NSString).size(withAttributes: attrs)
  7118. let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2)
  7119. (letter as NSString).draw(at: origin, withAttributes: attrs)
  7120. img.unlockFocus()
  7121. img.isTemplate = false
  7122. return img
  7123. }
  7124. @objc private func signOutClicked() {
  7125. onSignOut()
  7126. }
  7127. }
  7128. private final class SettingsMenuViewController: NSViewController {
  7129. private let palette: Palette
  7130. private let typography: Typography
  7131. private let speechLocaleOptions: [SpeechLocaleOption]
  7132. private let selectedPrimaryLanguageIdentifier: String?
  7133. private let selectedSecondaryLanguageIdentifier: String?
  7134. private let onToggleDarkMode: (Bool) -> Void
  7135. private let onUpdatePreferredSpeechLanguages: (String, String?) -> Void
  7136. private let onAction: (SettingsAction, NSView?, NSPoint?) -> Void
  7137. private var darkToggle: NSSwitch?
  7138. private var primaryLanguagePopup: NSPopUpButton?
  7139. private var secondaryLanguagePopup: NSPopUpButton?
  7140. init(
  7141. palette: Palette,
  7142. typography: Typography,
  7143. darkModeEnabled: Bool,
  7144. showRateUsInSettings: Bool,
  7145. showUpgradeInSettings: Bool,
  7146. speechLocaleOptions: [SpeechLocaleOption],
  7147. selectedPrimaryLanguageIdentifier: String?,
  7148. selectedSecondaryLanguageIdentifier: String?,
  7149. onToggleDarkMode: @escaping (Bool) -> Void,
  7150. onUpdatePreferredSpeechLanguages: @escaping (String, String?) -> Void,
  7151. onAction: @escaping (SettingsAction, NSView?, NSPoint?) -> Void
  7152. ) {
  7153. self.palette = palette
  7154. self.typography = typography
  7155. self.speechLocaleOptions = speechLocaleOptions
  7156. self.selectedPrimaryLanguageIdentifier = selectedPrimaryLanguageIdentifier
  7157. self.selectedSecondaryLanguageIdentifier = selectedSecondaryLanguageIdentifier
  7158. self.onToggleDarkMode = onToggleDarkMode
  7159. self.onUpdatePreferredSpeechLanguages = onUpdatePreferredSpeechLanguages
  7160. self.onAction = onAction
  7161. super.init(nibName: nil, bundle: nil)
  7162. self.view = makeView(
  7163. darkModeEnabled: darkModeEnabled,
  7164. showRateUsInSettings: showRateUsInSettings,
  7165. showUpgradeInSettings: showUpgradeInSettings
  7166. )
  7167. }
  7168. @available(*, unavailable)
  7169. required init?(coder: NSCoder) {
  7170. nil
  7171. }
  7172. func setDarkModeEnabled(_ enabled: Bool) {
  7173. darkToggle?.state = enabled ? .on : .off
  7174. }
  7175. private func makeView(
  7176. darkModeEnabled: Bool,
  7177. showRateUsInSettings: Bool,
  7178. showUpgradeInSettings: Bool
  7179. ) -> NSView {
  7180. let root = NSView()
  7181. root.translatesAutoresizingMaskIntoConstraints = false
  7182. let card = roundedCard()
  7183. root.addSubview(card)
  7184. NSLayoutConstraint.activate([
  7185. card.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  7186. card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  7187. card.topAnchor.constraint(equalTo: root.topAnchor),
  7188. card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  7189. root.widthAnchor.constraint(equalToConstant: 320)
  7190. ])
  7191. let stack = NSStackView()
  7192. stack.translatesAutoresizingMaskIntoConstraints = false
  7193. stack.orientation = .vertical
  7194. stack.spacing = 6
  7195. stack.alignment = .leading
  7196. card.addSubview(stack)
  7197. NSLayoutConstraint.activate([
  7198. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  7199. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  7200. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  7201. stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14)
  7202. ])
  7203. stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
  7204. stack.addArrangedSubview(settingsSpeechLanguageRow(
  7205. title: "AI Language 1",
  7206. isPrimary: true
  7207. ))
  7208. stack.addArrangedSubview(settingsSpeechLanguageRow(
  7209. title: "AI Language 2",
  7210. isPrimary: false
  7211. ))
  7212. if showRateUsInSettings {
  7213. stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
  7214. }
  7215. stack.addArrangedSubview(settingsActionRow(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy))
  7216. stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
  7217. stack.addArrangedSubview(settingsActionRow(icon: "📄", title: "Terms of Services", action: .termsOfServices))
  7218. stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
  7219. if showUpgradeInSettings {
  7220. stack.addArrangedSubview(settingsActionRow(icon: "⬆︎", title: "Upgrade", action: .upgrade))
  7221. }
  7222. for v in stack.arrangedSubviews {
  7223. v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  7224. }
  7225. return root
  7226. }
  7227. private func roundedCard() -> NSView {
  7228. let view = NSView()
  7229. view.translatesAutoresizingMaskIntoConstraints = false
  7230. view.wantsLayer = true
  7231. view.layer?.cornerRadius = 12
  7232. view.layer?.backgroundColor = palette.sectionCard.cgColor
  7233. view.layer?.borderColor = palette.inputBorder.cgColor
  7234. view.layer?.borderWidth = 1
  7235. view.layer?.shadowColor = NSColor.black.cgColor
  7236. view.layer?.shadowOpacity = 0.28
  7237. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  7238. view.layer?.shadowRadius = 10
  7239. return view
  7240. }
  7241. private func settingsDarkModeRow(enabled: Bool) -> NSView {
  7242. let row = NSView()
  7243. row.translatesAutoresizingMaskIntoConstraints = false
  7244. row.heightAnchor.constraint(equalToConstant: 44).isActive = true
  7245. row.wantsLayer = true
  7246. row.layer?.cornerRadius = 10
  7247. let icon = NSTextField(labelWithString: "◐")
  7248. icon.translatesAutoresizingMaskIntoConstraints = false
  7249. icon.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  7250. icon.textColor = palette.textPrimary
  7251. let title = NSTextField(labelWithString: "Dark Mode")
  7252. title.translatesAutoresizingMaskIntoConstraints = false
  7253. title.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  7254. title.textColor = palette.textPrimary
  7255. let toggle = NSSwitch()
  7256. toggle.translatesAutoresizingMaskIntoConstraints = false
  7257. toggle.state = enabled ? .on : .off
  7258. toggle.target = self
  7259. toggle.action = #selector(darkModeToggled(_:))
  7260. darkToggle = toggle
  7261. row.addSubview(icon)
  7262. row.addSubview(title)
  7263. row.addSubview(toggle)
  7264. row.layer?.backgroundColor = NSColor.clear.cgColor
  7265. NSLayoutConstraint.activate([
  7266. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  7267. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  7268. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  7269. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  7270. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
  7271. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  7272. ])
  7273. return row
  7274. }
  7275. private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView {
  7276. let row = HoverButton(title: "", target: self, action: #selector(settingsActionButtonPressed(_:)))
  7277. row.tag = action.rawValue
  7278. row.isBordered = false
  7279. row.translatesAutoresizingMaskIntoConstraints = false
  7280. row.heightAnchor.constraint(equalToConstant: 42).isActive = true
  7281. row.wantsLayer = true
  7282. row.layer?.cornerRadius = 10
  7283. row.layer?.backgroundColor = NSColor.clear.cgColor
  7284. let iconLabel = NSTextField(labelWithString: icon)
  7285. iconLabel.translatesAutoresizingMaskIntoConstraints = false
  7286. iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  7287. iconLabel.textColor = palette.textPrimary
  7288. let titleLabel = NSTextField(labelWithString: title)
  7289. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  7290. titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  7291. titleLabel.textColor = palette.textPrimary
  7292. row.addSubview(iconLabel)
  7293. row.addSubview(titleLabel)
  7294. NSLayoutConstraint.activate([
  7295. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  7296. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  7297. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  7298. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  7299. ])
  7300. row.onHoverChanged = { hovering in
  7301. row.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  7302. }
  7303. row.onHoverChanged?(false)
  7304. return row
  7305. }
  7306. private func settingsSpeechLanguageRow(title: String, isPrimary: Bool) -> NSView {
  7307. let row = NSView()
  7308. row.translatesAutoresizingMaskIntoConstraints = false
  7309. row.heightAnchor.constraint(equalToConstant: 62).isActive = true
  7310. row.wantsLayer = true
  7311. row.layer?.cornerRadius = 10
  7312. row.layer?.backgroundColor = NSColor.clear.cgColor
  7313. let titleLabel = NSTextField(labelWithString: title)
  7314. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  7315. titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
  7316. titleLabel.textColor = palette.textPrimary
  7317. let popup = NSPopUpButton(frame: .zero, pullsDown: false)
  7318. popup.translatesAutoresizingMaskIntoConstraints = false
  7319. popup.target = self
  7320. popup.action = #selector(speechLanguageChanged(_:))
  7321. if isPrimary {
  7322. for option in speechLocaleOptions {
  7323. popup.addItem(withTitle: option.displayName)
  7324. popup.lastItem?.representedObject = option.identifier
  7325. }
  7326. let preferred = selectedPrimaryLanguageIdentifier ?? speechLocaleOptions.first?.identifier
  7327. if let preferred {
  7328. selectLocale(identifier: preferred, in: popup)
  7329. }
  7330. primaryLanguagePopup = popup
  7331. } else {
  7332. popup.addItem(withTitle: "None")
  7333. popup.lastItem?.representedObject = ""
  7334. for option in speechLocaleOptions {
  7335. popup.addItem(withTitle: option.displayName)
  7336. popup.lastItem?.representedObject = option.identifier
  7337. }
  7338. if let secondary = selectedSecondaryLanguageIdentifier,
  7339. secondary.isEmpty == false {
  7340. selectLocale(identifier: secondary, in: popup)
  7341. } else {
  7342. popup.selectItem(at: 0)
  7343. }
  7344. secondaryLanguagePopup = popup
  7345. }
  7346. row.addSubview(titleLabel)
  7347. row.addSubview(popup)
  7348. NSLayoutConstraint.activate([
  7349. titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  7350. titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 6),
  7351. titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -4),
  7352. popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  7353. popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
  7354. popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6),
  7355. popup.heightAnchor.constraint(equalToConstant: 28)
  7356. ])
  7357. return row
  7358. }
  7359. private func selectLocale(identifier: String, in popup: NSPopUpButton) {
  7360. for item in popup.itemArray {
  7361. if let represented = item.representedObject as? String, represented == identifier {
  7362. popup.select(item)
  7363. return
  7364. }
  7365. }
  7366. }
  7367. @objc private func speechLanguageChanged(_ sender: NSPopUpButton) {
  7368. guard let primary = primaryLanguagePopup?.selectedItem?.representedObject as? String,
  7369. primary.isEmpty == false else { return }
  7370. var normalizedSecondary: String? = secondaryLanguagePopup?.selectedItem?.representedObject as? String
  7371. if normalizedSecondary?.isEmpty == true {
  7372. normalizedSecondary = nil
  7373. }
  7374. if let secondaryValue = normalizedSecondary,
  7375. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  7376. normalizedSecondary = nil
  7377. secondaryLanguagePopup?.selectItem(at: 0)
  7378. }
  7379. onUpdatePreferredSpeechLanguages(primary, normalizedSecondary)
  7380. }
  7381. @objc private func darkModeToggled(_ sender: NSSwitch) {
  7382. onToggleDarkMode(sender.state == .on)
  7383. }
  7384. @objc private func settingsActionButtonPressed(_ sender: NSButton) {
  7385. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  7386. let clickPoint: NSPoint?
  7387. if let event = NSApp.currentEvent {
  7388. let pointInWindow = event.locationInWindow
  7389. clickPoint = sender.convert(pointInWindow, from: nil)
  7390. } else {
  7391. clickPoint = nil
  7392. }
  7393. onAction(action, sender, clickPoint)
  7394. }
  7395. }
  7396. private extension ViewController {
  7397. private func hasGoogleSessionAvailable() -> Bool {
  7398. guard let tokens = googleOAuth.loadTokens() else { return false }
  7399. if tokens.expiresAt.timeIntervalSinceNow > 60 {
  7400. return true
  7401. }
  7402. return (tokens.refreshToken?.isEmpty == false)
  7403. }
  7404. private func requireGoogleLoginForCalendarScheduling() -> Bool {
  7405. guard hasGoogleSessionAvailable() else {
  7406. showSimpleAlert(
  7407. title: "Connect Google",
  7408. message: "Sign in with Google first to schedule a meeting from Calendar."
  7409. )
  7410. return false
  7411. }
  7412. return true
  7413. }
  7414. func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  7415. let view = NSView()
  7416. view.wantsLayer = true
  7417. view.layer?.backgroundColor = color.cgColor
  7418. view.layer?.cornerRadius = cornerRadius
  7419. return view
  7420. }
  7421. func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  7422. view.layer?.borderColor = borderColor.cgColor
  7423. view.layer?.borderWidth = borderWidth
  7424. if shadow {
  7425. view.layer?.shadowColor = NSColor.black.cgColor
  7426. view.layer?.shadowOpacity = 0.18
  7427. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  7428. view.layer?.shadowRadius = 5
  7429. }
  7430. }
  7431. func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  7432. let label = NSTextField(labelWithString: text)
  7433. label.translatesAutoresizingMaskIntoConstraints = false
  7434. label.textColor = color
  7435. label.font = font
  7436. return label
  7437. }
  7438. func iconLabel(_ text: String, size: CGFloat) -> NSTextField {
  7439. let label = NSTextField(labelWithString: text)
  7440. label.translatesAutoresizingMaskIntoConstraints = false
  7441. label.font = NSFont.systemFont(ofSize: size)
  7442. return label
  7443. }
  7444. func sidebarSectionTitle(_ text: String) -> NSTextField {
  7445. let sectionColor = darkModeEnabled ? palette.textMuted : NSColor(calibratedWhite: 0.14, alpha: 1.0)
  7446. let field = textLabel(text, font: typography.sidebarSection, color: sectionColor)
  7447. field.alignment = .left
  7448. return field
  7449. }
  7450. 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 {
  7451. let item = HoverButton(title: "", target: self, action: #selector(sidebarButtonClicked(_:)))
  7452. item.tag = page.rawValue
  7453. item.isBordered = false
  7454. item.wantsLayer = true
  7455. item.layer?.cornerRadius = 10
  7456. item.layer?.backgroundColor = NSColor.clear.cgColor
  7457. item.translatesAutoresizingMaskIntoConstraints = false
  7458. item.heightAnchor.constraint(equalToConstant: 36).isActive = true
  7459. item.layer?.borderWidth = 0
  7460. sidebarPageByView[ObjectIdentifier(item)] = page
  7461. let leadingView: NSView
  7462. if let name = logoImageName, let logo = NSImage(named: name) {
  7463. logo.isTemplate = true
  7464. let imageView = NSImageView(image: logo)
  7465. imageView.translatesAutoresizingMaskIntoConstraints = false
  7466. imageView.imageScaling = .scaleProportionallyDown
  7467. imageView.imageAlignment = .alignCenter
  7468. imageView.isEditable = false
  7469. leadingView = imageView
  7470. } else if let symbolName = systemSymbolName, let symbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: text) {
  7471. symbol.isTemplate = true
  7472. let imageView = NSImageView(image: symbol)
  7473. imageView.translatesAutoresizingMaskIntoConstraints = false
  7474. imageView.imageScaling = .scaleProportionallyDown
  7475. imageView.imageAlignment = .alignCenter
  7476. imageView.isEditable = false
  7477. leadingView = imageView
  7478. } else {
  7479. leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary)
  7480. }
  7481. let baseSidebarTextColor = darkModeEnabled ? palette.textSecondary : NSColor(calibratedWhite: 0.08, alpha: 1.0)
  7482. let titleLabel = textLabel(text, font: typography.sidebarItem, color: baseSidebarTextColor)
  7483. titleLabel.alignment = .left
  7484. item.addSubview(leadingView)
  7485. item.addSubview(titleLabel)
  7486. var constraints: [NSLayoutConstraint] = [
  7487. leadingView.leadingAnchor.constraint(equalTo: item.leadingAnchor, constant: 12),
  7488. leadingView.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  7489. titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8),
  7490. titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor)
  7491. ]
  7492. if showsDisclosure {
  7493. let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: baseSidebarTextColor)
  7494. chevron.translatesAutoresizingMaskIntoConstraints = false
  7495. chevron.alignment = .right
  7496. item.addSubview(chevron)
  7497. constraints.append(contentsOf: [
  7498. chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10),
  7499. chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  7500. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8)
  7501. ])
  7502. } else {
  7503. constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10))
  7504. }
  7505. if logoImageName != nil || systemSymbolName != nil {
  7506. let h = logoIconWidth * logoHeightMultiplier
  7507. constraints.append(contentsOf: [
  7508. leadingView.widthAnchor.constraint(equalToConstant: logoIconWidth),
  7509. leadingView.heightAnchor.constraint(equalToConstant: h)
  7510. ])
  7511. }
  7512. NSLayoutConstraint.activate(constraints)
  7513. applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate)
  7514. item.onHoverChanged = { [weak self, weak item] hovering in
  7515. guard let self, let item else { return }
  7516. self.applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate, hovering: hovering)
  7517. }
  7518. return item
  7519. }
  7520. func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool, hovering: Bool = false) {
  7521. let selected = (page == selectedSidebarPage)
  7522. let hoverColor = darkModeEnabled ? NSColor(calibratedWhite: 1, alpha: 0.07) : NSColor(calibratedWhite: 0, alpha: 0.08)
  7523. item.layer?.backgroundColor = (selected ? palette.primaryBlue : (hovering ? hoverColor : NSColor.clear)).cgColor
  7524. let baseSidebarTextColor = darkModeEnabled ? palette.textSecondary : NSColor(calibratedWhite: 0.08, alpha: 1.0)
  7525. let tint = selected ? NSColor.white : baseSidebarTextColor
  7526. let sidebarIconTint = darkModeEnabled ? tint : baseSidebarTextColor
  7527. guard item.subviews.count >= 2 else { return }
  7528. let leading = item.subviews[0]
  7529. let title = item.subviews.first { $0 is NSTextField } as? NSTextField
  7530. title?.textColor = tint
  7531. // Optional disclosure chevron (if present) is the last text field.
  7532. if let chevron = item.subviews.last as? NSTextField, chevron !== title {
  7533. chevron.textColor = sidebarIconTint
  7534. }
  7535. if let imageView = leading as? NSImageView {
  7536. if logoTemplate {
  7537. imageView.contentTintColor = sidebarIconTint
  7538. }
  7539. } else if let iconField = leading as? NSTextField {
  7540. iconField.textColor = sidebarIconTint
  7541. }
  7542. }
  7543. func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView {
  7544. let button = HoverTrackingView()
  7545. button.wantsLayer = true
  7546. button.layer?.cornerRadius = 9
  7547. button.layer?.backgroundColor = color.cgColor
  7548. button.translatesAutoresizingMaskIntoConstraints = false
  7549. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  7550. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  7551. styleSurface(button, borderColor: title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  7552. if title == "Cancel" {
  7553. button.layer?.backgroundColor = palette.cancelButton.cgColor
  7554. }
  7555. let label = textLabel(title, font: typography.buttonText, color: textColor)
  7556. button.addSubview(label)
  7557. NSLayoutConstraint.activate([
  7558. label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  7559. label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  7560. ])
  7561. let baseColor = (title == "Cancel") ? palette.cancelButton : color
  7562. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  7563. let hoverColor = baseColor.blended(withFraction: 0.12, of: hoverBlend) ?? baseColor
  7564. button.onHoverChanged = { hovering in
  7565. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  7566. }
  7567. button.onHoverChanged?(false)
  7568. return button
  7569. }
  7570. func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
  7571. let button = HoverTrackingView()
  7572. button.wantsLayer = true
  7573. button.layer?.cornerRadius = size / 2
  7574. button.layer?.backgroundColor = palette.inputBackground.cgColor
  7575. button.translatesAutoresizingMaskIntoConstraints = false
  7576. button.widthAnchor.constraint(equalToConstant: size).isActive = true
  7577. button.heightAnchor.constraint(equalToConstant: size).isActive = true
  7578. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  7579. let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
  7580. let iconView = NSImageView()
  7581. iconView.translatesAutoresizingMaskIntoConstraints = false
  7582. iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
  7583. iconView.symbolConfiguration = symbolConfig
  7584. iconView.contentTintColor = palette.textSecondary
  7585. button.addSubview(iconView)
  7586. NSLayoutConstraint.activate([
  7587. iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  7588. iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  7589. ])
  7590. let baseColor = palette.inputBackground
  7591. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  7592. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  7593. button.onHoverChanged = { hovering in
  7594. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  7595. }
  7596. button.onHoverChanged?(false)
  7597. button.onClick = onClick
  7598. return button
  7599. }
  7600. }
  7601. private let calendarDayKeyFormatter: DateFormatter = {
  7602. let f = DateFormatter()
  7603. f.calendar = Calendar(identifier: .gregorian)
  7604. f.locale = Locale(identifier: "en_US_POSIX")
  7605. f.timeZone = TimeZone.current
  7606. f.dateFormat = "yyyy-MM-dd"
  7607. return f
  7608. }()
  7609. // MARK: - Calendar page actions + rendering
  7610. private extension ViewController {
  7611. private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton {
  7612. let button = makeSchedulePillButton(title: title)
  7613. button.target = self
  7614. button.action = action
  7615. button.heightAnchor.constraint(equalToConstant: 30).isActive = true
  7616. return button
  7617. }
  7618. private func calendarStartOfMonth(for date: Date) -> Date {
  7619. let calendar = Calendar.current
  7620. let comps = calendar.dateComponents([.year, .month], from: date)
  7621. return calendar.date(from: comps) ?? calendar.startOfDay(for: date)
  7622. }
  7623. private func calendarMonthTitleText(for monthAnchor: Date) -> String {
  7624. let f = DateFormatter()
  7625. f.locale = Locale.current
  7626. f.timeZone = TimeZone.current
  7627. f.dateFormat = "MMMM yyyy"
  7628. return f.string(from: monthAnchor)
  7629. }
  7630. private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] {
  7631. // Align weekday header to Calendar.current.firstWeekday
  7632. let calendar = Calendar.current
  7633. var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"]
  7634. // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday.
  7635. let first = max(1, min(7, calendar.firstWeekday)) // 1..7
  7636. let shift = (first - 1) % 7
  7637. if shift == 0 { return symbols }
  7638. let head = Array(symbols[shift...])
  7639. let tail = Array(symbols[..<shift])
  7640. symbols = head + tail
  7641. return symbols
  7642. }
  7643. @objc func calendarPrevMonthPressed(_ sender: NSButton) {
  7644. let calendar = Calendar.current
  7645. calendarPageMonthAnchor = calendar.date(byAdding: .month, value: -1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
  7646. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  7647. renderCalendarMonthGrid()
  7648. }
  7649. @objc func calendarNextMonthPressed(_ sender: NSButton) {
  7650. let calendar = Calendar.current
  7651. calendarPageMonthAnchor = calendar.date(byAdding: .month, value: 1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
  7652. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  7653. renderCalendarMonthGrid()
  7654. }
  7655. @objc func calendarRefreshPressed(_ sender: NSButton) {
  7656. Task { [weak self] in
  7657. await self?.loadSchedule()
  7658. }
  7659. }
  7660. @objc func calendarDayCellPressed(_ sender: NSButton) {
  7661. guard requireGoogleLoginForCalendarScheduling() else { return }
  7662. guard let raw = sender.identifier?.rawValue,
  7663. let date = calendarDayKeyFormatter.date(from: raw) else { return }
  7664. calendarPageSelectedDate = Calendar.current.startOfDay(for: date)
  7665. renderCalendarMonthGrid()
  7666. renderCalendarSelectedDay()
  7667. if let refreshedButton = calendarButton(forDateKey: raw) {
  7668. showCalendarDayActionPopover(relativeTo: refreshedButton)
  7669. }
  7670. }
  7671. private func showCalendarDayActionPopover(relativeTo anchor: NSView) {
  7672. guard anchor.window != nil else { return }
  7673. calendarPageActionPopover?.performClose(nil)
  7674. let popover = NSPopover()
  7675. popover.behavior = .transient
  7676. popover.animates = true
  7677. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  7678. popover.contentViewController = CalendarDayActionMenuViewController(
  7679. palette: palette,
  7680. onSchedule: { [weak self] in
  7681. self?.calendarPageActionPopover?.performClose(nil)
  7682. self?.calendarPageActionPopover = nil
  7683. self?.presentCreateMeetingPopover(relativeTo: anchor)
  7684. }
  7685. )
  7686. calendarPageActionPopover = popover
  7687. popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
  7688. }
  7689. private func presentCreateMeetingPopover(relativeTo anchor: NSView) {
  7690. guard anchor.window != nil else { return }
  7691. calendarPageCreatePopover?.performClose(nil)
  7692. let popover = NSPopover()
  7693. popover.behavior = .transient
  7694. popover.animates = true
  7695. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  7696. let selectedDate = Calendar.current.startOfDay(for: calendarPageSelectedDate)
  7697. let vc = CreateMeetingPopoverViewController(
  7698. palette: palette,
  7699. typography: typography,
  7700. selectedDate: selectedDate,
  7701. onCancel: { [weak self] in
  7702. self?.calendarPageCreatePopover?.performClose(nil)
  7703. self?.calendarPageCreatePopover = nil
  7704. },
  7705. onSave: { [weak self] draft in
  7706. guard let self else { return }
  7707. self.calendarPageCreatePopover?.performClose(nil)
  7708. self.calendarPageCreatePopover = nil
  7709. self.calendarCreateMeeting(title: draft.title, notes: draft.notes, start: draft.startDate, end: draft.endDate)
  7710. }
  7711. )
  7712. popover.contentViewController = vc
  7713. calendarPageCreatePopover = popover
  7714. popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
  7715. }
  7716. private func calendarCreateMeeting(title: String, notes: String?, start: Date, end: Date) {
  7717. Task { [weak self] in
  7718. guard let self else { return }
  7719. do {
  7720. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  7721. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  7722. _ = try await self.calendarClient.createEvent(
  7723. accessToken: token,
  7724. title: title,
  7725. description: notes,
  7726. start: start,
  7727. end: end,
  7728. timeZone: .current
  7729. )
  7730. await self.loadSchedule()
  7731. await MainActor.run {
  7732. let calendar = Calendar.current
  7733. self.calendarPageSelectedDate = calendar.startOfDay(for: start)
  7734. self.calendarPageMonthAnchor = self.calendarStartOfMonth(for: start)
  7735. self.calendarPageMonthLabel?.stringValue = self.calendarMonthTitleText(for: self.calendarPageMonthAnchor)
  7736. self.renderCalendarMonthGrid()
  7737. self.renderCalendarSelectedDay()
  7738. self.showTopToast(message: "Meeting added successfully.", isError: false)
  7739. }
  7740. } catch {
  7741. await MainActor.run {
  7742. self.showTopToast(message: "An issue occurred. Please try again.", isError: true)
  7743. }
  7744. }
  7745. }
  7746. }
  7747. private func renderCalendarMonthGrid() {
  7748. guard let gridStack = calendarPageGridStack else { return }
  7749. gridStack.arrangedSubviews.forEach { v in
  7750. gridStack.removeArrangedSubview(v)
  7751. v.removeFromSuperview()
  7752. }
  7753. let calendar = Calendar.current
  7754. let monthStart = calendarStartOfMonth(for: calendarPageMonthAnchor)
  7755. guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart),
  7756. let monthEnd = calendar.date(byAdding: DateComponents(month: 1, day: 0), to: monthStart) else { return }
  7757. let firstWeekday = calendar.component(.weekday, from: monthStart) // 1..7
  7758. let leadingEmpty = (firstWeekday - calendar.firstWeekday + 7) % 7
  7759. let totalDays = dayRange.count
  7760. let totalCells = leadingEmpty + totalDays
  7761. let rowCount = Int(ceil(Double(totalCells) / 7.0))
  7762. let rowHeight: CGFloat = 56
  7763. let rowSpacing: CGFloat = 12
  7764. let verticalPadding: CGFloat = 32
  7765. calendarPageGridHeightConstraint?.constant = verticalPadding + (CGFloat(rowCount) * rowHeight) + (CGFloat(max(0, rowCount - 1)) * rowSpacing)
  7766. let meetingCounts = calendarMeetingCountsByDay(from: scheduleCachedMeetings, monthStart: monthStart, monthEnd: monthEnd)
  7767. var day = 1
  7768. for _ in 0..<rowCount {
  7769. let row = NSStackView()
  7770. row.translatesAutoresizingMaskIntoConstraints = false
  7771. row.userInterfaceLayoutDirection = .leftToRight
  7772. row.orientation = .horizontal
  7773. row.alignment = .top
  7774. row.distribution = .fillEqually
  7775. row.spacing = rowSpacing
  7776. row.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true
  7777. for col in 0..<7 {
  7778. let cellIndex = (gridStack.arrangedSubviews.count * 7) + col
  7779. if cellIndex < leadingEmpty || day > totalDays {
  7780. row.addArrangedSubview(calendarEmptyDayCell())
  7781. continue
  7782. }
  7783. guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else {
  7784. row.addArrangedSubview(calendarEmptyDayCell())
  7785. continue
  7786. }
  7787. let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate)
  7788. let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date))
  7789. let count = meetingCounts[key] ?? 0
  7790. row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected))
  7791. day += 1
  7792. }
  7793. gridStack.addArrangedSubview(row)
  7794. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  7795. }
  7796. }
  7797. private func calendarButton(forDateKey key: String) -> NSButton? {
  7798. guard let gridStack = calendarPageGridStack else { return nil }
  7799. for rowView in gridStack.arrangedSubviews {
  7800. guard let row = rowView as? NSStackView else { continue }
  7801. for cell in row.arrangedSubviews {
  7802. if let button = cell as? NSButton, button.identifier?.rawValue == key {
  7803. return button
  7804. }
  7805. }
  7806. }
  7807. return nil
  7808. }
  7809. private func renderCalendarSelectedDay() {
  7810. let calendar = Calendar.current
  7811. let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate)
  7812. let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400)
  7813. let meetings = scheduleCachedMeetings
  7814. .filter { $0.startDate >= selectedDay && $0.startDate < nextDay }
  7815. .sorted(by: { $0.startDate < $1.startDate })
  7816. let f = DateFormatter()
  7817. f.locale = Locale.current
  7818. f.timeZone = TimeZone.current
  7819. f.dateFormat = "EEE, d MMM"
  7820. if meetings.isEmpty {
  7821. calendarPageDaySummaryLabel?.stringValue = hasGoogleSessionAvailable() == false
  7822. ? "Connect Google to see meetings"
  7823. : "No meetings on \(f.string(from: selectedDay))"
  7824. } else if meetings.count == 1 {
  7825. calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))"
  7826. } else {
  7827. calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))"
  7828. }
  7829. }
  7830. private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] {
  7831. let calendar = Calendar.current
  7832. var counts: [String: Int] = [:]
  7833. for meeting in meetings {
  7834. guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue }
  7835. let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate))
  7836. counts[key, default: 0] += 1
  7837. }
  7838. return counts
  7839. }
  7840. private func calendarEmptyDayCell() -> NSView {
  7841. let v = NSView()
  7842. v.translatesAutoresizingMaskIntoConstraints = false
  7843. v.heightAnchor.constraint(equalToConstant: 56).isActive = true
  7844. return v
  7845. }
  7846. private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton {
  7847. let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:)))
  7848. button.translatesAutoresizingMaskIntoConstraints = false
  7849. button.isBordered = false
  7850. button.bezelStyle = .regularSquare
  7851. button.wantsLayer = true
  7852. button.layer?.cornerRadius = 12
  7853. button.layer?.masksToBounds = true
  7854. button.identifier = NSUserInterfaceItemIdentifier(dateKey)
  7855. button.heightAnchor.constraint(equalToConstant: 56).isActive = true
  7856. button.setContentHuggingPriority(.required, for: .vertical)
  7857. button.setContentCompressionResistancePriority(.required, for: .vertical)
  7858. button.alignment = .left
  7859. button.imagePosition = .noImage
  7860. let base = palette.inputBackground
  7861. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  7862. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  7863. let selectedBackground = darkModeEnabled
  7864. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  7865. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  7866. let borderIdle = palette.inputBorder
  7867. let borderSelected = palette.primaryBlueBorder
  7868. func applyAppearance(hovering: Bool) {
  7869. button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor
  7870. button.layer?.borderWidth = isSelected ? 1.5 : 1
  7871. button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor
  7872. }
  7873. applyAppearance(hovering: false)
  7874. button.onHoverChanged = { hovering in
  7875. applyAppearance(hovering: hovering)
  7876. }
  7877. button.attributedTitle = NSAttributedString(
  7878. string: " \(dayNumber)",
  7879. attributes: [
  7880. .font: NSFont.systemFont(ofSize: 14, weight: .bold),
  7881. .foregroundColor: palette.textPrimary
  7882. ]
  7883. )
  7884. if meetingCount > 0 {
  7885. let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge)
  7886. dot.translatesAutoresizingMaskIntoConstraints = false
  7887. dot.layer?.borderWidth = 0
  7888. button.addSubview(dot)
  7889. NSLayoutConstraint.activate([
  7890. dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10),
  7891. dot.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  7892. dot.widthAnchor.constraint(equalToConstant: 8),
  7893. dot.heightAnchor.constraint(equalToConstant: 8)
  7894. ])
  7895. }
  7896. return button
  7897. }
  7898. }
  7899. private final class CalendarDayActionMenuViewController: NSViewController {
  7900. private let palette: Palette
  7901. private let onSchedule: () -> Void
  7902. init(palette: Palette, onSchedule: @escaping () -> Void) {
  7903. self.palette = palette
  7904. self.onSchedule = onSchedule
  7905. super.init(nibName: nil, bundle: nil)
  7906. }
  7907. required init?(coder: NSCoder) { nil }
  7908. override func loadView() {
  7909. let root = NSView()
  7910. root.translatesAutoresizingMaskIntoConstraints = false
  7911. let stack = NSStackView()
  7912. stack.translatesAutoresizingMaskIntoConstraints = false
  7913. stack.orientation = .vertical
  7914. stack.alignment = .leading
  7915. stack.spacing = 10
  7916. let title = NSTextField(labelWithString: "Actions")
  7917. title.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  7918. title.textColor = palette.textMuted
  7919. let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:)))
  7920. schedule.bezelStyle = .rounded
  7921. schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium)
  7922. stack.addArrangedSubview(title)
  7923. stack.addArrangedSubview(schedule)
  7924. root.addSubview(stack)
  7925. NSLayoutConstraint.activate([
  7926. root.widthAnchor.constraint(equalToConstant: 220),
  7927. root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86),
  7928. stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
  7929. stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
  7930. stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  7931. stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12)
  7932. ])
  7933. view = root
  7934. }
  7935. @objc private func schedulePressed(_ sender: NSButton) {
  7936. onSchedule()
  7937. }
  7938. }
  7939. private final class CreateMeetingPopoverViewController: NSViewController, NSTextViewDelegate {
  7940. struct Draft {
  7941. let title: String
  7942. let notes: String?
  7943. let startDate: Date
  7944. let endDate: Date
  7945. }
  7946. private let palette: Palette
  7947. private let typography: Typography
  7948. private let selectedDate: Date
  7949. private let onCancel: () -> Void
  7950. private let onSave: (Draft) -> Void
  7951. private var titleField: NSTextField?
  7952. private var timePicker: NSDatePicker?
  7953. private var durationField: NSTextField?
  7954. private var notesView: NSTextView?
  7955. private var notesScrollView: NSScrollView?
  7956. private var errorLabel: NSTextField?
  7957. private var notesBorderIdle = NSColor.clear
  7958. private var notesBorderHover = NSColor.clear
  7959. private var notesBorderFocused = NSColor.clear
  7960. private var notesIsHovered = false
  7961. private var notesIsFocused = false
  7962. init(palette: Palette,
  7963. typography: Typography,
  7964. selectedDate: Date,
  7965. onCancel: @escaping () -> Void,
  7966. onSave: @escaping (Draft) -> Void) {
  7967. self.palette = palette
  7968. self.typography = typography
  7969. self.selectedDate = selectedDate
  7970. self.onCancel = onCancel
  7971. self.onSave = onSave
  7972. super.init(nibName: nil, bundle: nil)
  7973. }
  7974. required init?(coder: NSCoder) { nil }
  7975. override func loadView() {
  7976. let root = NSView()
  7977. root.translatesAutoresizingMaskIntoConstraints = false
  7978. root.userInterfaceLayoutDirection = .leftToRight
  7979. root.wantsLayer = true
  7980. root.layer?.cornerRadius = 14
  7981. root.layer?.masksToBounds = true
  7982. root.layer?.backgroundColor = palette.sectionCard.cgColor
  7983. root.layer?.borderWidth = 1
  7984. root.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.9).cgColor
  7985. let stack = NSStackView()
  7986. stack.translatesAutoresizingMaskIntoConstraints = false
  7987. stack.orientation = .vertical
  7988. stack.alignment = .leading
  7989. stack.spacing = 14
  7990. stack.userInterfaceLayoutDirection = .leftToRight
  7991. let inputSurface = palette.inputBackground.blended(withFraction: 0.18, of: palette.sectionCard) ?? palette.inputBackground
  7992. let fieldBorder = palette.textSecondary.withAlphaComponent(0.4)
  7993. notesBorderIdle = fieldBorder
  7994. notesBorderHover = palette.textSecondary.withAlphaComponent(0.72)
  7995. notesBorderFocused = palette.primaryBlueBorder
  7996. let header = NSTextField(labelWithString: "Schedule meeting")
  7997. header.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  7998. header.textColor = palette.textPrimary
  7999. header.alignment = .left
  8000. header.userInterfaceLayoutDirection = .leftToRight
  8001. header.baseWritingDirection = .leftToRight
  8002. let titleLabel = NSTextField(labelWithString: "Title")
  8003. titleLabel.font = typography.fieldLabel
  8004. titleLabel.textColor = palette.textSecondary
  8005. titleLabel.alignment = .left
  8006. titleLabel.userInterfaceLayoutDirection = .leftToRight
  8007. titleLabel.baseWritingDirection = .leftToRight
  8008. let titleShell = NSView()
  8009. titleShell.translatesAutoresizingMaskIntoConstraints = false
  8010. titleShell.wantsLayer = true
  8011. titleShell.layer?.cornerRadius = 8
  8012. titleShell.layer?.backgroundColor = inputSurface.cgColor
  8013. titleShell.layer?.borderColor = fieldBorder.cgColor
  8014. titleShell.layer?.borderWidth = 1.2
  8015. titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true
  8016. let titleField = NSTextField(string: "")
  8017. titleField.translatesAutoresizingMaskIntoConstraints = false
  8018. titleField.isBordered = false
  8019. titleField.drawsBackground = false
  8020. titleField.focusRingType = .none
  8021. titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular)
  8022. titleField.textColor = palette.textPrimary
  8023. titleField.placeholderString = "Team sync"
  8024. titleShell.addSubview(titleField)
  8025. NSLayoutConstraint.activate([
  8026. titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10),
  8027. titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10),
  8028. titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor)
  8029. ])
  8030. self.titleField = titleField
  8031. let timeRow = NSStackView()
  8032. timeRow.translatesAutoresizingMaskIntoConstraints = false
  8033. timeRow.orientation = .horizontal
  8034. timeRow.alignment = .centerY
  8035. timeRow.spacing = 10
  8036. timeRow.distribution = .fill
  8037. let startLabel = NSTextField(labelWithString: "Start")
  8038. startLabel.font = typography.fieldLabel
  8039. startLabel.textColor = palette.textSecondary
  8040. startLabel.alignment = .left
  8041. startLabel.userInterfaceLayoutDirection = .leftToRight
  8042. startLabel.baseWritingDirection = .leftToRight
  8043. let pickerShell = NSView()
  8044. pickerShell.translatesAutoresizingMaskIntoConstraints = false
  8045. pickerShell.wantsLayer = true
  8046. pickerShell.layer?.cornerRadius = 8
  8047. pickerShell.layer?.backgroundColor = inputSurface.cgColor
  8048. pickerShell.layer?.borderColor = fieldBorder.cgColor
  8049. pickerShell.layer?.borderWidth = 1.2
  8050. pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
  8051. let timePicker = NSDatePicker()
  8052. timePicker.translatesAutoresizingMaskIntoConstraints = false
  8053. timePicker.isBordered = false
  8054. timePicker.drawsBackground = false
  8055. timePicker.focusRingType = .none
  8056. timePicker.datePickerStyle = .textFieldAndStepper
  8057. timePicker.datePickerElements = [.hourMinute]
  8058. timePicker.font = typography.filterText
  8059. timePicker.textColor = palette.textSecondary
  8060. timePicker.dateValue = Date()
  8061. pickerShell.addSubview(timePicker)
  8062. NSLayoutConstraint.activate([
  8063. timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8),
  8064. timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8),
  8065. timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor)
  8066. ])
  8067. self.timePicker = timePicker
  8068. let durationLabel = NSTextField(labelWithString: "Duration (min)")
  8069. durationLabel.font = typography.fieldLabel
  8070. durationLabel.textColor = palette.textSecondary
  8071. durationLabel.alignment = .left
  8072. durationLabel.userInterfaceLayoutDirection = .leftToRight
  8073. durationLabel.baseWritingDirection = .leftToRight
  8074. let durationShell = NSView()
  8075. durationShell.translatesAutoresizingMaskIntoConstraints = false
  8076. durationShell.wantsLayer = true
  8077. durationShell.layer?.cornerRadius = 8
  8078. durationShell.layer?.backgroundColor = inputSurface.cgColor
  8079. durationShell.layer?.borderColor = fieldBorder.cgColor
  8080. durationShell.layer?.borderWidth = 1.2
  8081. durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
  8082. let durationField = NSTextField(string: "30")
  8083. durationField.translatesAutoresizingMaskIntoConstraints = false
  8084. durationField.isBordered = false
  8085. durationField.drawsBackground = false
  8086. durationField.focusRingType = .none
  8087. durationField.font = typography.filterText
  8088. durationField.textColor = palette.textSecondary
  8089. durationField.formatter = NumberFormatter()
  8090. durationShell.addSubview(durationField)
  8091. NSLayoutConstraint.activate([
  8092. durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8),
  8093. durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8),
  8094. durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor)
  8095. ])
  8096. self.durationField = durationField
  8097. let startGroup = NSStackView(views: [startLabel, pickerShell])
  8098. startGroup.translatesAutoresizingMaskIntoConstraints = false
  8099. startGroup.orientation = .vertical
  8100. startGroup.alignment = .leading
  8101. startGroup.spacing = 6
  8102. let durationGroup = NSStackView(views: [durationLabel, durationShell])
  8103. durationGroup.translatesAutoresizingMaskIntoConstraints = false
  8104. durationGroup.orientation = .vertical
  8105. durationGroup.alignment = .leading
  8106. durationGroup.spacing = 6
  8107. timeRow.addArrangedSubview(startGroup)
  8108. timeRow.addArrangedSubview(durationGroup)
  8109. startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true
  8110. let notesLabel = NSTextField(labelWithString: "Notes")
  8111. notesLabel.font = typography.fieldLabel
  8112. notesLabel.textColor = palette.textSecondary
  8113. notesLabel.alignment = .left
  8114. notesLabel.userInterfaceLayoutDirection = .leftToRight
  8115. notesLabel.baseWritingDirection = .leftToRight
  8116. let notesScroll = HoverFocusScrollView()
  8117. notesScroll.translatesAutoresizingMaskIntoConstraints = false
  8118. notesScroll.drawsBackground = true
  8119. notesScroll.backgroundColor = inputSurface
  8120. notesScroll.hasVerticalScroller = true
  8121. notesScroll.hasHorizontalScroller = false
  8122. notesScroll.borderType = .noBorder
  8123. notesScroll.wantsLayer = true
  8124. notesScroll.layer?.cornerRadius = 8
  8125. notesScroll.layer?.masksToBounds = true
  8126. notesScroll.layer?.borderWidth = 1.2
  8127. notesScroll.layer?.borderColor = notesBorderIdle.cgColor
  8128. notesScroll.heightAnchor.constraint(equalToConstant: 100).isActive = true
  8129. notesScroll.onHoverChanged = { [weak self] hovering in
  8130. guard let self else { return }
  8131. self.notesIsHovered = hovering
  8132. self.updateNotesBorderAppearance()
  8133. }
  8134. notesScroll.onMouseDown = { [weak self] in
  8135. guard let self, let notesView = self.notesView as? ImmediateFocusTextView else { return }
  8136. self.view.window?.makeFirstResponder(notesView)
  8137. notesView.ensureCaretVisibleImmediately()
  8138. self.notesIsFocused = true
  8139. self.updateNotesBorderAppearance()
  8140. }
  8141. let notesView = ImmediateFocusTextView(frame: .zero)
  8142. notesView.drawsBackground = false
  8143. notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  8144. notesView.textColor = palette.textPrimary
  8145. notesView.insertionPointColor = palette.textPrimary
  8146. notesView.isEditable = true
  8147. notesView.isSelectable = true
  8148. notesView.isRichText = false
  8149. notesView.importsGraphics = false
  8150. notesView.isHorizontallyResizable = false
  8151. notesView.isVerticallyResizable = true
  8152. notesView.autoresizingMask = [.width]
  8153. notesView.textContainerInset = NSSize(width: 6, height: 6)
  8154. notesView.textContainer?.widthTracksTextView = true
  8155. notesView.textContainer?.containerSize = NSSize(
  8156. width: notesScroll.contentSize.width,
  8157. height: CGFloat.greatestFiniteMagnitude
  8158. )
  8159. notesView.delegate = self
  8160. notesScroll.documentView = notesView
  8161. self.notesView = notesView
  8162. self.notesScrollView = notesScroll
  8163. let error = NSTextField(labelWithString: "")
  8164. error.translatesAutoresizingMaskIntoConstraints = false
  8165. error.textColor = NSColor.systemRed
  8166. error.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  8167. error.isHidden = true
  8168. self.errorLabel = error
  8169. let actions = NSStackView()
  8170. actions.translatesAutoresizingMaskIntoConstraints = false
  8171. actions.orientation = .horizontal
  8172. actions.alignment = .centerY
  8173. actions.spacing = 10
  8174. let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:)))
  8175. cancel.bezelStyle = .rounded
  8176. let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:)))
  8177. save.bezelStyle = .rounded
  8178. save.keyEquivalent = "\r"
  8179. let actionsSpacer = NSView()
  8180. actionsSpacer.translatesAutoresizingMaskIntoConstraints = false
  8181. actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  8182. actions.addArrangedSubview(cancel)
  8183. actions.addArrangedSubview(actionsSpacer)
  8184. actions.addArrangedSubview(save)
  8185. stack.addArrangedSubview(header)
  8186. stack.addArrangedSubview(titleLabel)
  8187. stack.addArrangedSubview(titleShell)
  8188. stack.addArrangedSubview(timeRow)
  8189. stack.addArrangedSubview(notesLabel)
  8190. stack.addArrangedSubview(notesScroll)
  8191. stack.addArrangedSubview(error)
  8192. stack.addArrangedSubview(actions)
  8193. root.addSubview(stack)
  8194. NSLayoutConstraint.activate([
  8195. root.widthAnchor.constraint(equalToConstant: 372),
  8196. stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  8197. stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  8198. stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 16),
  8199. stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16),
  8200. titleShell.widthAnchor.constraint(equalTo: stack.widthAnchor),
  8201. timeRow.widthAnchor.constraint(equalTo: stack.widthAnchor),
  8202. notesScroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
  8203. error.widthAnchor.constraint(equalTo: stack.widthAnchor),
  8204. actions.widthAnchor.constraint(equalTo: stack.widthAnchor)
  8205. ])
  8206. view = root
  8207. }
  8208. @objc private func cancelPressed(_ sender: NSButton) {
  8209. onCancel()
  8210. }
  8211. @objc private func savePressed(_ sender: NSButton) {
  8212. setError(nil)
  8213. let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  8214. if title.isEmpty {
  8215. setError("Please enter a title.")
  8216. return
  8217. }
  8218. let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  8219. let durationMinutes = Int(durationText) ?? 0
  8220. if durationMinutes <= 0 {
  8221. setError("Duration must be a positive number of minutes.")
  8222. return
  8223. }
  8224. let time = timePicker?.dateValue ?? Date()
  8225. let calendar = Calendar.current
  8226. let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate)
  8227. let timeParts = calendar.dateComponents([.hour, .minute], from: time)
  8228. var merged = DateComponents()
  8229. merged.year = dateParts.year
  8230. merged.month = dateParts.month
  8231. merged.day = dateParts.day
  8232. merged.hour = timeParts.hour
  8233. merged.minute = timeParts.minute
  8234. guard let start = calendar.date(from: merged) else {
  8235. setError("Invalid start time.")
  8236. return
  8237. }
  8238. let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60))
  8239. let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines)
  8240. let cleanedNotes = (notes?.isEmpty == false) ? notes : nil
  8241. onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end))
  8242. }
  8243. private func updateNotesBorderAppearance() {
  8244. let color: NSColor
  8245. let width: CGFloat
  8246. if notesIsFocused {
  8247. color = notesBorderFocused
  8248. width = 1.6
  8249. } else if notesIsHovered {
  8250. color = notesBorderHover
  8251. width = 1.4
  8252. } else {
  8253. color = notesBorderIdle
  8254. width = 1.2
  8255. }
  8256. notesScrollView?.layer?.borderColor = color.cgColor
  8257. notesScrollView?.layer?.borderWidth = width
  8258. }
  8259. private func setError(_ message: String?) {
  8260. guard let errorLabel else { return }
  8261. errorLabel.stringValue = message ?? ""
  8262. errorLabel.isHidden = message == nil
  8263. }
  8264. func textDidBeginEditing(_ notification: Notification) {
  8265. guard let current = notification.object as? NSTextView, current === notesView else { return }
  8266. notesIsFocused = true
  8267. updateNotesBorderAppearance()
  8268. }
  8269. func textDidEndEditing(_ notification: Notification) {
  8270. guard let current = notification.object as? NSTextView, current === notesView else { return }
  8271. notesIsFocused = false
  8272. updateNotesBorderAppearance()
  8273. }
  8274. }
  8275. private final class HoverFocusScrollView: NSScrollView {
  8276. var onHoverChanged: ((Bool) -> Void)?
  8277. var onMouseDown: (() -> Void)?
  8278. private var hoverTrackingArea: NSTrackingArea?
  8279. override func updateTrackingAreas() {
  8280. super.updateTrackingAreas()
  8281. if let hoverTrackingArea {
  8282. removeTrackingArea(hoverTrackingArea)
  8283. }
  8284. let area = NSTrackingArea(
  8285. rect: bounds,
  8286. options: [.activeInActiveApp, .inVisibleRect, .mouseEnteredAndExited],
  8287. owner: self,
  8288. userInfo: nil
  8289. )
  8290. addTrackingArea(area)
  8291. hoverTrackingArea = area
  8292. }
  8293. override func mouseEntered(with event: NSEvent) {
  8294. super.mouseEntered(with: event)
  8295. onHoverChanged?(true)
  8296. }
  8297. override func mouseExited(with event: NSEvent) {
  8298. super.mouseExited(with: event)
  8299. onHoverChanged?(false)
  8300. }
  8301. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  8302. true
  8303. }
  8304. override func mouseDown(with event: NSEvent) {
  8305. onMouseDown?()
  8306. if let window, !window.isKeyWindow {
  8307. window.makeKeyAndOrderFront(nil)
  8308. return
  8309. }
  8310. // Forward the click straight to the text view so caret placement/blink starts immediately.
  8311. if let textView = documentView as? NSTextView {
  8312. window?.makeFirstResponder(textView)
  8313. textView.mouseDown(with: event)
  8314. return
  8315. }
  8316. super.mouseDown(with: event)
  8317. }
  8318. }
  8319. private final class ImmediateFocusTextView: NSTextView {
  8320. override var acceptsFirstResponder: Bool { true }
  8321. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  8322. true
  8323. }
  8324. @discardableResult
  8325. override func becomeFirstResponder() -> Bool {
  8326. let accepted = super.becomeFirstResponder()
  8327. if accepted {
  8328. ensureCaretVisibleImmediately()
  8329. }
  8330. return accepted
  8331. }
  8332. override func mouseDown(with event: NSEvent) {
  8333. window?.makeFirstResponder(self)
  8334. super.mouseDown(with: event)
  8335. ensureCaretVisibleImmediately()
  8336. }
  8337. func ensureCaretVisibleImmediately() {
  8338. var range = selectedRange()
  8339. if range.location == NSNotFound {
  8340. range = NSRange(location: string.utf16.count, length: 0)
  8341. }
  8342. setSelectedRange(range)
  8343. scrollRangeToVisible(range)
  8344. needsDisplay = true
  8345. displayIfNeeded()
  8346. }
  8347. }
  8348. // MARK: - Schedule actions (OAuth entry)
  8349. private extension ViewController {
  8350. @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
  8351. scheduleReloadClicked()
  8352. }
  8353. @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
  8354. scrollScheduleCards(direction: -1)
  8355. }
  8356. @objc func scheduleScrollRightPressed(_ sender: NSButton) {
  8357. scrollScheduleCards(direction: 1)
  8358. }
  8359. @objc func scheduleCardButtonPressed(_ sender: NSButton) {
  8360. guard storeKitCoordinator.hasPremiumAccess else {
  8361. showPaywall()
  8362. return
  8363. }
  8364. guard let raw = sender.identifier?.rawValue,
  8365. let url = URL(string: raw) else { return }
  8366. let meeting = scheduleCachedMeetings.first(where: { $0.meetURL.absoluteString == raw })
  8367. openMeetingURL(url, meeting: meeting)
  8368. }
  8369. @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
  8370. scheduleConnectClicked()
  8371. }
  8372. @objc func schedulePageLoadMorePressed(_ sender: NSButton) {
  8373. appendSchedulePageBatchIfNeeded()
  8374. }
  8375. private func scheduleInitialHeadingText() -> String {
  8376. hasGoogleSessionAvailable() ? "Loading…" : "Connect Google to see meetings"
  8377. }
  8378. private func schedulePageInitialHeadingText() -> String {
  8379. hasGoogleSessionAvailable() ? "Loading schedule…" : "Connect Google to see meetings"
  8380. }
  8381. @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
  8382. guard let selectedItem = sender.selectedItem,
  8383. let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
  8384. applyScheduleFilter(filter)
  8385. }
  8386. private func applyScheduleFilter(_ filter: ScheduleFilter) {
  8387. scheduleFilter = filter
  8388. scheduleFilterDropdown?.selectItem(at: filter.rawValue)
  8389. Task { [weak self] in
  8390. await self?.loadSchedule()
  8391. }
  8392. }
  8393. @objc func schedulePageFilterDropdownChanged(_ sender: NSPopUpButton) {
  8394. guard let selectedItem = sender.selectedItem,
  8395. let filter = SchedulePageFilter(rawValue: selectedItem.tag) else { return }
  8396. schedulePageFilter = filter
  8397. refreshSchedulePageDateFilterUI()
  8398. applySchedulePageFiltersAndRender()
  8399. }
  8400. @objc func schedulePageDatePickerChanged(_ sender: NSDatePicker) {
  8401. schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
  8402. schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
  8403. }
  8404. @objc func schedulePageApplyDateRangePressed(_ sender: NSButton) {
  8405. if let selectedItem = schedulePageFilterDropdown?.selectedItem,
  8406. let selectedFilter = SchedulePageFilter(rawValue: selectedItem.tag) {
  8407. schedulePageFilter = selectedFilter
  8408. }
  8409. refreshSchedulePageDateFilterUI()
  8410. applySchedulePageFiltersAndRender()
  8411. }
  8412. @objc func schedulePageResetFiltersPressed(_ sender: NSButton) {
  8413. schedulePageFilter = .all
  8414. schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.all.rawValue)
  8415. let today = Calendar.current.startOfDay(for: Date())
  8416. schedulePageFromDate = today
  8417. schedulePageToDate = today
  8418. schedulePageFromDatePicker?.dateValue = today
  8419. schedulePageToDatePicker?.dateValue = today
  8420. refreshSchedulePageDateFilterUI()
  8421. applySchedulePageFiltersAndRender()
  8422. }
  8423. @objc private func aiCompanionRecordingsFilterChanged(_ sender: NSPopUpButton) {
  8424. guard let selectedItem = sender.selectedItem,
  8425. let filter = AiCompanionRecordingsFilter(rawValue: selectedItem.tag) else { return }
  8426. aiCompanionRecordingsFilter = filter
  8427. if filter != .customRange {
  8428. setAiCompanionRecordingsRangeError(nil)
  8429. }
  8430. refreshAiCompanionRecordingsFilterChrome()
  8431. redrawAiCompanionPageIfNeeded()
  8432. }
  8433. @objc private func aiCompanionFilterDatePickerChanged(_ sender: NSDatePicker) {
  8434. aiCompanionFilterFromDate = aiCompanionFilterFromDatePicker?.dateValue ?? aiCompanionFilterFromDate
  8435. aiCompanionFilterToDate = aiCompanionFilterToDatePicker?.dateValue ?? aiCompanionFilterToDate
  8436. }
  8437. @objc private func aiCompanionRecordingsApplyPressed(_ sender: NSButton) {
  8438. aiCompanionFilterFromDate = aiCompanionFilterFromDatePicker?.dateValue ?? aiCompanionFilterFromDate
  8439. aiCompanionFilterToDate = aiCompanionFilterToDatePicker?.dateValue ?? aiCompanionFilterToDate
  8440. if let selectedItem = aiCompanionRecordingsFilterDropdown?.selectedItem,
  8441. let filter = AiCompanionRecordingsFilter(rawValue: selectedItem.tag) {
  8442. aiCompanionRecordingsFilter = filter
  8443. }
  8444. if aiCompanionRecordingsFilter == .customRange && !aiCompanionHasValidCustomFilterRange() {
  8445. setAiCompanionRecordingsRangeError("Start date must be on or before end date.")
  8446. } else {
  8447. setAiCompanionRecordingsRangeError(nil)
  8448. }
  8449. refreshAiCompanionRecordingsFilterChrome()
  8450. redrawAiCompanionPageIfNeeded()
  8451. }
  8452. @objc private func aiCompanionRecordingsResetPressed(_ sender: NSButton) {
  8453. aiCompanionRecordingsFilter = .today
  8454. aiCompanionRecordingsFilterDropdown?.selectItem(at: AiCompanionRecordingsFilter.today.rawValue)
  8455. let today = Calendar.current.startOfDay(for: Date())
  8456. aiCompanionFilterFromDate = today
  8457. aiCompanionFilterToDate = today
  8458. aiCompanionFilterFromDatePicker?.dateValue = today
  8459. aiCompanionFilterToDatePicker?.dateValue = today
  8460. setAiCompanionRecordingsRangeError(nil)
  8461. refreshAiCompanionRecordingsFilterChrome()
  8462. redrawAiCompanionPageIfNeeded()
  8463. }
  8464. @objc private func aiCompanionRecordingsRefreshPressed(_ sender: NSButton) {
  8465. loadAiCompanionLocalRecordings()
  8466. redrawAiCompanionPageIfNeeded()
  8467. }
  8468. @objc func schedulePageAddMeetingPressed(_ sender: NSButton) {
  8469. guard requireGoogleLoginForCalendarScheduling() else { return }
  8470. let accessory = NSView(frame: NSRect(x: 0, y: 0, width: 230, height: 28))
  8471. let datePicker = NSDatePicker(frame: accessory.bounds)
  8472. datePicker.autoresizingMask = [.width, .height]
  8473. datePicker.datePickerMode = .single
  8474. datePicker.datePickerStyle = .textFieldAndStepper
  8475. datePicker.datePickerElements = [.yearMonthDay]
  8476. datePicker.dateValue = Calendar.current.startOfDay(for: Date())
  8477. accessory.addSubview(datePicker)
  8478. let alert = NSAlert()
  8479. alert.messageText = "Select date"
  8480. alert.informativeText = "Choose a date to schedule your meeting."
  8481. alert.alertStyle = .informational
  8482. alert.accessoryView = accessory
  8483. alert.addButton(withTitle: "Continue")
  8484. alert.addButton(withTitle: "Cancel")
  8485. guard alert.runModal() == .alertFirstButtonReturn else { return }
  8486. calendarPageSelectedDate = Calendar.current.startOfDay(for: datePicker.dateValue)
  8487. presentCreateMeetingPopover(relativeTo: sender)
  8488. }
  8489. private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
  8490. if meeting.isAllDay { return "All day" }
  8491. let f = DateFormatter()
  8492. f.locale = Locale.current
  8493. f.timeZone = TimeZone.current
  8494. f.dateStyle = .none
  8495. f.timeStyle = .short
  8496. return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
  8497. }
  8498. private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
  8499. let f = DateFormatter()
  8500. f.locale = Locale.current
  8501. f.timeZone = TimeZone.current
  8502. f.dateFormat = "EEE, d MMM"
  8503. return f.string(from: meeting.startDate)
  8504. }
  8505. private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
  8506. if meeting.isAllDay { return "Duration: all day" }
  8507. let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
  8508. let totalMinutes = Int(duration / 60)
  8509. let hours = totalMinutes / 60
  8510. let minutes = totalMinutes % 60
  8511. if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
  8512. if hours > 0 { return "Duration: \(hours)h" }
  8513. return "Duration: \(minutes)m"
  8514. }
  8515. private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
  8516. guard let first = meetings.first else {
  8517. return hasGoogleSessionAvailable() ? "No upcoming meetings" : "Connect Google to see meetings"
  8518. }
  8519. let day = Calendar.current.startOfDay(for: first.startDate)
  8520. let f = DateFormatter()
  8521. f.locale = Locale.current
  8522. f.timeZone = TimeZone.current
  8523. f.dateFormat = "EEEE, d MMM"
  8524. return f.string(from: day)
  8525. }
  8526. private func openMeetingURL(_ url: URL, meeting: ScheduledMeeting? = nil) {
  8527. let title = meeting?.title ?? "Scheduled Meeting"
  8528. let shouldOpenMeeting = beginMeetingRecordingIfConsented(meetingTitle: title, meetingURL: url)
  8529. guard shouldOpenMeeting else { return }
  8530. openInDefaultBrowser(url: url)
  8531. }
  8532. private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
  8533. displayedScheduleMeetings = meetings
  8534. let shouldShowScrollControls = meetings.count > 3
  8535. scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
  8536. scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
  8537. scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
  8538. if let scroll = scheduleCardsScrollView {
  8539. scroll.reflectScrolledClipView(scroll.contentView)
  8540. }
  8541. stack.arrangedSubviews.forEach { v in
  8542. stack.removeArrangedSubview(v)
  8543. v.removeFromSuperview()
  8544. }
  8545. if meetings.isEmpty {
  8546. let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
  8547. empty.translatesAutoresizingMaskIntoConstraints = false
  8548. empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
  8549. empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
  8550. styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  8551. let label = textLabel(hasGoogleSessionAvailable() ? "No meetings" : "Connect to load schedule", font: typography.cardSubtitle, color: palette.textSecondary)
  8552. label.translatesAutoresizingMaskIntoConstraints = false
  8553. empty.addSubview(label)
  8554. NSLayoutConstraint.activate([
  8555. label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
  8556. label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
  8557. ])
  8558. stack.addArrangedSubview(empty)
  8559. return
  8560. }
  8561. for meeting in meetings {
  8562. stack.addArrangedSubview(scheduleCard(meeting: meeting))
  8563. }
  8564. }
  8565. private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
  8566. let now = Date()
  8567. switch scheduleFilter {
  8568. case .all:
  8569. return meetings.filter { $0.endDate >= now }
  8570. case .today:
  8571. let start = Calendar.current.startOfDay(for: now)
  8572. let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
  8573. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  8574. case .week:
  8575. let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
  8576. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  8577. }
  8578. }
  8579. private func filteredMeetingsForSchedulePage(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
  8580. let calendar = Calendar.current
  8581. let now = Date()
  8582. switch schedulePageFilter {
  8583. case .all:
  8584. return meetings.filter { $0.endDate >= now }
  8585. case .today:
  8586. let start = calendar.startOfDay(for: now)
  8587. let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
  8588. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  8589. case .week:
  8590. let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
  8591. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  8592. case .month:
  8593. let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400)
  8594. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  8595. case .customRange:
  8596. let start = calendar.startOfDay(for: schedulePageFromDate)
  8597. let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate)
  8598. guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else {
  8599. return meetings
  8600. }
  8601. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  8602. }
  8603. }
  8604. private func refreshSchedulePageDateFilterUI() {
  8605. let isCustom = schedulePageFilter == .customRange
  8606. schedulePageFromDatePicker?.isEnabled = isCustom
  8607. schedulePageToDatePicker?.isEnabled = isCustom
  8608. let dim: CGFloat = isCustom ? 1.0 : 0.65
  8609. schedulePageFromDatePicker?.alphaValue = 1
  8610. schedulePageToDatePicker?.alphaValue = 1
  8611. schedulePageFromDatePicker?.superview?.alphaValue = dim
  8612. schedulePageToDatePicker?.superview?.alphaValue = dim
  8613. }
  8614. private func schedulePageHasValidCustomRange() -> Bool {
  8615. let start = Calendar.current.startOfDay(for: schedulePageFromDate)
  8616. let end = Calendar.current.startOfDay(for: schedulePageToDate)
  8617. return start <= end
  8618. }
  8619. private func setSchedulePageRangeError(_ message: String?) {
  8620. guard let label = schedulePageRangeErrorLabel else { return }
  8621. label.stringValue = message ?? ""
  8622. label.isHidden = message == nil
  8623. }
  8624. private func applySchedulePageFiltersAndRender() {
  8625. schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
  8626. schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
  8627. if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() {
  8628. setSchedulePageRangeError("Start date must be on or before end date.")
  8629. schedulePageFilteredMeetings = []
  8630. schedulePageVisibleCount = 0
  8631. renderSchedulePageCards()
  8632. schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range"
  8633. return
  8634. }
  8635. setSchedulePageRangeError(nil)
  8636. schedulePageFilteredMeetings = filteredMeetingsForSchedulePage(scheduleCachedMeetings)
  8637. schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredMeetings.count)
  8638. renderSchedulePageCards()
  8639. schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredMeetings)
  8640. }
  8641. private func appendSchedulePageBatchIfNeeded() {
  8642. guard schedulePageVisibleCount < schedulePageFilteredMeetings.count else { return }
  8643. let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredMeetings.count)
  8644. guard nextCount > schedulePageVisibleCount else { return }
  8645. schedulePageVisibleCount = nextCount
  8646. renderSchedulePageCards()
  8647. // If we're still near the bottom after adding a batch (large viewport),
  8648. // immediately evaluate again so pagination keeps advancing in chunks of 6.
  8649. DispatchQueue.main.async { [weak self] in
  8650. self?.schedulePageScrolled()
  8651. }
  8652. }
  8653. private func schedulePageScrolled() {
  8654. guard let scroll = schedulePageCardsScrollView else { return }
  8655. let contentBounds = scroll.contentView.bounds
  8656. let contentHeight = scroll.documentView?.bounds.height ?? 0
  8657. guard contentHeight > 0 else { return }
  8658. let remaining = contentHeight - (contentBounds.origin.y + contentBounds.height)
  8659. if remaining <= 200 {
  8660. appendSchedulePageBatchIfNeeded()
  8661. }
  8662. }
  8663. private func renderSchedulePageCards() {
  8664. guard let stack = schedulePageCardsStack else { return }
  8665. stack.arrangedSubviews.forEach { v in
  8666. stack.removeArrangedSubview(v)
  8667. v.removeFromSuperview()
  8668. }
  8669. let visibleMeetings = Array(schedulePageFilteredMeetings.prefix(schedulePageVisibleCount))
  8670. if visibleMeetings.isEmpty {
  8671. let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
  8672. empty.translatesAutoresizingMaskIntoConstraints = false
  8673. empty.heightAnchor.constraint(equalToConstant: 140).isActive = true
  8674. styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  8675. let label = textLabel(hasGoogleSessionAvailable() ? "No meetings for selected filters" : "Connect to load schedule", font: typography.cardSubtitle, color: palette.textSecondary)
  8676. label.translatesAutoresizingMaskIntoConstraints = false
  8677. empty.addSubview(label)
  8678. NSLayoutConstraint.activate([
  8679. label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
  8680. label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
  8681. ])
  8682. stack.addArrangedSubview(empty)
  8683. empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  8684. return
  8685. }
  8686. var index = 0
  8687. while index < visibleMeetings.count {
  8688. let row = NSStackView()
  8689. row.translatesAutoresizingMaskIntoConstraints = false
  8690. row.userInterfaceLayoutDirection = .leftToRight
  8691. row.orientation = .horizontal
  8692. row.alignment = .top
  8693. row.spacing = schedulePageCardSpacing
  8694. row.distribution = .fillEqually
  8695. let rowEnd = min(index + schedulePageCardsPerRow, visibleMeetings.count)
  8696. for meeting in visibleMeetings[index..<rowEnd] {
  8697. row.addArrangedSubview(scheduleCard(meeting: meeting, useFlexibleWidth: true, contentHeight: schedulePageCardHeight))
  8698. }
  8699. stack.addArrangedSubview(row)
  8700. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  8701. index = rowEnd
  8702. }
  8703. if schedulePageVisibleCount < schedulePageFilteredMeetings.count {
  8704. let pagination = NSStackView()
  8705. pagination.translatesAutoresizingMaskIntoConstraints = false
  8706. pagination.orientation = .horizontal
  8707. pagination.alignment = .centerY
  8708. pagination.spacing = 10
  8709. let moreLabel = textLabel(
  8710. "Showing \(schedulePageVisibleCount) of \(schedulePageFilteredMeetings.count)",
  8711. font: NSFont.systemFont(ofSize: 12, weight: .medium),
  8712. color: palette.textMuted
  8713. )
  8714. pagination.addArrangedSubview(moreLabel)
  8715. let spacer = NSView()
  8716. spacer.translatesAutoresizingMaskIntoConstraints = false
  8717. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  8718. pagination.addArrangedSubview(spacer)
  8719. stack.addArrangedSubview(pagination)
  8720. pagination.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  8721. }
  8722. }
  8723. private func scrollScheduleCards(direction: Int) {
  8724. guard let scroll = scheduleCardsScrollView else { return }
  8725. let contentBounds = scroll.contentView.bounds
  8726. let step = max(220, contentBounds.width * 0.7)
  8727. let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
  8728. let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
  8729. let nextX = min(max(0, proposedX), maxX)
  8730. scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
  8731. scroll.reflectScrolledClipView(scroll.contentView)
  8732. }
  8733. private func loadSchedule() async {
  8734. do {
  8735. if hasGoogleSessionAvailable() == false {
  8736. await MainActor.run {
  8737. updateGoogleAuthButtonTitle()
  8738. applyGoogleProfile(nil)
  8739. scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  8740. schedulePageDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  8741. if let stack = scheduleCardsStack {
  8742. renderScheduleCards(into: stack, meetings: [])
  8743. }
  8744. scheduleCachedMeetings = []
  8745. pageCache[.aiCompanion] = nil
  8746. publishWidgetMeetingsSnapshot(from: [])
  8747. DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
  8748. MeetingReminderManager.shared.cancelAllReminders()
  8749. applySchedulePageFiltersAndRender()
  8750. if calendarPageGridStack != nil {
  8751. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  8752. renderCalendarMonthGrid()
  8753. renderCalendarSelectedDay()
  8754. }
  8755. }
  8756. return
  8757. }
  8758. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  8759. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  8760. let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
  8761. let filtered = filteredMeetings(meetings)
  8762. await MainActor.run {
  8763. updateGoogleAuthButtonTitle()
  8764. applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  8765. scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
  8766. if let stack = scheduleCardsStack {
  8767. renderScheduleCards(into: stack, meetings: filtered)
  8768. }
  8769. scheduleCachedMeetings = meetings
  8770. pageCache[.aiCompanion] = nil
  8771. publishWidgetMeetingsSnapshot(from: filtered)
  8772. DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
  8773. if storeKitCoordinator.hasPremiumAccess {
  8774. MeetingReminderManager.shared.scheduleReminders(for: meetings)
  8775. }
  8776. applySchedulePageFiltersAndRender()
  8777. if calendarPageGridStack != nil {
  8778. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  8779. renderCalendarMonthGrid()
  8780. renderCalendarSelectedDay()
  8781. }
  8782. }
  8783. } catch {
  8784. await MainActor.run {
  8785. updateGoogleAuthButtonTitle()
  8786. if hasGoogleSessionAvailable() == false {
  8787. applyGoogleProfile(nil)
  8788. }
  8789. scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
  8790. schedulePageDateHeadingLabel?.stringValue = "Couldn’t load schedule"
  8791. if let stack = scheduleCardsStack {
  8792. renderScheduleCards(into: stack, meetings: [])
  8793. }
  8794. scheduleCachedMeetings = []
  8795. pageCache[.aiCompanion] = nil
  8796. publishWidgetMeetingsSnapshot(from: [])
  8797. DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
  8798. MeetingReminderManager.shared.cancelAllReminders()
  8799. applySchedulePageFiltersAndRender()
  8800. if calendarPageGridStack != nil {
  8801. calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
  8802. renderCalendarMonthGrid()
  8803. renderCalendarSelectedDay()
  8804. }
  8805. showSimpleError("Couldn’t load schedule.", error: error)
  8806. }
  8807. }
  8808. }
  8809. private func publishWidgetMeetingsSnapshot(from meetings: [ScheduledMeeting]) {
  8810. let formatter = DateFormatter()
  8811. formatter.dateFormat = "EEE, h:mm a"
  8812. formatter.locale = Locale.current
  8813. let payload = meetings.prefix(widgetSnapshotLimit).map { meeting in
  8814. let endText = DateFormatter.localizedString(from: meeting.endDate, dateStyle: .none, timeStyle: .short)
  8815. return WidgetMeetingSnapshot(
  8816. id: meeting.id,
  8817. title: meeting.title,
  8818. timeText: "\(formatter.string(from: meeting.startDate)) - \(endText)",
  8819. joinLink: meeting.meetURL.absoluteString
  8820. )
  8821. }
  8822. if let data = try? JSONEncoder().encode(payload) {
  8823. UserDefaults.standard.set(data, forKey: WidgetMeetingStore.key)
  8824. } else {
  8825. UserDefaults.standard.removeObject(forKey: WidgetMeetingStore.key)
  8826. }
  8827. NotificationCenter.default.post(name: .meetingsSnapshotUpdated, object: nil)
  8828. }
  8829. func showScheduleHelp() {
  8830. let alert = NSAlert()
  8831. alert.messageText = "Google schedule"
  8832. alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
  8833. alert.addButton(withTitle: "OK")
  8834. alert.runModal()
  8835. }
  8836. func scheduleReloadClicked() {
  8837. Task { [weak self] in
  8838. guard let self else { return }
  8839. do {
  8840. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  8841. _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  8842. await MainActor.run {
  8843. self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
  8844. self.pageCache[.joinMeetings] = nil
  8845. self.pageCache[.photo] = nil
  8846. self.pageCache[.widgets] = nil
  8847. self.pageCache[.aiCompanion] = nil
  8848. self.showSidebarPage(self.selectedSidebarPage)
  8849. }
  8850. await self.loadSchedule()
  8851. } catch {
  8852. await MainActor.run {
  8853. self.showSimpleError("Couldn’t refresh schedule.", error: error)
  8854. }
  8855. }
  8856. }
  8857. }
  8858. func scheduleConnectClicked() {
  8859. Task { [weak self] in
  8860. guard let self else { return }
  8861. do {
  8862. if self.hasGoogleSessionAvailable() {
  8863. await MainActor.run { self.showGoogleAccountMenu() }
  8864. return
  8865. }
  8866. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  8867. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  8868. let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
  8869. await MainActor.run {
  8870. self.updateGoogleAuthButtonTitle()
  8871. self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  8872. self.pageCache[.joinMeetings] = nil
  8873. self.pageCache[.photo] = nil
  8874. self.pageCache[.video] = nil
  8875. self.pageCache[.widgets] = nil
  8876. self.pageCache[.settings] = nil
  8877. self.pageCache[.aiCompanion] = nil
  8878. self.showSidebarPage(self.selectedSidebarPage)
  8879. }
  8880. // Ensure desktop widgets refresh immediately with the newly available meetings.
  8881. await self.loadSchedule()
  8882. } catch {
  8883. self.showSimpleError("Couldn’t connect Google account.", error: error)
  8884. }
  8885. }
  8886. }
  8887. private func showGoogleAccountMenu() {
  8888. guard let button = scheduleGoogleAuthButton else { return }
  8889. if googleAccountPopover?.isShown == true {
  8890. googleAccountPopover?.performClose(nil)
  8891. googleAccountPopover = nil
  8892. return
  8893. }
  8894. let popover = NSPopover()
  8895. popover.behavior = .transient
  8896. popover.animates = true
  8897. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  8898. let name = scheduleCurrentProfile?.name ?? "Google account"
  8899. let email = scheduleCurrentProfile?.email ?? "Signed in"
  8900. let avatar = scheduleProfileMenuAvatar
  8901. popover.contentViewController = GoogleAccountMenuViewController(
  8902. palette: palette,
  8903. darkModeEnabled: darkModeEnabled,
  8904. displayName: name,
  8905. email: email,
  8906. avatar: avatar,
  8907. onSignOut: { [weak self] in
  8908. self?.googleAccountPopover?.performClose(nil)
  8909. self?.googleAccountPopover = nil
  8910. self?.performGoogleSignOut()
  8911. }
  8912. )
  8913. googleAccountPopover = popover
  8914. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
  8915. }
  8916. private func performGoogleSignOut() {
  8917. do {
  8918. MeetingReminderManager.shared.cancelAllReminders()
  8919. try googleOAuth.signOut()
  8920. applyGoogleProfile(nil)
  8921. publishWidgetMeetingsSnapshot(from: [])
  8922. DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
  8923. updateGoogleAuthButtonTitle()
  8924. pageCache[.joinMeetings] = nil
  8925. pageCache[.photo] = nil
  8926. pageCache[.video] = nil
  8927. pageCache[.widgets] = nil
  8928. pageCache[.settings] = nil
  8929. pageCache[.aiCompanion] = nil
  8930. showSidebarPage(selectedSidebarPage)
  8931. Task { [weak self] in
  8932. await self?.loadSchedule()
  8933. }
  8934. } catch {
  8935. showSimpleError("Couldn’t logout Google account.", error: error)
  8936. }
  8937. }
  8938. private func updateGoogleAuthButtonTitle() {
  8939. let signedIn = hasGoogleSessionAvailable()
  8940. guard let button = scheduleGoogleAuthButton else { return }
  8941. let profileName = scheduleCurrentProfile?.name ?? "Google account"
  8942. let ringHostInset: CGFloat = signedIn ? 14 : 0
  8943. scheduleGoogleAuthHostPadWidthConstraint?.constant = ringHostInset
  8944. scheduleGoogleAuthHostPadHeightConstraint?.constant = ringHostInset
  8945. scheduleGoogleAuthHostView?.setAvatarRingMode(signedIn)
  8946. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: darkModeEnabled, accent: palette.primaryBlue)
  8947. if signedIn == false {
  8948. scheduleGoogleAuthHostView?.setProfileHoverActive(false)
  8949. }
  8950. if signedIn {
  8951. button.setAccessibilityLabel("\(profileName), Google account")
  8952. button.attributedTitle = NSAttributedString(string: "")
  8953. button.imagePosition = .imageOnly
  8954. button.imageScaling = .scaleProportionallyDown
  8955. button.symbolConfiguration = nil
  8956. scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize
  8957. scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize
  8958. button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2
  8959. let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
  8960. if let symbol {
  8961. let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize))
  8962. button.image = sized
  8963. button.contentTintColor = palette.textSecondary
  8964. } else {
  8965. button.image = nil
  8966. button.contentTintColor = nil
  8967. }
  8968. scheduleProfileMenuAvatar = button.image
  8969. } else {
  8970. button.setAccessibilityLabel("Sign in with Google")
  8971. let title = "Sign in with Google"
  8972. let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
  8973. let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
  8974. button.attributedTitle = NSAttributedString(string: title, attributes: [
  8975. .font: titleFont,
  8976. .foregroundColor: titleColor
  8977. ])
  8978. let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
  8979. let idealWidth = ceil(textWidth + 80)
  8980. scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
  8981. scheduleGoogleAuthButtonHeightConstraint?.constant = 42
  8982. button.layer?.cornerRadius = 21
  8983. button.imagePosition = .imageLeading
  8984. button.imageScaling = .scaleNone
  8985. if let g = NSImage(named: "GoogleGLogo") {
  8986. button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
  8987. } else {
  8988. button.image = nil
  8989. }
  8990. button.contentTintColor = nil
  8991. }
  8992. applyGoogleAuthButtonSurface()
  8993. }
  8994. private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
  8995. let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
  8996. let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  8997. return GoogleProfileDisplay(
  8998. name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
  8999. email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
  9000. pictureURL: profile.picture.flatMap(URL.init(string:))
  9001. )
  9002. }
  9003. private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
  9004. scheduleProfileImageTask?.cancel()
  9005. scheduleProfileImageTask = nil
  9006. if profile == nil {
  9007. scheduleProfileMenuAvatar = nil
  9008. }
  9009. scheduleCurrentProfile = profile
  9010. updateGoogleAuthButtonTitle()
  9011. pageCache[.aiCompanion] = nil
  9012. redrawAiCompanionPageIfNeeded()
  9013. guard let profile, let pictureURL = profile.pictureURL else { return }
  9014. let avatarDiameter = scheduleGoogleSignedInAvatarSize
  9015. scheduleProfileImageTask = Task { [weak self] in
  9016. do {
  9017. let (data, _) = try await URLSession.shared.data(from: pictureURL)
  9018. if Task.isCancelled { return }
  9019. guard let image = NSImage(data: data) else { return }
  9020. await MainActor.run { [weak self] in
  9021. guard let self else { return }
  9022. let rounded = self.circularProfileImage(image, diameter: avatarDiameter)
  9023. self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48)
  9024. self.scheduleGoogleAuthButton?.image = rounded
  9025. self.scheduleGoogleAuthButton?.contentTintColor = nil
  9026. }
  9027. } catch {
  9028. // Keep placeholder avatar if image fetch fails.
  9029. }
  9030. }
  9031. }
  9032. private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
  9033. let result = NSImage(size: size)
  9034. result.lockFocus()
  9035. image.draw(in: NSRect(origin: .zero, size: size),
  9036. from: NSRect(origin: .zero, size: image.size),
  9037. operation: .copy,
  9038. fraction: 1.0)
  9039. result.unlockFocus()
  9040. result.isTemplate = false
  9041. return result
  9042. }
  9043. /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square).
  9044. private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  9045. circularNSImage(image, diameter: diameter)
  9046. }
  9047. private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
  9048. let base = resizedImage(image, to: iconSize)
  9049. let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
  9050. let result = NSImage(size: canvas)
  9051. result.lockFocus()
  9052. base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
  9053. from: NSRect(origin: .zero, size: base.size),
  9054. operation: .copy,
  9055. fraction: 1.0)
  9056. result.unlockFocus()
  9057. result.isTemplate = false
  9058. return result
  9059. }
  9060. private func applyGoogleAuthButtonSurface() {
  9061. guard let button = scheduleGoogleAuthButton else { return }
  9062. let signedIn = hasGoogleSessionAvailable()
  9063. let isDark = darkModeEnabled
  9064. if signedIn {
  9065. button.layer?.backgroundColor = NSColor.clear.cgColor
  9066. button.layer?.borderWidth = 0
  9067. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: isDark, accent: palette.primaryBlue)
  9068. return
  9069. }
  9070. let baseBackground = isDark
  9071. ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  9072. : NSColor.white
  9073. let hoverBlend = isDark ? NSColor.white : NSColor.black
  9074. let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
  9075. let baseBorder = isDark
  9076. ? NSColor(calibratedWhite: 0.50, alpha: 1)
  9077. : NSColor(calibratedWhite: 0.72, alpha: 1)
  9078. let hoverBorder = isDark
  9079. ? NSColor(calibratedWhite: 0.62, alpha: 1)
  9080. : NSColor(calibratedWhite: 0.56, alpha: 1)
  9081. button.layer?.borderWidth = 1
  9082. button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
  9083. button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
  9084. }
  9085. @MainActor
  9086. func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
  9087. _ = presentingWindow
  9088. guard googleOAuth.configuredClientId() != nil else { throw GoogleOAuthError.missingClientId }
  9089. guard googleOAuth.configuredClientSecret() != nil else { throw GoogleOAuthError.missingClientSecret }
  9090. }
  9091. func showSimpleError(_ title: String, error: Error) {
  9092. DispatchQueue.main.async {
  9093. let alert = NSAlert()
  9094. alert.alertStyle = .warning
  9095. alert.messageText = title
  9096. alert.informativeText = error.localizedDescription
  9097. alert.addButton(withTitle: "OK")
  9098. alert.runModal()
  9099. }
  9100. }
  9101. }
  9102. private struct Palette {
  9103. let pageBackground: NSColor
  9104. let sidebarBackground: NSColor
  9105. let sectionCard: NSColor
  9106. let tabBarBackground: NSColor
  9107. let tabIdleBackground: NSColor
  9108. let inputBackground: NSColor
  9109. let inputBorder: NSColor
  9110. let primaryBlue: NSColor
  9111. let primaryBlueBorder: NSColor
  9112. let cancelButton: NSColor
  9113. let meetingBadge: NSColor
  9114. let separator: NSColor
  9115. let textPrimary: NSColor
  9116. let textSecondary: NSColor
  9117. let textTertiary: NSColor
  9118. let textMuted: NSColor
  9119. init(isDarkMode: Bool) {
  9120. if isDarkMode {
  9121. pageBackground = NSColor(calibratedRed: 10.0 / 255.0, green: 11.0 / 255.0, blue: 12.0 / 255.0, alpha: 1)
  9122. sidebarBackground = NSColor(calibratedRed: 16.0 / 255.0, green: 17.0 / 255.0, blue: 19.0 / 255.0, alpha: 1)
  9123. sectionCard = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  9124. tabBarBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  9125. tabIdleBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  9126. inputBackground = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  9127. inputBorder = NSColor(calibratedRed: 38.0 / 255.0, green: 40.0 / 255.0, blue: 44.0 / 255.0, alpha: 1)
  9128. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  9129. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  9130. cancelButton = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  9131. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  9132. separator = NSColor(calibratedRed: 26.0 / 255.0, green: 27.0 / 255.0, blue: 30.0 / 255.0, alpha: 1)
  9133. textPrimary = NSColor(calibratedWhite: 0.98, alpha: 1)
  9134. textSecondary = NSColor(calibratedWhite: 0.78, alpha: 1)
  9135. textTertiary = NSColor(calibratedWhite: 0.66, alpha: 1)
  9136. textMuted = NSColor(calibratedWhite: 0.44, alpha: 1)
  9137. } else {
  9138. pageBackground = NSColor(calibratedRed: 244.0 / 255.0, green: 246.0 / 255.0, blue: 249.0 / 255.0, alpha: 1)
  9139. sidebarBackground = NSColor(calibratedRed: 232.0 / 255.0, green: 236.0 / 255.0, blue: 242.0 / 255.0, alpha: 1)
  9140. sectionCard = NSColor.white
  9141. tabBarBackground = NSColor.white
  9142. tabIdleBackground = NSColor.white
  9143. inputBackground = NSColor(calibratedRed: 247.0 / 255.0, green: 249.0 / 255.0, blue: 252.0 / 255.0, alpha: 1)
  9144. inputBorder = NSColor(calibratedRed: 211.0 / 255.0, green: 218.0 / 255.0, blue: 228.0 / 255.0, alpha: 1)
  9145. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  9146. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  9147. cancelButton = NSColor(calibratedRed: 240.0 / 255.0, green: 243.0 / 255.0, blue: 248.0 / 255.0, alpha: 1)
  9148. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  9149. separator = NSColor(calibratedRed: 212.0 / 255.0, green: 219.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
  9150. textPrimary = NSColor(calibratedRed: 32.0 / 255.0, green: 38.0 / 255.0, blue: 47.0 / 255.0, alpha: 1)
  9151. textSecondary = NSColor(calibratedRed: 82.0 / 255.0, green: 92.0 / 255.0, blue: 107.0 / 255.0, alpha: 1)
  9152. textTertiary = NSColor(calibratedRed: 110.0 / 255.0, green: 120.0 / 255.0, blue: 136.0 / 255.0, alpha: 1)
  9153. textMuted = NSColor(calibratedRed: 134.0 / 255.0, green: 145.0 / 255.0, blue: 162.0 / 255.0, alpha: 1)
  9154. }
  9155. }
  9156. }
  9157. private struct Typography {
  9158. let sidebarBrand = NSFont.systemFont(ofSize: 26, weight: .bold)
  9159. let sidebarSection = NSFont.systemFont(ofSize: 11, weight: .medium)
  9160. let sidebarIcon = NSFont.systemFont(ofSize: 12, weight: .medium)
  9161. let sidebarItem = NSFont.systemFont(ofSize: 16, weight: .medium)
  9162. let pageTitle = NSFont.systemFont(ofSize: 27, weight: .semibold)
  9163. let joinWithURLTitle = NSFont.systemFont(ofSize: 17, weight: .semibold)
  9164. let sectionTitleBold = NSFont.systemFont(ofSize: 25, weight: .bold)
  9165. let dateHeading = NSFont.systemFont(ofSize: 18, weight: .medium)
  9166. let tabIcon = NSFont.systemFont(ofSize: 13, weight: .regular)
  9167. let tabTitle = NSFont.systemFont(ofSize: 31 / 2, weight: .semibold)
  9168. let fieldLabel = NSFont.systemFont(ofSize: 15, weight: .medium)
  9169. let inputPlaceholder = NSFont.systemFont(ofSize: 14, weight: .regular)
  9170. let buttonText = NSFont.systemFont(ofSize: 16, weight: .medium)
  9171. let filterText = NSFont.systemFont(ofSize: 15, weight: .regular)
  9172. let filterArrow = NSFont.systemFont(ofSize: 12, weight: .regular)
  9173. let iconButton = NSFont.systemFont(ofSize: 14, weight: .medium)
  9174. let cardIcon = NSFont.systemFont(ofSize: 8, weight: .bold)
  9175. let cardTitle = NSFont.systemFont(ofSize: 15, weight: .semibold)
  9176. let cardSubtitle = NSFont.systemFont(ofSize: 13, weight: .bold)
  9177. let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
  9178. }
  9179. // MARK: - In-app browser (macOS WKWebView + chrome)
  9180. // Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
  9181. private enum InAppBrowserURLPolicy: Equatable {
  9182. case allowAll
  9183. case whitelist(hostSuffixes: [String])
  9184. }
  9185. private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
  9186. let scheme = (url.scheme ?? "").lowercased()
  9187. if scheme == "about" { return true }
  9188. guard scheme == "http" || scheme == "https" else { return false }
  9189. guard let host = url.host?.lowercased() else { return false }
  9190. switch policy {
  9191. case .allowAll:
  9192. return true
  9193. case .whitelist(let suffixes):
  9194. for suffix in suffixes {
  9195. let s = suffix.lowercased()
  9196. if host == s || host.hasSuffix("." + s) { return true }
  9197. }
  9198. return false
  9199. }
  9200. }
  9201. private enum InAppBrowserWebKitSupport {
  9202. static func makeWebViewConfiguration() -> WKWebViewConfiguration {
  9203. let config = WKWebViewConfiguration()
  9204. config.websiteDataStore = .default()
  9205. config.preferences.javaScriptCanOpenWindowsAutomatically = true
  9206. if #available(macOS 12.3, *) {
  9207. config.preferences.isElementFullscreenEnabled = true
  9208. }
  9209. config.mediaTypesRequiringUserActionForPlayback = []
  9210. if #available(macOS 11.0, *) {
  9211. config.defaultWebpagePreferences.allowsContentJavaScript = true
  9212. }
  9213. config.applicationNameForUserAgent = "MeetingsApp/1.0"
  9214. return config
  9215. }
  9216. }
  9217. private final class InAppBrowserWindowController: NSWindowController {
  9218. private static let defaultContentSize = NSSize(width: 1100, height: 760)
  9219. private static let minimumContentSize = NSSize(width: 800, height: 520)
  9220. private let browserViewController = InAppBrowserContainerViewController()
  9221. init() {
  9222. let browserWindow = NSWindow(
  9223. contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
  9224. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  9225. backing: .buffered,
  9226. defer: false
  9227. )
  9228. browserWindow.title = "Browser"
  9229. browserWindow.isRestorable = false
  9230. browserWindow.setFrameAutosaveName("")
  9231. browserWindow.minSize = browserWindow.frameRect(forContentRect: NSRect(origin: .zero, size: Self.minimumContentSize)).size
  9232. browserWindow.center()
  9233. browserWindow.contentViewController = browserViewController
  9234. super.init(window: browserWindow)
  9235. }
  9236. @available(*, unavailable)
  9237. required init?(coder: NSCoder) {
  9238. nil
  9239. }
  9240. /// Resets size and position each time the browser is shown so a previously tiny window is never reused.
  9241. func applyDefaultFrameCenteredOnVisibleScreen() {
  9242. guard let w = window, let screen = w.screen ?? NSScreen.main else { return }
  9243. let windowFrame = w.frameRect(forContentRect: NSRect(origin: .zero, size: Self.defaultContentSize))
  9244. let vf = screen.visibleFrame
  9245. var frame = windowFrame
  9246. frame.origin.x = vf.midX - frame.width / 2
  9247. frame.origin.y = vf.midY - frame.height / 2
  9248. if frame.maxX > vf.maxX { frame.origin.x = vf.maxX - frame.width }
  9249. if frame.minX < vf.minX { frame.origin.x = vf.minX }
  9250. if frame.maxY > vf.maxY { frame.origin.y = vf.maxY - frame.height }
  9251. if frame.minY < vf.minY { frame.origin.y = vf.minY }
  9252. w.setFrame(frame, display: true)
  9253. }
  9254. func load(url: URL, policy: InAppBrowserURLPolicy) {
  9255. browserViewController.setNavigationPolicy(policy)
  9256. browserViewController.load(url: url)
  9257. }
  9258. }
  9259. private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
  9260. private var webView: WKWebView!
  9261. private var webContainerView: NSView!
  9262. private weak var urlField: NSTextField?
  9263. private var backButton: NSButton!
  9264. private var forwardButton: NSButton!
  9265. private var reloadStopButton: NSButton!
  9266. private var goButton: NSButton!
  9267. private var progressBar: NSProgressIndicator!
  9268. private var lastLoadedURL: URL?
  9269. private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
  9270. private var processTerminateRetryCount = 0
  9271. /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
  9272. private let maxProcessTerminateRetries = 3
  9273. private var kvoTokens: [NSKeyValueObservation] = []
  9274. deinit {
  9275. kvoTokens.removeAll()
  9276. }
  9277. func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
  9278. navigationPolicy = policy
  9279. }
  9280. override func loadView() {
  9281. let root = NSView()
  9282. root.translatesAutoresizingMaskIntoConstraints = false
  9283. let wv = makeWebView()
  9284. webView = wv
  9285. let webHost = NSView()
  9286. webHost.translatesAutoresizingMaskIntoConstraints = false
  9287. webHost.wantsLayer = true
  9288. webHost.addSubview(wv)
  9289. NSLayoutConstraint.activate([
  9290. wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
  9291. wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
  9292. wv.topAnchor.constraint(equalTo: webHost.topAnchor),
  9293. wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
  9294. ])
  9295. webContainerView = webHost
  9296. let toolbar = NSStackView()
  9297. toolbar.translatesAutoresizingMaskIntoConstraints = false
  9298. toolbar.orientation = .horizontal
  9299. toolbar.spacing = 8
  9300. toolbar.alignment = .centerY
  9301. toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
  9302. backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
  9303. backButton.target = self
  9304. backButton.action = #selector(goBack)
  9305. forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
  9306. forwardButton.target = self
  9307. forwardButton.action = #selector(goForward)
  9308. reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
  9309. reloadStopButton.target = self
  9310. reloadStopButton.action = #selector(reloadOrStop)
  9311. let field = NSTextField(string: "")
  9312. field.translatesAutoresizingMaskIntoConstraints = false
  9313. field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  9314. field.placeholderString = "Address"
  9315. field.cell?.sendsActionOnEndEditing = false
  9316. field.delegate = self
  9317. urlField = field
  9318. goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
  9319. goButton.translatesAutoresizingMaskIntoConstraints = false
  9320. goButton.bezelStyle = .rounded
  9321. toolbar.addArrangedSubview(backButton)
  9322. toolbar.addArrangedSubview(forwardButton)
  9323. toolbar.addArrangedSubview(reloadStopButton)
  9324. toolbar.addArrangedSubview(field)
  9325. toolbar.addArrangedSubview(goButton)
  9326. field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
  9327. let bar = NSProgressIndicator()
  9328. bar.translatesAutoresizingMaskIntoConstraints = false
  9329. bar.style = .bar
  9330. bar.isIndeterminate = false
  9331. bar.minValue = 0
  9332. bar.maxValue = 1
  9333. bar.doubleValue = 0
  9334. bar.isHidden = true
  9335. progressBar = bar
  9336. let separator = NSBox()
  9337. separator.translatesAutoresizingMaskIntoConstraints = false
  9338. separator.boxType = .separator
  9339. webView.navigationDelegate = self
  9340. webView.uiDelegate = self
  9341. root.addSubview(toolbar)
  9342. root.addSubview(bar)
  9343. root.addSubview(separator)
  9344. root.addSubview(webHost)
  9345. NSLayoutConstraint.activate([
  9346. toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  9347. toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  9348. toolbar.topAnchor.constraint(equalTo: root.topAnchor),
  9349. bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  9350. bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  9351. bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
  9352. bar.heightAnchor.constraint(equalToConstant: 3),
  9353. separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  9354. separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  9355. separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
  9356. webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  9357. webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  9358. webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
  9359. webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  9360. ])
  9361. view = root
  9362. installWebViewObservers()
  9363. syncToolbarFromWebView()
  9364. }
  9365. private func makeWebView() -> WKWebView {
  9366. let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
  9367. wv.translatesAutoresizingMaskIntoConstraints = false
  9368. return wv
  9369. }
  9370. private func teardownWebViewObservers() {
  9371. kvoTokens.removeAll()
  9372. }
  9373. /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
  9374. private func replaceWebViewAndLoad(url: URL) {
  9375. teardownWebViewObservers()
  9376. webView.navigationDelegate = nil
  9377. webView.uiDelegate = nil
  9378. webView.removeFromSuperview()
  9379. let wv = makeWebView()
  9380. webView = wv
  9381. webContainerView.addSubview(wv)
  9382. NSLayoutConstraint.activate([
  9383. wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
  9384. wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
  9385. wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
  9386. wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
  9387. ])
  9388. webView.navigationDelegate = self
  9389. webView.uiDelegate = self
  9390. installWebViewObservers()
  9391. syncToolbarFromWebView()
  9392. webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
  9393. }
  9394. private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
  9395. let b = NSButton()
  9396. b.translatesAutoresizingMaskIntoConstraints = false
  9397. b.bezelStyle = .texturedRounded
  9398. b.setAccessibilityLabel(accessibilityDescription)
  9399. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
  9400. b.image = img
  9401. b.imagePosition = .imageOnly
  9402. } else {
  9403. b.title = title
  9404. }
  9405. b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
  9406. return b
  9407. }
  9408. private func installWebViewObservers() {
  9409. kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
  9410. self?.syncToolbarFromWebView()
  9411. })
  9412. kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
  9413. self?.syncToolbarFromWebView()
  9414. })
  9415. kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
  9416. self?.syncToolbarFromWebView()
  9417. })
  9418. kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
  9419. self?.syncProgressFromWebView()
  9420. })
  9421. kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
  9422. self?.syncWindowTitle()
  9423. })
  9424. kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
  9425. self?.syncAddressFieldFromWebView()
  9426. })
  9427. }
  9428. private func syncToolbarFromWebView() {
  9429. backButton?.isEnabled = webView.canGoBack
  9430. forwardButton?.isEnabled = webView.canGoForward
  9431. if webView.isLoading {
  9432. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
  9433. reloadStopButton.image = img
  9434. reloadStopButton.imagePosition = .imageOnly
  9435. reloadStopButton.title = ""
  9436. } else {
  9437. reloadStopButton.title = "Stop"
  9438. }
  9439. } else {
  9440. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
  9441. reloadStopButton.image = img
  9442. reloadStopButton.imagePosition = .imageOnly
  9443. reloadStopButton.title = ""
  9444. } else {
  9445. reloadStopButton.title = "Reload"
  9446. }
  9447. }
  9448. syncProgressFromWebView()
  9449. }
  9450. private func syncProgressFromWebView() {
  9451. guard let progressBar else { return }
  9452. if webView.isLoading {
  9453. progressBar.isHidden = false
  9454. progressBar.doubleValue = webView.estimatedProgress
  9455. } else {
  9456. progressBar.isHidden = true
  9457. progressBar.doubleValue = 0
  9458. }
  9459. }
  9460. private func syncAddressFieldFromWebView() {
  9461. guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
  9462. urlField.stringValue = url.absoluteString
  9463. }
  9464. private func syncWindowTitle() {
  9465. let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  9466. let host = webView.url?.host ?? ""
  9467. view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
  9468. }
  9469. func load(url: URL) {
  9470. lastLoadedURL = url
  9471. processTerminateRetryCount = 0
  9472. urlField?.stringValue = url.absoluteString
  9473. webView.load(URLRequest(url: url))
  9474. syncWindowTitle()
  9475. }
  9476. @objc private func goBack() {
  9477. webView.goBack()
  9478. }
  9479. @objc private func goForward() {
  9480. webView.goForward()
  9481. }
  9482. @objc private func reloadOrStop() {
  9483. if webView.isLoading {
  9484. webView.stopLoading()
  9485. } else {
  9486. webView.reload()
  9487. }
  9488. }
  9489. @objc private func addressFieldSubmitted() {
  9490. let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  9491. guard raw.isEmpty == false else { return }
  9492. var normalized = raw
  9493. if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
  9494. normalized = "https://\(normalized)"
  9495. }
  9496. guard let url = URL(string: normalized),
  9497. let scheme = url.scheme?.lowercased(),
  9498. scheme == "http" || scheme == "https",
  9499. url.host != nil
  9500. else {
  9501. let alert = NSAlert()
  9502. alert.messageText = "Invalid address"
  9503. alert.informativeText = "Enter a valid web address, for example https://example.com"
  9504. alert.addButton(withTitle: "OK")
  9505. alert.runModal()
  9506. return
  9507. }
  9508. guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
  9509. presentBlockedHostAlert()
  9510. return
  9511. }
  9512. load(url: url)
  9513. }
  9514. private func presentBlockedHostAlert() {
  9515. let alert = NSAlert()
  9516. alert.messageText = "Address not allowed"
  9517. alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
  9518. alert.addButton(withTitle: "OK")
  9519. alert.runModal()
  9520. }
  9521. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  9522. processTerminateRetryCount = 0
  9523. syncAddressFieldFromWebView()
  9524. syncWindowTitle()
  9525. }
  9526. func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
  9527. let nsError = error as NSError
  9528. if nsError.code == NSURLErrorCancelled {
  9529. return
  9530. }
  9531. let alert = NSAlert()
  9532. alert.messageText = "Unable to load page"
  9533. alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
  9534. alert.addButton(withTitle: "Try Again")
  9535. alert.addButton(withTitle: "OK")
  9536. if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
  9537. processTerminateRetryCount = 0
  9538. webView.load(URLRequest(url: url))
  9539. }
  9540. }
  9541. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  9542. guard let url = lastLoadedURL else { return }
  9543. if processTerminateRetryCount < maxProcessTerminateRetries {
  9544. processTerminateRetryCount += 1
  9545. replaceWebViewAndLoad(url: url)
  9546. return
  9547. }
  9548. let alert = NSAlert()
  9549. alert.messageText = "Page stopped loading"
  9550. alert.informativeText =
  9551. "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
  9552. alert.addButton(withTitle: "Try Again")
  9553. alert.addButton(withTitle: "OK")
  9554. if alert.runModal() == .alertFirstButtonReturn {
  9555. processTerminateRetryCount = 0
  9556. replaceWebViewAndLoad(url: url)
  9557. }
  9558. }
  9559. func webView(
  9560. _ webView: WKWebView,
  9561. decidePolicyFor navigationAction: WKNavigationAction,
  9562. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  9563. ) {
  9564. guard let url = navigationAction.request.url else {
  9565. decisionHandler(.allow)
  9566. return
  9567. }
  9568. let scheme = (url.scheme ?? "").lowercased()
  9569. if scheme == "mailto" || scheme == "tel" {
  9570. decisionHandler(.cancel)
  9571. return
  9572. }
  9573. if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
  9574. if navigationAction.targetFrame?.isMainFrame != false {
  9575. DispatchQueue.main.async { [weak self] in
  9576. self?.presentBlockedHostAlert()
  9577. }
  9578. }
  9579. decisionHandler(.cancel)
  9580. return
  9581. }
  9582. decisionHandler(.allow)
  9583. }
  9584. func webView(
  9585. _ webView: WKWebView,
  9586. createWebViewWith configuration: WKWebViewConfiguration,
  9587. for navigationAction: WKNavigationAction,
  9588. windowFeatures: WKWindowFeatures
  9589. ) -> WKWebView? {
  9590. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  9591. if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
  9592. webView.load(URLRequest(url: requestURL))
  9593. } else {
  9594. presentBlockedHostAlert()
  9595. }
  9596. }
  9597. return nil
  9598. }
  9599. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  9600. if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  9601. addressFieldSubmitted()
  9602. return true
  9603. }
  9604. return false
  9605. }
  9606. }