No Description

ViewController.swift 368KB

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