Aucune description

ViewController.swift 312KB

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