ViewController.swift 494 KB

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