Nav apraksta

ViewController.swift 472KB

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