From bbc0baecf2efbdd64186012cd4faf061aab1b1dc Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 27 Jul 2026 20:05:26 -0500 Subject: [PATCH 1/2] feat(event): DEF CON 34 logo and full brand palette in event theming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event branding read only one color per edition — the accent wash on the app bar. For DEF CON 34 that color is #0D294A, a navy so dark it is invisible washed over a dark surface, so the app showed a generic Meshtastic logo and no visible theming at all while connected to DEF CON firmware. The metadata already carried a six-color palette, named brand colors, a theme name, and a tagline; none of it reached the UI. - Bundle the DEF CON 34 mark as the branding icon, and point the edition's iconUrl at the hosted copy shipping alongside in meshtastic/api. - Carry the full palette and a high-contrast highlight through EventTheme, and draw the palette as a gradient rule under the app bar and closing the info sheet header — the wash can only ever show one color. - Surface the theme name and tagline in the info sheet ("Agency" for DC34). - Pick info-sheet tints with pickLegible(): brand palettes are authored for the event's own materials, so an edition's loudest color may fail contrast on our light or dark surface. Falls back through the palette, then onSurfaceVariant. All of it is data-driven, so every edition gets whatever colors it publishes; DEF CON is simply the first with a full palette to spend. Co-Authored-By: Claude Opus 5 --- .../src/main/assets/event_firmware.json | 2 +- .../kotlin/org/meshtastic/app/MainActivity.kt | 15 +- .../drawable/img_event_defcon.png | Bin 0 -> 18630 bytes .../core/ui/component/EventInfoSheet.kt | 197 ++++++++++++------ .../core/ui/component/MainAppBar.kt | 107 +++++++--- .../meshtastic/core/ui/theme/ColorContrast.kt | 14 ++ .../meshtastic/core/ui/theme/EventFonts.kt | 21 +- .../core/ui/util/LocalEventBranding.kt | 34 ++- .../core/ui/theme/PickLegibleTest.kt | 61 ++++++ .../core/ui/util/EventBrandingTest.kt | 66 ++++++ 10 files changed, 410 insertions(+), 107 deletions(-) create mode 100644 core/resources/src/commonMain/composeResources/drawable/img_event_defcon.png create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/PickLegibleTest.kt diff --git a/androidApp/src/main/assets/event_firmware.json b/androidApp/src/main/assets/event_firmware.json index 8eec0923cd..e1c0b2c3e6 100644 --- a/androidApp/src/main/assets/event_firmware.json +++ b/androidApp/src/main/assets/event_firmware.json @@ -143,7 +143,7 @@ "eventEnd": "2026-08-09", "timeZone": "America/Los_Angeles", "location": "Las Vegas Convention Center, Las Vegas, Nevada, USA", - "iconUrl": null, + "iconUrl": "https://api.meshtastic.org/resource/eventFirmware/defcon34.png", "accentColor": "#0D294A", "domain": "defcon.meshtastic.org", "links": [ diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 8b1d09f62d..b328ebcda8 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -92,6 +92,8 @@ import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider import org.meshtastic.core.ui.util.LocalTracerouteMapProvider import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider import org.meshtastic.core.ui.util.accentColorOrNull +import org.meshtastic.core.ui.util.brandHighlightOrNull +import org.meshtastic.core.ui.util.brandPalette import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.NO_DEVICE_SELECTED @@ -193,16 +195,23 @@ class MainActivity : AppCompatActivity() { val eventEdition by model.eventEdition.collectAsStateWithLifecycle() val eventThemeEnabled by model.eventThemeEnabled.collectAsStateWithLifecycle() val eventFontResolver = koinInject() - // Resolve the ambient event theme once per edition: an accent wash (works on any flavor) and/or downloadable - // fonts (Google flavor only). Applied app-wide only while on event firmware and not opted out. + // Resolve the ambient event theme once per edition: an accent wash and brand palette (any flavor) and/or + // downloadable fonts (Google flavor only). Applied app-wide only while on event firmware and not opted out. val eventAccent = eventEdition?.accentColorOrNull() + val eventHighlight = eventEdition?.brandHighlightOrNull() + val eventPalette = remember(eventEdition) { eventEdition?.brandPalette().orEmpty() } val resolvedEventFonts = remember(eventEdition, eventFontResolver) { eventFontResolver.resolve(eventEdition?.theme?.fonts) } CompositionLocalProvider( LocalEventBranding provides eventEdition, LocalEventTheme provides if (eventThemeEnabled && eventEdition != null) { - EventTheme(accent = eventAccent, fonts = resolvedEventFonts) + EventTheme( + accent = eventAccent, + highlight = eventHighlight, + palette = eventPalette, + fonts = resolvedEventFonts, + ) } else { null }, diff --git a/core/resources/src/commonMain/composeResources/drawable/img_event_defcon.png b/core/resources/src/commonMain/composeResources/drawable/img_event_defcon.png new file mode 100644 index 0000000000000000000000000000000000000000..037ec0cac7de03e43ec67ab1267b584e64ffcf8e GIT binary patch literal 18630 zcmX_nbyO5z`2L;U1(xoV4yB}#h6SXN68#`8Qqm=jyC5ynjg&|zAV`P6(jp=$ptOKA z5+cq1_nkaY zYOk(OZ3eo=YOGXj)CltH5KK@QW~2|Jqld+LLhm!6vM89R8`M(-HD*GM(J+?>P+0&( zLJilFh8oLY$?-6To6!3*sJsDcY=pZe!E`*J-dX75Ff6GGsttsR+=FWOVd3Ub@j8?~ z4TTRv?$t0k2Pk_Liun$8qG5&!-24xD0R!aFqz4HNy9VZ^=5yPxq00s1k^} z8b*8h-Dys^Vzt`v+*QIgpBLqfu>q+PCE%vhj9bw|Cn?E>UV$dj%XbAg#{VyJ9o9vI z+06fqKps!d;ER2Kw;%)K2dzgz(h<*txGPqOdy~Dk{5)ZoUW6kPUd`sGLt=X~5buNc z-?gvEF=nIJ$!=CYeD{#Q{yLlbmymj+i(d7UskN}bOi0Z0|Gm=}?51khKi@j`Ai8`G z-bY*y)Y4uh$Nc{qt*XMF2M8g-iFlV(@&8p6xA$s;95a*r!I*!ua-oOt@42_~k&}!S zu6LloiX3AknDMfsiTmFS)HLqYtKNJPG=!}pSk#-E2tGZY?0GH)Of4SxP=p=)sT{a) z0(C~kMxUpghP*b8wXyEs@SdJ}HP7AO)!n%G`#)yv9(XsbNpF;X>AiOPSs zpbSoncjfQjhX<0>}o#%ifx~5i+!ysMfg{?sQ3(cDV%ATNCSdCl-!wN zQLjw4BC2z1{Vtfr|GqlsYF-HsSTqY0n@>2UdDVX|dA2Zgw%PjvD_fj_9DLgZ%2u%6XVp>VDT>iJza_*%jadGi^`=_N7E1}AnoB`Fp)a{jt)n#<6a)K!}HX=NB(@q*=Rj*4oHP*-MF3) zZv?d(zd|B#?-%+;g%9-=jMA}u{tp!(3AJ?h5NAAr09GdQ5RFD#Rm`^r8@!}lQ8Kl! z)I%cFPy$JmLQ=L&NJIe?cZ<>xBC+-zPZP0QOvB7q2MNgS!GQs5ITn2v+{ONWVF}Oc zUc2B6K*6^?7B!rQY>Gxx)51Gp1v{={^+j|bRB!Mod_uWuN3Fnp{xeUT0&skubgqkV zvG}kW;=sC8MiVwl$FNAjW~>ZkZ|4<_XkYr4FZd?H;G^DgBI0lwK{6{<8lzF`A^MK+ES+6hOwQTzmE zL+i!u3y8l_J2-L(oS*M`{Na&exZk-2g?=uNEt!b@u0qL;8hefhe7Yc%l<}yN4F;%-}LR5vd~3D&h6mAIKmg|_o7m0_v<1Rpj+ zmlMbcl!5$(uo(D~_m)h)@awSRLj;zbpXSTGPO^1v0bb}1PVO@P6{=(EVDgn6Xu5+E zmpM;5vsm5@^9cavS4BDZpHUbbJO`|r;M2wTiQsx8_4g9aPPL&Q992;$d`w9L@8yrD z>DjrKRd96RPKkcTU04A%PJ`>8gLDfojGh1$#Qjf6UW5UK-$eI51-~YRf*X2Jc(X{r ziElyh&SAZ*znEG)#!7(TmB=Q#58Yd>Jp_Hm7Cb$B_8-8Ej^LqoyeDTw%*FJ5o&ujm zaqqu}^0R@~zNirt663NTSHeyS$BN?dOzbtjA$V17**L6p#W1-w<*?mx8c=hrF zKB(q;JOkod)9uI@V844)M~c0b*+CCl_Q*7s!oRF`{UM^jJnlzu@zl%IBnq&O1Q@*d zW!zk@=z0{emWWw{KNx?u7dd?^gF2KLUho|W^6*QBS^fKJE&p=v0gnH+e~aEuxJ_}j zslxz7pFFW)Ule=9n6kSTXqCkdTOL#!m;#HPLOvT`+K7R_vKl!~;CMg9cRH3=6U!2) zOa*>=4#k>KB*Ia@{cJi`caea>;=}Rrdob$>GARwpzmoEIp{>zd`;R+lFb&q>)G+Wx zjSQm(rt=GRh(IR&@<=aK7O4fK%Q9nmYbH~IlM}6Am=C@P!jTlnV@;~Nduc)#GT*rP zlZc7R9>$+;!S|*l|B4gj(QXz2pYDL5g`Zd19!5aId-@P&9c5N_6zXzAL_Ong@}0$+ zX7*ee#l?utmJ*Sd+q~SlcFWky3Z6qT7+62Dhns&(-*o2H#>eK4?vMwm&JcQmkro8D zCwyADv4w_O7hOre(gz&{wnd8mctH#X)oG}kl>WVp0hr+hY`TEEjA$V#)}bbD201{C zjs7JP3ojA8DK4Cm-tNyIrqT5_$X~BZ;SawD+id>Zq~`z>g@Zad9{mCY_?rR&{6;wx zANwz`KA9=Pc#u$J#Y))v2z`rZQ|t57dM}xL?B&ONA*RPg#%>W%;>v?(Lk1H@-hjWX z->h~*G$V#GC4S%0e>AVq*;X~Tos9;T8vZ_IvWkAZLY5E!eoZ2j90&LttO{9-ucps3 z&%`t(k#?`YvGATMBbZ>7eh{EHaU@$Gm_5J84vN`k;}yw8k&_*rF)A z+O)sW$w;%;DgC2t3@HFa4sh%*b-y>8vW~?Yb_&4ulY zCN^-Ay=U1k>{ntMcK)1%S)vo&Mlx69o`nB^su?4+U(gkI8Ogag;x6yDeO?_vZ zysIy%38#c8J$!%fP2+l8QG==o+)hUP8o%h?rZ7QQOnhRse>dt@e+lY%z@i|A0GwzK z>zyx(#OYQ_U$9vP7%bitxA{s&Q%;*j;&@8rpyUga{PgVXE%+M(*53>NnN7J9DtpoK zZ|ud)-$+i__wzSj$*h&=D$%Gey@Ig8WB?*r2sD9*M=id>^Ajzy$Zj_Z=-J|l(tq~7 zud==CO+UiK4BjCq3}WrJd-yS=ZgfZ}V~G!Vw6RzAd><|t*u*A#ayxt6xb*gG-E~wc z&`bkMTcj&!+MMh(Do$XJJ-vDTD(QIuVs@k*4)k7doZs_My|>v z>=||4=RfE{%z3k_O+-Wls3$w*Jbh+uh;6oiI_-X8Ar!tB&sWMc+hT^ybEj{eq*$YwK`Tm}vj^mD?V{ zmb4eS?GyO6d~%zycG;!Bb@T~P?-Xjhrk>yw5&d@FCCC-xt2_Wv$RgnZ!mm>1BLM}J1KV@LzE4RD@3{|f5^GL`=kuZcw zdg+-Op}n6W@sbhDftH9RQ2MCYl(Y*&6w&o9MH%)p2CTP&kwOiPI%nU`g?>7k!oFUJ zVj=7C-Jx~9-?obR35&zQqY97XHhRn{giO6mU2ys)k&o_A^vlab1h-)V4xh=4U+ZMO zGRmTGl;BT|bqu2OE<&7uMS-VhLmoF!LlM)bh zYAST0!>1-+0<(N`c?uDCv!Wjl2m`9?C0L|1b|sjfjdY~NV=t-iucySotgIgs_`-EG zr8q0c#;x#hI@2h`x^cUtsYT$$Q`W+(t1^XlR#)bU!#rGER{Y>?8$}YE@1z3x~GZ*z$N>y@?LnqnN#n z1jhXH1H#4W4pbw5bDlw@ZTFF1Rz<&1%J)H4-i^bi67p; zn1s{Q3mI!S7+gA~%aN}U;1O{6n-SApwCCn|NLAgIS@l?l=qX2`B`X5RyXc1B$qs9= zA(ozC03%KC}7`+|u|zfp|kHl26QYKzvcfjWy1Cy)+Va$zig&^EO8 zjo$E&$V`O*FP!%AR*WNjJxekgVu}F;3W(f>qBDsUpI2CNm^H7}%T+k4ac?_8U1wI) zDf7vQN>TIP6FO)SYjf{8KH!ER!0P!Y;ZV)zti0%ea7frTgt`B*qtWjT!0{ak_xrDrCBcxA$ATnr=BhYDwwOY3Ad)pRn$c%$&}XvE4N~QE1IC0~ z4m6W6O@OJ9tq*RH1V+|B8ymDP5nx=G_uv#l&_h9>P0wS!YZ6Y_boQ$O`GioAEulHp zWV{#g%Hs}e-tWlC=?{&j7KWR_H0nO)2bQ#NGxo$|h=^b#L~v9hFxN^!0;l>{i6BTX zM+*~@R}5w!J^3B@jpUwa4DRLAzPIa7o7wmFA5pv>J~S|i3m-tzmkU~vD7!^dk^uvL zzb~P$M%1bQ%dc|*n>(yC_ZGJP27VyreT>u6Hwi1tl17fdgkj3rsiEpLvNC#vH6O6{ z;W$$fAFn+ zI}cJ@##~6H>*sbkELiH61YqT&vf$YGeOO$~Ffr2;AkxREu!uu&hMt;F{Ar0<67K2p zzF=dsW#yC>W$e!wmEOXz!Yr1W&{Wiv$Hk_3-#gyJ+-<=u<0Q@m$K@-&<#H6`JxTlh z*Gd~4Yapoh@oppo-9;IXT?#okAz@*AJ$hfT4>!u4sId8lOa(dJfBIK_Kw1Ucc)*eQ zs#{r}2qQr2%c3f&NJUnhB7hMy3IizaxK5#96G8P*FVVhs;+%6{f*+^oKsA!CnJ9j{ zfD@IH0@`ZE;i_(mexq^VL3sdSWQf0Z4>7)???g>^88aW`D>>B2KdxQa;L$RVIXOAe ze8P}fqaN^-K6%DDIo9fJ0geN3nMAM>nU!~nIZTON(>5o z9uR;Z7*N>p;}%Cx-r+Bvf2$5YyAQAaoUGGc1%1H-SY6(l5Jjf_WtPQ`t`qX-3HBzF zUuF04v%UY}E!mUt!7{DdOJ~ZA#d@&rGh}`Sl?^_xx-inGc-~*-o1p!-` z9OK8>uPPPzyt+8sV>w|YsA~I6F(UbXz**MvQw-8y(31kQ^AzzZqB}R}%>QNZSRYv^ zGl=_;i;g^bFW1YVupewTC(~zrSdkSq2*V03LkQa$wkCKtr6bMjQUsWOt~PS#p!Kdh zn=p3f);|@F;f3}#C7D|ojV^N>dB4u9^6w}!#3p(LNBH36CCsCL8)e7|-@Sj(MA>K{ z)Z{1m7Glq9aKB-IYNr1u%K<)xD9FhGQ=L1anO6`=dZBXK>N>8rxqEGw)XtQb64pRx zbg=q`5t=#c zNN!}R2cJ)2XI>bP_=dm~VJsWn4Tpay29dGC7AMddloxCNjw?bYet;2A!t(K?=#avu zXpGK_WL;uf7dZgxzVSeBF--(CS8p44>{mmiNH8)F!&27K{Xv;owJB3G4lwXi_}d(P z6JCT7Wi)J@FPKc44B9-OKw>7Ff&WbV$}h`sGr}m6lCU>N9ggT{@EmWy^>=v)<(t4n z^>sdo@H(Bh{MMgSZA0mZVL^d!#bHgb!1!QNFo3f(IU|ijgJpKjcFjRfJ1drYH5(V;8JZZ#9+>-mVYd=2qI zmf;9JI-H(YYz;u|kW(F)<7pvjsM!skO6UY6Mm(f;{rnENEc~LSAQ<2nVkQr`pe;s! z`ZpyqHBHx+qw9-G>0Lq8zQzL&qiKe5FaC%b>xTAy0g@=WAj0gSYgjbRF+F9rl4AJ@ zX3dWKxGjB$dJA177d{+{Ltxzw+%qijYk)y3I*|zbqH+3z2P2T9P{*%@2TR^98Xw5V z2ZtCNT8f*Sj_*kcyRFjEmi#S$EmS^WG)BdgF^u)*L0{;%V?FiSFow{Pq)_{M&tZ4ns>? z)qa2fzzE6+5E%O3aKElvLir?qGTPt?H&4*;16VuA9XG#P!O^7@^wsD;Cl6G-fC38j zXt6i{vGBK^|BjaQ7A2TlMu|Myu9{@2#0z0>Pp@b7znLFe;h*^}+P6@G|1q$o#w0kL zoR%Il;DvKwBpduV;~jg#RF@LjB6_(Qa7=@}Hfx0gwppa1f1BA?+nErU;1LW>7)$k5 zCm!L0X#(FR)1uL*Gr;zj(qL7kWsyRGFhVO!)>(P2;zW|}=~C&bijQI1YFAM>83uLd zKi+zT;}Nc#cyD*dcm+16Q&(X4VqEw?C3B>UQ{&94-b2u^`{Q~1K8Aw3Z7}^Xg7p+I zVR4w8B3)bng~=bf)<^hP4rPP_lG@!el@#PXSDS4<@DC3K=;q7^2X>nXehkMH4x$2!p7M(4Pzs!{|q00jFhr&7+16eOPMiPA6W-#+FvefAiz zAJiwtAzLH)IT*7bED@LK;yoX#R8-6PFwP@;Xk;Mka#(Nz%I1jB?cS;#2(HQd$ysfA zA!jcFe>6TSTWC=AF+SCpIJ#(qj|-$CE_Ksicm!JTGGjW97>Jfa3B^Qmsg6f^OdUSL zN*iweBAoh`#YP~kZmE-iz+{9jjB_jN-x`vaHg)tevCpPz-OoKxRsqt8dDdh{kO?zw z>si5Aerw}2YAnIgF^~)U&c*4-J!b~Z`mLQ`p4Pv)s^A^!Q79N@AV;DCf6`=@@<9piy$Rb``9*^>W!lPOSzo9}zc#I)0h?Ma(Qr)VxatSgc1dzdZ`&+wjuw^v3 zbROlD2V z+L|Ve(r_TVmoLi3a&=f=JmX^sY`B7pCnk0$d`tOCRbFnZB4z+)h`N^2a1A}ET1^71 zy9iE|e%qa(TMy?qeYHU9i0Rw=DK5VujC$)6YDJq?wmoR3n`U+4@aFLB+WEiRVU50Q z+JWHj#_OTS!b7Vx&LS_>`o0W403x57hXUITZqpG*aGtXZ3}B#1pS1T7)#hJAfP!3~ z2#T1*ln#@gos6xy$w@F{|x0Zf`W$iz&&lwd+)}-%aJ`8 zT(w?}Ff4uH*MrqS6wEmeME`m6N}>u34UQ8X>FjO<9CD<<{=244t&)J_Z)0|e4&wR0 z1-gWx)ak}~a8PMESo_>u31NEM3X$I0^FE0i8e2|~J4L~;_;srn?)&PMyf~_~k>4ro z;J?2Xu*sYmWX4N8>|(EW>7B!bZQ-iA_1Xa&sb)_3=$7&P`Y{4RuVYH_>;)&lYK6}MsCgQhv1$k91 zcA5)x20T`L=dEnPBAV=uYFen$7jth!3GqKfveWrHsFpUD4o%|VtazzJKo}7}iL+c_ z0zAW|!j4vs^_yB;jzeD;!wjBH5p1gpp=(X-7U|Ma`oY`qR!%0&%HNc z>c-T0a<3@4VIPP%fm>;q2gGQJAQUsO9YkFo7oP@y{i!gHTe?|b2dCiv9s0p?e*V|# zFfHXz*vi3LUs`sh9O?D8)rtJW!lg@JU{V%(^GD-~Hl&$CJhu7Zr@Dt#`FBUHs85U$ zDIdx_CtOw;0ZZsfd;YLAoE?QV52!(KGwgK?wv|GY9>yw_u^{cHk9&OiuI;P zYzAhrYPNTsc-zD8M{XEQ*47nEROr*p?h^araM+Y$!{(s!}A)!2@%zztT@!$o#1EOF`eNz?X%CxBnjeVKz#ojJ|bm zMfS5j98>)2%U?*gR`o6 zwqdjRxR>&T#!{RZ_+?94EJjxJcJ#gtnm2aT2D_W&brIj^_b~z|dI(^PBIP@@GeD8S z>Y}WVE(B{NsDX5nc6rvG$}8-#;Fd&AVkh#jH(0yU^ix9iU4>UQ^}J_x?29%IaLlf1 z>6_riGL&Prm5uX8Tz%5FuXR~1K5mY$W=Db^bSl_M$1v?g=3R3)?2IsFV{sV8qFjzJ z+#r21>w`j_jvQYK8DNcGvI7j^h4?KLxbbjnF}J|R!q+TdD6E$3Yd)-dYU@P?T@cS) zrSBsnNsjg;Y0IoyVDbr#eaR@) zjr&iOpvUYPbwYw`LVVem)XKdS&1;-iH(3K*( z^B{h9yNdYJ*rz(qS*v=v%L#pJrd;6#S=A{pE5< zZ9vGJ%lyKIfR+eFI*hV<|A)aN<;X6PKjwE>F8L0uB0*ubE-%D*7Ur)dhReK#__8Ji zZ=RCUS3Gq+zdE}J4|*gzgZq1`^8NTOG8h?Z!U@)8NkY$IKV0Ggdoz*tFk5ayFE5W* zIDfBcwv5QU3vDlY;rB?@l>V=_LkqCE(h%%7zhxH!CSn7zlf-lVh} zTJrItg9$KX8J?R(v@Bl8mYVnBns+-Dr8!j!p2hDyu8$d_cWrFXHVJ08=be6c`KrCE z@!;~(HTdvX_4#KJ`*E$e%*<7{pgIN%mEzH1mHQ_C5atTde&iO% z{6k3)L>0x{F0Sp-8g+3NcxEN8&Hw)6%v9GG5eE&-j)sgd=!!+aFP$XS#ZzfoNSy(2jpyp4~PT@-Y z&NvjnxqLF;TRC$h^&KypD*pEY|7_!jgeQ5oGZy7$=g_5|QheUl+;ocK z7>Q*uSf$^Wz6L*sW`WFzHyNoR%|Y325C-pxat-$!ZC>?a{v8dd+~ui{WZBrW8YKoo zz0XMF3x7NFRluZr=5sz5-yQOtz`5T_&v$&s*-_kis?7{DxBfr|q_wuV#8{b)?=Du8 zh-l$s9un{3guoj zr2D?q3mwXl(n8Eg7?w7Esx{%yV$G5^z)a^orwRqk$5`~>$f78xhe@nwHVsCjbAw|w z0r#?`DVF|mq6iI9L~x8nQQ=76svOTkY|Z#At<&+F_wZ)@_aOlfC@N>BYa};zGk)D& z?9NI}z(?gj%P6Hr48+Crg4b((RCS|gy#La*U4fp-)SOmEzZ2!{Y8P0Q2`YM;o8%Q& zXScn$75{hbAur(rpYnL|_sE%=HcnK|+l62v4UsObzD)ldpEryj(KX2))zZGv9O-GQ zc(%2(KdX-WubCUdLSfh*I|1fa!G`;n$S_^3l?$z#xR+7;kDHY1bsx1SU42_E-e){( zdl(wIJKru(D7hIpA2?;!D>!)MSTVsfpd~T5Tr;L2fekX-2o@#>m?kIAtQ)4bI$lNW zd!*Q9{#m*-s4G@%ukD%>r1&crk`WlvQU34m!Pzh88jFzjPx3*|Z4HG5C217AnPl1z zlW7K%3}>f*0?!-nP)y(y)m1Av=p^WL zOLLTx$atY7O_cKWLRKxn8eKZaC-opgn9Do(VrA&Ce5z`ltNCov`^REr;H1jnOIwK+ zdN^ipc=cBWT7F|Pn_)P`v;Rq#?Q5gu9s!Xh7}mtnSBQ&m>}kJr#l-esz4x-e^BPB6 z8}j|T;TB=L_BS2t*qiF`j~>wgtC^v-6wOjD5i_>|rcVnwU~-wtMOzXKQ_#tvSpz#P!YH#i!k^^NJnw@{Oto zWvH%&s?75do|(7>{RO|OCt6-HmYcdi`MK|D$f+$-<^AbDcS7PU(gjYn_h-54ny0GP zqTSI4vW#|7=6b6mq{*UZJGUSG*E%Km8UpM;gItRFznptL(XKQ~+gGYOX?^ic@pi-6 zba*-DyFg>>bm97ghvmZ=$8UAqy10A$T)cnZV55RBYP_(}W*ETrn2n4ZxE=0OoegDF zkv@?{Vq4KX8oS|)PdY}-;TS}70n^NLN9r4K15?zW&SL)l75diHv~X692XyVQW-+F| zxN#7TjxCvA2nv$-RIe~^9>Fx^=P9Xt2zlh{zlPoo;EKuUj8L%qnz{96hZbl`aNYDl zPga`-{ECf!>;C6dHSlBa_!hy`nM^=;NJBeG>Li@?I4og1$x z`N;w+B|3~8dTwM!=KWSn$ujMy6A=@~uY||l)q|gA^;IeSk7}W5w%iiAfbmdk_^(@y zRU{ZT(t6ulLfV1^%AKl1r%t3w!fD1V1g$F3q$^s@u{soUhHcE!S$n4raa+|tUymXM z4$hC5#qPUn%G?6iuC9L;1B^9Mt@p=rKQxG(1*YA!dML~WAe{+$_j#rHVnP@*$JL7S z+m8-sE@1t)P|L|P>1}<~;zZ70rwPJQr#cgs_E0uXXgYFyCda&f<)LuSaaC2q@o_>G zdGqVp%Dg+7j??l=t9wg~%V3VigjG${U#M$ z17Dk|DzE?ITa%wx8b5i**{sXuAkVk%=Ql7vd;)1co4r!Xy9xi{t5zLZX<8Jw)?pB9 zVOTNd)Xoh?<_CuEAIQr$J+yLI37`OZUrBC_tD~kqG9-@)+(Rd1M!tC08{Gbozu=0m z|JxU7cV0?B5;hgy%UeB6{{%f|o+w&GS5ZGlC96{}DKyy5o|h_M%~CU~nM9+V$&R|6 zT)|Ku_*}n1emxmTBd3Q7p0aDU32P4g_wh1?Xzwk0jC(cNEo{a}Y2BL{QLcYlDe6$@ z8hf2}rWF+2(+ovvgScx0ATJ%!C*%U}+wA+=lm%{lLMf<4TR|`Vj0we!WY3o7k_NW< zOAnJ<8tki+Jx0Z$L%bD~xd?Vr(40ZD3A?8(GTssRdrAWHubKP&hn(i?xSV}0kLYXJ41hbnb-Th=hW0iGn&yMK`-gGq&EY{*yt37dH&@BMC|5um9xvH=yBp-<5T z)npak0Kg3R=%(YvBomNzH=f=FRV!M4h|rlZN%dab#~;DwV<#neKw^aH7G%E=`8dmk z-77s&c*9p?S}=V)kCxg|d>H3+`duWCzB9xwwCznJ0+c3TGJi=? zgf4SzJkZ%_D5b~XNWXbmKo%2@pIa9s)OL*g%?~6AR;jj44i0h4A=H9rA1oEWrR>U6 z1G*f%Wz)4FEkot58KTYT>?yb6s1CZA4mv1QwaKHzW1~A}3H2qZWkK~-^$*i{**P_32rqz`{{ZE>4 zTXrfKk5SiXCDl={`x z>z`3K2;|#>5jE5T3zzRiWtu824z>2K>0jt1?_*5HrP#=dI+aIt2s&5&F;#hiL^Hcc z7eJ-#-!GRmjZiP8`DCo(dh|(L^wM(b+M}{MU6(xljsL~@a(!F#FlozM7-IX%Sc;Sd zf}9&(rKcRf%D1$dZN!-AZ(kf5N>t-NkvY6HU-SKnT0XG%Zb~kmhpK~C5oQ2sUlJ2*dCzjN}bU&875BM6syWuZ- zBOQyy8ERut<#XMw?k2~mGP0jWTGCOaXIsRtaRu#o+acyli%i}s223@=zD}AcD*-oAb3?QW>Rjzt3!twyP0}t{=QCP z5}lKzPO(w#%gK{RtSF9@leD@f;s2zsD1u!Z1x2U>jrW9HO?O^B8yv1moN8z+zI*pq z5@68ry?Cm?rm)B6L8_4yFV9j}TFgxUGrNp#kW?`?e62&U8TQrDhH}1P=x>%nc$C>M zl{<3C8xKRa(Qjh89PU?J|EA;ruZjamyZT)7+ulM=heZGJ)U*{mA6N@|8ZVJl7j?^$ zmyzJC*m{^tlIh^VtuFwocCEGiWskXO@KHvy-9F)-=$C2opZ!lP8q|S`t89_J)?rv z!{ozlC}AXz$@B^u4DA;Q=MfZhDWoEHhqhh7zGMU^m2yT~S>&-YXhoEA3>@7w3sCx;HYPh_8 zBX{=o4Q{*)qp)uTQ?*+71q+87&SQj}pU!_y`A7WsXyE2^1&Wh!V1xXDw?4mW9|IEo z!dNY6?|Ql)++g}Zj&b!n&t)CQg^WH5DVY&QOu9AxsmgDc)ODZv+&s3El;8cfMauv3*DK zOrv$VUH6_^h6T3MCqTfJ@F?NTK0dsX3(v+`ib5^}|BFY02`55(_bjXAt<&oib5MK4 z_zRJ968*HD6iMhIDe0UMk~^6FAKi8lkK)E(MPA2 zv!fgVY6}R@ht~%B;@Ysx6ZbsVySpj?BUJ3j;bIY9rv&?2-O4G;2tI@*y;_agf94A) zpc^oXu%34fGtjkU`Dk6rPL!I#uyrrAbyPPT9 z)0~_(=OpxNCRHlBs~%2!=UuzuE*W3`mw$sWJ5(-LAGR28!Ng^$_NG=6o#pUAKQCFZ z{`AOtsE$DOXGQ!AUy}^ouIKoSX9><94sbnjTOHrOBe z{A?fK$0r0Q=?{BW9#_>URVW+=)@6IioZm5e?4d2Bxu~SfjtnhnI2U6gB`_(pDrRKR z>_u-dO>$qeTx#3wuHM}wNljRWRUlzVgw#s}LoR-=dwYDj9#-vGC{<-~F3XTcbwdNF z7Pq;T3v?R&^e-sUZ7H3jz-+6?=jGr1Cf(3ZJ|GdKaK6{-JaT@iG7yOpI11tf%n?h~ zQU=v4dE34RsTbr=KWgG*3<&Kz-6=x<*dc*S#xbqi{qGa@<;&~r-_YB5JBP9I(b0pr zLRE!x_vFyhS}t&=^tP9WzjFyxAEwzL$(?$sC`KG8FO)1dWX+0=*Xw2!fdTPR%O}>a z!{{Ikf^BAJKhwzP#lAnuLcx66-HepdW%P8kGbdO%1{J+bZ|mj5^Kr}jMz0>hHneNU z9i}*%WuuuWV{lmK6LABc#-DH83r!Idn*l-Us_eD}c%Z=(@Yb?_Y>@7ti>1MY9Q0M( z--22S$z_F2-cw}P_Hn! zDm7vEi8ME8+=+u5K$-g;h%>ffbTWzBu6ZQJUUrW*t+;|L2>}>Ggw^D%1vi{NiQv`g z?ndQtuvDhv|2rZHiNZBp-fKd!kaoVKSTu&VoTe%-XHyZYk01}q>U-rM>q={iiFnmI zPneIu+-Ac&93+Qvxg0)Gvd-^gLu#Y)B?8ubtF~%4I-2<&C@0-vw~-9%65{gI>CAks z80lEJLU*DI4D?!JDp4M2r8LWYqu8<|2Sr`!xGO}BZ#JBFE96d={>?Rf zOx?)5QgG>w1-kqjhs-$KO)`i9tZfn3oYThO7!o`$-Rpz8N7-Ynu#@?3BQUb#}V7Wa%e+A=d4lk@Ue&P^0m0*u-tJk`^zO;3GR;%1&Dqz6Z7w9g(Ka~ zSE2>JZ~TUN8{PEivY?R)kMnn*r%(p>`qY5~?|sj$ix6d7^i2F!N9%n8)h~DBzBUb+ z_K4Nt=|w8hA%kiYHRAPQD68G>v{k7acZq&S05}LG>U-xbcKu7nbhuPj`9-OwV|F#9 z#!W2j-+MQg4o@B5Z6)7Gr*tUa4FJ;2T$B`o9}g2Ye;zj;Rgl ziGix)?_#xE$<|xI^w~l~M`q;~`CPbb8I$Vp4&!vUAe&}EUI|5bAun_yU<@29DIWzCwW|%0cTrC4`CS(}T zEm8mgtON<@{*9i(^cTdq$XLt0w6auYXkUL`HS`-#iXli{rrlw@S?a4M3_IhyCG@f3 z`%hv5&{~k?-E4(VUxc1PzSbhng@EeqXE{S|TDM6Va0=ViXWf*wz#6UsjkIw6!XeRP znQ`hV@3zENxf{v+pHIBj`+fMK|Ck;4{!gnNqk_VyldrLZZ2o6O*)$Ax$GL(k((i3e zR<$whA~7gDDGWaU{O0UDw;^_(**$-VbZ1k$H3PrGcrbwML>y)3iRA( zHHV0oT-NRd@(9x1|~A!DaS(%gB*sm-J99L%|!Y<8`KNFBsb$i~!#D~!T% zLq$v6#$C@A!VAP6>Cz2A(LA%UHh#)%s$V*{ofZ|)eP_HKMIs!zKN%hV1zpL7hMYQl zm+Zt&eB=)yy!K%wIt#(9S26(UHV2p5N8RymN;@WiyUZ__iXpzP@54TOs^9?!U`cg??*>ndLe(Go8!#_6vl~oAdV47 z|MKI19Z`_S^gX)qN4oz8OmTACzRyXYj7YA?uW8L>V0!%AMC3kM*$gP!-RO~lL{Pw` zw!EAqwB&e7Gz%fZrkPAMruzY>@Q4UuE%d%%z;S8BKlv_fa%;|pB?VXeZ~Al@ZB@tQ z=xRk&G^B|@+%7iLg%7nqYY~xtz;>O`03To~pZV~2{|zQ&dq!eOp#@%h#(+$pt4l?4 zNF)}46@Z)>!8nVu$PcwzH8P+M#T%R0CPXZ|ekC&wuoc!rzP$~9O#te@!y(>%^rH<< z;96C#%7{N1D@~+V&Oax*+(9k@tXW}LTh27lOsRgP7;~rhF+lOUpofxgW5gK-n}6O4 zkhb>;*Lw*^f~aLCXyo`bo1YH$8ILL2VN`@b)esj26RC%;dy+DvfdMmMa#Ue^qi5el z4lOJ%$r@$v%dTedB!Atfsb zZb|eKE<|hhqzsT3qL{~e(U<;L{cn=v8z4vgyHXNmVUw+Mx&&-qVi5ix$I&=4!@4FN zEqpA?!dFjjdg>=j*6ix93dw}hhdIq@K{Py*ZL&T-*l~p9uk3Qu%JAUv4^)@EoY}6#kwX!GDF&?#*A|6 z7-AX;_-6InWz%U&lB6Z#r-Nfu|1$Ql82EEr*C5LZC;-KlSlh3@1qS58vj8`v%$<9&{rfk0a(oY zMWy&DI&p|;hb&0SOo#wH_Ksp(JzDs4nS_Uxr}i|rZ(F*Ya4FklQGwynOa;R5J0B}G z6(sA$h=7}~FZkDQKqvG>;D_PD=ibL_ck)C)7*k*7&Z%j(s>zXG1Fq^8VtxYG8k`}=Ip;Dzn7a<%6L4?g?3-SO?(0=`91|*=O4R_P7PhAFBvD-in#oJ z#clo>&&u%IJj~d_`A)?yDXHOELJZGd=*PmXIsKJWEW(N2pndv?5hLelx{KvPW zC_L_BvBOVs`sun%zGbzNl%+Hg!{fA+%}k#IlA~}~8$^KsC8G>D{7PlbD#HB4lE`0} z;1Aw3MaAe2*AEJmsFn;p+goBG7pei~pzq`dR;EQHkaaQ)!@?VQ1Gs9voZn^>otht} z2!(Taw6oaqZ8(9GYmgB7_CDMJvfcrhf%^KbQxjg8^VBOF)AdNCIWfq;v?FqqNS^P~ z0AGv1bT0t1Ym0(RcSL^9$;?I4Tm*vZ9jt5-oov9uh9TX1|lPzFA=6?Rw!}2ed z*LAROcodU)+9T*%h=A!7J$i@_Kd}Xc#f|zy;H3M0<<@@KZ#gR~0|S_^CD^rhra}JR zLaUkt|209V<68`POu7~fY*4ip!EJSe_vw@82-a?`;DSZ-hI`$!i8T;(-om){scQtQ z6APbQtQt-iyh+D+4{81H_J>!$U_LB_CTE-?n=cY2-#OmFmD*rRra7xsNmrKgNkBf) z8vzZGpEev*vb@0WE0s9ngByVmIi`fyzvl9$bU4m?MCpkL=md{8e#$y-63cg#dII40 zh(EQ;MwXd7N#HX!o5REP_H~=#S5Nw9FA2c*ixgY^%qxN`KRZHeI}PN;%;`}S6*(}S z&u#Tp|MsM&rxCUI3z%G5X2=#;P>V1}ZlE{iQ^}v*Fgg3V^IHy0C^Jb~oG+R1jjSXD z8RaUM*xY-Y%Yq3!!KjH<+Ljj>Tt#i6uT+p&+gs1g9{quV3N^4xGT1ZT7T@v zo$y~hJR=@1L0#UHC*c@)?tJJFOZ#yyI2Va|;Xj&PVls_nsh^~-a%^qqX%_(x*=9pX zg>^20%1-ZkokKx{1S!j*QjeVyGIwyD+dc-k%!bR?a+7 zf?z(EBdv7xru{1%N+kOh0ocVukQf$`*QZ6qFT2$sEZ^x;om};)64T3&wTpl_Tv0%e z5pCs5Wvnd+*vmD*Q)?T{N_maCeV14&U8huZHTARzaqZc1Jad%r`G)I}uv5VQcwNs# zF?13E*ddV^@h-!jScy;X2e&Z}H|RbLw3J!-JfgMLLxNU9J;nbQEC|#0q=)g>JX-%C zpO)AZv^zTh$bam9dGtS<4L}-!S2Xe2Ur_WDQ@(tG12B;S zkWB)}{@VVIvj9amNhLWoOoHQbI_Mhp0Hr%|&%cg?dsy{#27f*Nk2VE|XyR^5@p8)s zK$sRq8cE!dlB5OJv;h9IWQE=?9ZsE$$1>myS zErmu?zJ+EN1mKVj0Db1%0K*2}?TMoq8+x?g@c#l8L?H`yQ0IW9Rc2j4VBw3uwB?)u zK(+UYFEEGzU?&4$9k;KBTL9kS>={L6XPzmc@7aaj&@1%?Im}s(hvWHhit0rN7Zg1p z4**7YJa)TJ=01mq127f%$|S%HRKz?8=<;2XmOb2Zq(MB_xT6tR(n6-$YjGd+JOF?a^67B}0aye9IEVnSyMGY@3nJOQ zz4}dr0PnzXgE0T~B#v@B!c}4}<{_kN|f7 zHFg2P>2-Qh0%I@+yAoh8Sp%xg+A#MqZP0@Mj~V9N)@?LqpCZzPkbc-@^p`pqTD;O_--Fu0);)6j9c z4+1W)8|oWG3IL$(Qvg7#_w3z!M$iSe>?leqX#n;>vM=GR56vqj7g; znNt=AxXhbPq5#mB2!N=NzmIYNXwx~nc!#`6b`O={P2!T!^DQCHL|2Xj!0yh6)c!!> z@EF2jkFRMieEbgrfCVveb4(ZjxO~sqJHuw*10rxYlZ|O#m#z%}_9*gH9PZy6d0b7< zPXYjbeG>tIY|wYcwm`G**js?aHN>-9^Z)7q+*uflVGWq(Fua?7be{x3&?QtUaSza9 znG^sTkatIuEE0#{T8LZo|M~y`0=yOgNnJ2>U^@BR8l zBQP;^eE{HWP8Y;}VX>#S9D4x(3Gn-k3;==E;Q*KffIA)e2Y^e4WtvOV6f*vrM+ITt zQ@jIUwqE#aUq}eRT51Cb09)&$MYzTIMLb}lW~eOQS4-XZ{mLRUBd+KgX@v0Ty}ct>G8_=nI>0>CgS65eqDL}pV#Ks2M_ z05Ctu$psy6A9G3RCjo%>kzfb34S--W(*l4>=)m(p`{S*14q^180Dw>oWPmQfkmTL0 z{bIM9NdP#60D#w#9QeMn!_oYa{Qp@1;CeBv$?Vt#3<6h`?qYWk)wmHM0RWG=w=YAw z(K3`+`dI+rW!Mwj85MJv3P^xhkBI@`PFH3oX7zt6`+pn&*s#2uq9R&|4Jdct%K!j| z#g_qSj%Z(U=IQC z1;^H&m)xo7euGUX=b#@6z#R<=h(}s87rsR&%6{kBiJNXR_~+1VazU9$Hz!^IjHSH< zg(I;3ftj9o`l%8iApBd@K(Jul3Y=9>{WUxh8)0u*kT-1m4 zBXd1*^oszn*8g^{VW5q~2R=EWu@pcHv`~j=G!9acL0EQ!F2uU29f|r>$8o- zneP#~A^i>jl;z$G^fvEYrGpy>)<3f8A(nmz0LHhZ`xXHJ-(LqZkL(tJTfff?^g95M z5KfuW%^2ov#yX)7!G)~9fPO6iOavS{CYX!-k@SmlK?3R4k*y=9J22oc^Y=NScfVNz zXxE054n6$Drk@1#YvKSYSxe6h-Y;}Pk$yD*SdyeWANS^ER~ufCem4Mjx}jK>y?yIf z(k}<#_tPH$H>N)TZcKjw+?f6VxGnweIN*N+{Q+=e`n|cJ|0VP%0XL>U0B%fw0Nj}V z0Jt&z8R6T~UmS32`UBv`^dGMd@~Wf%4<6@ox!eBRsDb_;41a>}a?Z{200000NkvXX Hu0mjf=fW-( literal 0 HcmV?d00001 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt index 8fcb3bca50..d8800f6dbd 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt @@ -19,9 +19,11 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -37,16 +39,20 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.EventFirmwareEdition @@ -58,8 +64,11 @@ import org.meshtastic.core.ui.icon.LinkIcon import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Place import org.meshtastic.core.ui.theme.LocalEventThemeToggle +import org.meshtastic.core.ui.theme.pickLegible import org.meshtastic.core.ui.util.EventBrandingIcon import org.meshtastic.core.ui.util.accentColorOrNull +import org.meshtastic.core.ui.util.brandHighlightOrNull +import org.meshtastic.core.ui.util.brandPalette /** * Bottom sheet shown when the user taps the event branding in [MainAppBar]. Surfaces the event metadata the bundled @@ -71,82 +80,141 @@ fun EventInfoSheet(edition: EventFirmwareEdition, onDismiss: () -> Unit) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val uriHandler = LocalUriHandler.current val accent = edition.accentColorOrNull() + val palette = edition.brandPalette() ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { - EventHeader(edition, accent) - - Column( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (edition.welcomeMessage.isNotBlank()) { - Text(text = edition.welcomeMessage, style = MaterialTheme.typography.bodyLarge) - } + EventHeader(edition, accent, palette) + EventDetails(edition = edition, palette = palette, onOpenUri = uriHandler::openUri) + } + } +} - val iconTint = accent ?: MaterialTheme.colorScheme.onSurfaceVariant - edition.location?.takeIf { it.isNotBlank() }?.let { InfoRow(MeshtasticIcons.Place, it, iconTint) } - dateRange(edition)?.let { InfoRow(MeshtasticIcons.CalendarMonth, it, iconTint) } - - val links = edition.links.filter { it.url.isNotBlank() } - if (links.isNotEmpty()) { - HorizontalDivider() - links.forEach { link -> - LinkRow(label = link.label.ifBlank { link.url }, tint = iconTint) { - uriHandler.openUri(link.url) - } - } - } +/** Welcome message, theme tagline, venue/dates, links, and the ambient-theme opt-out. */ +@Composable +private fun EventDetails(edition: EventFirmwareEdition, palette: List, onOpenUri: (String) -> Unit) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (edition.welcomeMessage.isNotBlank()) { + Text(text = edition.welcomeMessage, style = MaterialTheme.typography.bodyLarge) + } - // Opt-out for the ambient event theme (accent wash + app-wide fonts). Always shown — the sheet only - // opens for an active event, so there's always a theme to govern. - val themeToggle = LocalEventThemeToggle.current - HorizontalDivider() - Row( - modifier = - Modifier.fillMaxWidth() - .toggleable( - value = themeToggle.enabled, - onValueChange = themeToggle.onChange, - role = Role.Switch, - ), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.event_use_event_theme), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - // Row owns the toggle; null keeps the Switch visual-only (no double-fire). - Switch(checked = themeToggle.enabled, onCheckedChange = null) - } + edition.theme + ?.tagline + ?.takeIf { it.isNotBlank() } + ?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Brand colors are authored for the event's own materials, not for our light *and* dark surfaces — pick the + // loudest one that still reads here, preferring the highlight, then the rest of the palette. + val surface = MaterialTheme.colorScheme.surface + val fallbackTint = MaterialTheme.colorScheme.onSurfaceVariant + val iconTint = + remember(edition, surface, fallbackTint) { + pickLegible( + candidates = listOfNotNull(edition.brandHighlightOrNull()) + palette, + background = surface, + fallback = fallbackTint, + ) + } + edition.location?.takeIf { it.isNotBlank() }?.let { InfoRow(MeshtasticIcons.Place, it, iconTint) } + dateRange(edition)?.let { InfoRow(MeshtasticIcons.CalendarMonth, it, iconTint) } + + val links = edition.links.filter { it.url.isNotBlank() } + if (links.isNotEmpty()) { + HorizontalDivider() + links.forEach { link -> + LinkRow(label = link.label.ifBlank { link.url }, tint = iconTint) { onOpenUri(link.url) } } } + + HorizontalDivider() + EventThemeToggleRow() } } -/** Accent-colored header band with the event icon + display name; falls back to the theme surface when no accent. */ +/** + * Opt-out for the ambient event theme (accent wash, brand rule, and app-wide fonts). Always shown — the sheet only + * opens for an active event, so there's always a theme to govern. + */ @Composable -private fun EventHeader(edition: EventFirmwareEdition, accent: Color?) { - val background = accent ?: MaterialTheme.colorScheme.surfaceVariant - val foreground = accent?.contentColorFor() ?: MaterialTheme.colorScheme.onSurfaceVariant +private fun EventThemeToggleRow() { + val themeToggle = LocalEventThemeToggle.current Row( - modifier = Modifier.fillMaxWidth().background(background).padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier.fillMaxWidth() + .toggleable(value = themeToggle.enabled, onValueChange = themeToggle.onChange, role = Role.Switch), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - EventBrandingIcon( - edition = edition, - modifier = Modifier.size(48.dp).clip(CircleShape), - contentDescription = null, - ) Text( - text = edition.displayName, - style = MaterialTheme.typography.headlineSmall, - color = foreground, - modifier = Modifier.semantics { heading() }, + text = stringResource(Res.string.event_use_event_theme), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), ) + // Row owns the toggle; null keeps the Switch visual-only (no double-fire). + Switch(checked = themeToggle.enabled, onCheckedChange = null) + } +} + +/** + * Accent-colored header band with the event icon, display name, and theme name ("Agency" for DEF CON 34), closed by a + * gradient edge of the edition's full [palette]. The band stays a single flat color so the title keeps a predictable + * contrast ratio; the palette gets its own strip rather than running under the text. + */ +@Composable +private fun EventHeader(edition: EventFirmwareEdition, accent: Color?, palette: List) { + val background = accent ?: MaterialTheme.colorScheme.surfaceVariant + val foreground = accent?.contentColorFor() ?: MaterialTheme.colorScheme.onSurfaceVariant + Column(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().background(background).padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + EventBrandingIcon( + edition = edition, + modifier = Modifier.size(48.dp).clip(CircleShape), + contentDescription = null, + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = edition.displayName, + style = MaterialTheme.typography.headlineSmall, + color = foreground, + modifier = Modifier.semantics { heading() }, + ) + edition.theme + ?.name + ?.takeIf { it.isNotBlank() } + ?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = foreground.copy(alpha = THEME_NAME_ALPHA), + ) + } + } + } + if (palette.isNotEmpty()) { + val brush = + remember(palette) { + if (palette.size >= MIN_GRADIENT_STOPS) { + Brush.horizontalGradient(palette) + } else { + SolidColor(palette.first()) + } + } + Box(Modifier.fillMaxWidth().height(PALETTE_STRIP_HEIGHT).background(brush)) + } } } @@ -195,3 +263,12 @@ private fun dateRange(edition: EventFirmwareEdition): String? { private fun Color.contentColorFor(): Color = if (luminance() > LUMINANCE_MIDPOINT) Color.Black else Color.White private const val LUMINANCE_MIDPOINT = 0.5f + +/** The theme name is a subtitle to the event name — dimmed, but still well clear of the AA text threshold. */ +private const val THEME_NAME_ALPHA = 0.8f + +/** Height of the brand palette strip closing the header band. */ +private val PALETTE_STRIP_HEIGHT = 6.dp + +/** A gradient needs two stops; a single-color palette is drawn as a flat strip instead. */ +private const val MIN_GRADIENT_STOPS = 2 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt index 6ff9f2fd44..09bbb1f32b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt @@ -19,7 +19,12 @@ package org.meshtastic.core.ui.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -38,6 +43,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow @@ -57,6 +65,12 @@ import org.meshtastic.core.ui.util.LocalEventBranding /** Alpha for the ambient event accent wash over the app bar — subtle enough to keep title text legible. */ private const val EVENT_ACCENT_ALPHA = 0.12f +/** Height of the event brand rule under the app bar. A hairline: brand presence without stealing vertical space. */ +private val EVENT_BRAND_RULE_HEIGHT = 3.dp + +/** A gradient needs two stops; a single-color palette is drawn as a flat rule instead. */ +private const val MIN_GRADIENT_STOPS = 2 + @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun MainAppBar( @@ -73,7 +87,8 @@ fun MainAppBar( ) { // Ambient event theming: when connected to event firmware (and not opted out), tint the bar with a faint wash of // the edition's accent color. Gated with the app-wide fonts via LocalEventTheme / the "Use event theme" toggle. - val accent = LocalEventTheme.current?.accent + val eventTheme = LocalEventTheme.current + val accent = eventTheme?.accent val colors = if (accent != null) { TopAppBarDefaults.topAppBarColors( @@ -83,45 +98,71 @@ fun MainAppBar( } else { TopAppBarDefaults.topAppBarColors() } - TopAppBar( - colors = colors, - title = { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLargeEmphasized, - ) - }, - subtitle = { - subtitle?.let { + Column(modifier = modifier) { + TopAppBar( + colors = colors, + title = { Text( - text = it, + text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleLargeEmphasized, ) - } - }, - modifier = modifier, - navigationIcon = - if (canNavigateUp) { - { - IconButton(onClick = onNavigateUp) { - Icon( - imageVector = MeshtasticIcons.ArrowBack, - contentDescription = stringResource(Res.string.navigate_back), + }, + subtitle = { + subtitle?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + }, + navigationIcon = + if (canNavigateUp) { + { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = MeshtasticIcons.ArrowBack, + contentDescription = stringResource(Res.string.navigate_back), + ) + } + } + } else { + { brandingContent() } + }, + actions = { + TopBarActions( + ourNode = ourNode, + showNodeChip = showNodeChip, + actions = actions, + onClickChip = onClickChip, + ) + }, + ) + EventBrandRule(palette = eventTheme?.palette.orEmpty()) + } +} + +/** + * Hairline of the event's full brand palette under the app bar. The accent wash alone can only carry one color, and for + * editions whose primary is very dark (DEF CON's navy) it is nearly invisible against a dark surface — the rule is + * where the rest of the palette actually shows up. Draws nothing when the edition publishes no colors. + */ +@Composable +private fun EventBrandRule(palette: List) { + if (palette.isEmpty()) return + val brush = + remember(palette) { + if (palette.size >= MIN_GRADIENT_STOPS) { + Brush.horizontalGradient(palette) + } else { + SolidColor(palette.first()) } - } else { - { brandingContent() } - }, - actions = { - TopBarActions(ourNode = ourNode, showNodeChip = showNodeChip, actions = actions, onClickChip = onClickChip) - }, - ) + } + Box(Modifier.fillMaxWidth().height(EVENT_BRAND_RULE_HEIGHT).background(brush)) } /** Reads [LocalEventBranding] to show event branding (tap → [EventInfoSheet]), or the default Meshtastic logo. */ diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ColorContrast.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ColorContrast.kt index 3c0d3354f3..1be7babcb7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ColorContrast.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ColorContrast.kt @@ -34,4 +34,18 @@ fun contrastRatio(a: Color, b: Color): Float { return (hi + WCAG_OFFSET) / (lo + WCAG_OFFSET) } +/** + * The first of [candidates] that clears [minRatio] against [background], or [fallback] if none does. + * + * Brand palettes are authored for the event's own print/web treatment, not for the app's light *and* dark surfaces — an + * event's darkest brand color is invisible in dark mode, its lightest is invisible in light mode. Callers pass their + * preferred order (usually brand accent, then the rest of the palette) and get back a tint that actually reads. + */ +fun pickLegible( + candidates: List, + background: Color, + fallback: Color, + minRatio: Float = MIN_GRAPHICAL_CONTRAST, +): Color = candidates.firstOrNull { contrastRatio(it, background) >= minRatio } ?: fallback + private const val WCAG_OFFSET = 0.05f diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/EventFonts.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/EventFonts.kt index 95c231814b..5b0fd791fe 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/EventFonts.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/EventFonts.kt @@ -29,13 +29,22 @@ import org.meshtastic.core.model.EventFirmwareFonts data class EventFonts(val heading: FontFamily? = null, val body: FontFamily? = null) /** - * The ambient event branding to apply **app-wide** — a subtle [accent] wash and/or the event [fonts]. Populated at the - * composition root only when a device is on event firmware and the user hasn't opted out via [LocalEventThemeToggle]; - * `null` everywhere else. [AppTheme] reads [fonts] to swap [AppTypography]; the app bar reads [accent] for its wash. - * The event info sheet and branding icon are driven separately by - * [LocalEventBranding][org.meshtastic.core.ui.util.LocalEventBranding] so they stay available even when opted out. + * The ambient event branding to apply **app-wide** — a subtle [accent] wash, the event [palette], and/or the event + * [fonts]. Populated at the composition root only when a device is on event firmware and the user hasn't opted out via + * [LocalEventThemeToggle]; `null` everywhere else. [AppTheme] reads [fonts] to swap [AppTypography]; the app bar reads + * [accent] for its wash and [palette] for its brand rule. The event info sheet and branding icon are driven separately + * by [LocalEventBranding][org.meshtastic.core.ui.util.LocalEventBranding] so they stay available even when opted out. + * + * @param accent the edition's primary — calm enough to sit behind title text as a low-alpha wash. + * @param highlight the edition's loud detail color, for small marks that need to pop rather than recede. + * @param palette every brand color the edition publishes, in authored order; empty when it publishes none. */ -data class EventTheme(val accent: Color? = null, val fonts: EventFonts? = null) +data class EventTheme( + val accent: Color? = null, + val highlight: Color? = null, + val palette: List = emptyList(), + val fonts: EventFonts? = null, +) /** * Resolves an edition's [EventFirmwareFonts] (Google Font *family names*, e.g. `Lato`) into loadable [FontFamily]s. diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt index 246fc16199..7111a2a5be 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt @@ -33,6 +33,7 @@ import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.model.EventFirmwareEdition import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ic_meshtastic +import org.meshtastic.core.resources.img_event_defcon import org.meshtastic.core.resources.img_event_hamvention import kotlin.time.Clock @@ -51,6 +52,7 @@ val LocalEventBranding = compositionLocalOf { null } */ fun eventIconFor(editionName: String): DrawableResource? = when (editionName) { "HAMVENTION" -> Res.drawable.img_event_hamvention + "DEFCON" -> Res.drawable.img_event_defcon else -> null } @@ -101,11 +103,12 @@ fun EventFirmwareEdition.hasEnded(): Boolean { return Clock.System.todayIn(zone) > end } -/** Parses the edition's `#RRGGBB` [EventFirmwareEdition.accentColor] into a [Color], or `null` if absent/malformed. */ -fun EventFirmwareEdition.accentColorOrNull(): Color? { +/** + * Parses a `#RRGGBB` brand color into a [Color], or `null` if absent/malformed. Tolerates a missing `#` and any case. + */ +fun parseBrandColor(hex: String?): Color? { val rgb = - accentColor?.trim()?.removePrefix("#")?.takeIf { it.length == RGB_HEX_LENGTH }?.toIntOrNull(HEX_RADIX) - ?: return null + hex?.trim()?.removePrefix("#")?.takeIf { it.length == RGB_HEX_LENGTH }?.toIntOrNull(HEX_RADIX) ?: return null return Color( red = (rgb shr RED_SHIFT) and BYTE_MASK, green = (rgb shr GREEN_SHIFT) and BYTE_MASK, @@ -113,6 +116,29 @@ fun EventFirmwareEdition.accentColorOrNull(): Color? { ) } +/** Parses the edition's `#RRGGBB` [EventFirmwareEdition.accentColor] into a [Color], or `null` if absent/malformed. */ +fun EventFirmwareEdition.accentColorOrNull(): Color? = parseBrandColor(accentColor) + +/** + * The edition's full brand palette (`theme.palette`) as colors, malformed entries dropped. Falls back to the named + * `theme.colors` (primary/secondary/accent) and finally the top-level [accentColor][EventFirmwareEdition.accentColor], + * so an edition that carries only one color still yields a usable, non-empty list where one exists. + */ +fun EventFirmwareEdition.brandPalette(): List { + val fromPalette = theme?.palette.orEmpty().mapNotNull(::parseBrandColor) + if (fromPalette.isNotEmpty()) return fromPalette + val named = listOfNotNull(theme?.colors?.primary, theme?.colors?.secondary, theme?.colors?.accent, accentColor) + return named.mapNotNull(::parseBrandColor).distinct() +} + +/** + * The edition's high-contrast detail color — `theme.colors.accent` (DEF CON's pink against its navy primary), falling + * back to `theme.colors.secondary`. Distinct from [accentColorOrNull], which is the *primary* used for the ambient + * wash: the wash wants the calm color, small details want the loud one. + */ +fun EventFirmwareEdition.brandHighlightOrNull(): Color? = + parseBrandColor(theme?.colors?.accent) ?: parseBrandColor(theme?.colors?.secondary) + private const val RGB_HEX_LENGTH = 6 private const val HEX_RADIX = 16 private const val RED_SHIFT = 16 diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/PickLegibleTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/PickLegibleTest.kt new file mode 100644 index 0000000000..36fcf46363 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/PickLegibleTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PickLegibleTest { + + private val fallback = Color(0xFF888888) + + // DEF CON 34's published palette, in authored order. + private val defconNavy = Color(0xFF0D294A) + private val defconTeal = Color(0xFF017FA4) + private val defconPink = Color(0xFFE0004E) + private val defconMint = Color(0xFF6CCDB8) + + @Test + fun picksFirstCandidateThatClearsTheThreshold() { + // On a dark surface the navy primary is invisible, so the first legible color wins instead. + val onDark = pickLegible(listOf(defconNavy, defconPink, defconTeal), background = Color.Black, fallback) + assertEquals(defconPink, onDark) + assertTrue(contrastRatio(onDark, Color.Black) >= MIN_GRAPHICAL_CONTRAST) + } + + @Test + fun picksDifferentColorsForLightAndDarkBackgrounds() { + // Same palette, opposite surfaces: mint reads on black but not on white; navy is the reverse. + assertEquals(defconMint, pickLegible(listOf(defconMint, defconNavy), background = Color.Black, fallback)) + assertEquals(defconNavy, pickLegible(listOf(defconMint, defconNavy), background = Color.White, fallback)) + } + + @Test + fun fallsBackWhenNoCandidateReads() { + assertEquals(fallback, pickLegible(listOf(Color.White), background = Color.White, fallback)) + assertEquals(fallback, pickLegible(emptyList(), background = Color.Black, fallback)) + } + + @Test + fun honoursACustomMinimumRatio() { + // Pink on black (~4.3:1) clears the graphical bar but not the stricter text bar. + assertEquals(defconPink, pickLegible(listOf(defconPink), Color.Black, fallback)) + assertEquals(fallback, pickLegible(listOf(defconPink), Color.Black, fallback, minRatio = MIN_TEXT_CONTRAST)) + } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt index d610bc4970..42c3fff642 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt @@ -18,6 +18,8 @@ package org.meshtastic.core.ui.util import androidx.compose.ui.graphics.Color import org.meshtastic.core.model.EventFirmwareEdition +import org.meshtastic.core.model.EventFirmwareTheme +import org.meshtastic.core.model.EventFirmwareThemeColors import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -70,4 +72,68 @@ class EventBrandingTest { // Bad IANA id must not throw — it falls back to the device zone, and a long-past date is still ended. assertTrue(ended("2000-01-01", tz = "Not/AZone")) } + + @Test + fun brandPalettePrefersAuthoredPaletteAndDropsMalformedEntries() { + val edition = + EventFirmwareEdition( + edition = "DEFCON", + accentColor = "#0D294A", + theme = EventFirmwareTheme(palette = listOf("#0D294A", "nope", "#E0004E")), + ) + assertEquals( + listOf(Color(red = 0x0D, green = 0x29, blue = 0x4A), Color(red = 0xE0, green = 0x00, blue = 0x4E)), + edition.brandPalette(), + ) + } + + @Test + fun brandPaletteFallsBackToNamedColorsThenAccent() { + val named = + EventFirmwareEdition( + edition = "X", + accentColor = "#0D294A", + // No palette; the named colors carry the brand. The accent duplicates primary and must not repeat. + theme = EventFirmwareTheme(colors = EventFirmwareThemeColors(primary = "#0D294A", accent = "#E0004E")), + ) + assertEquals( + listOf(Color(red = 0x0D, green = 0x29, blue = 0x4A), Color(red = 0xE0, green = 0x00, blue = 0x4E)), + named.brandPalette(), + ) + + val accentOnly = EventFirmwareEdition(edition = "X", accentColor = "#EC8819") + assertEquals(listOf(Color(red = 0xEC, green = 0x88, blue = 0x19)), accentOnly.brandPalette()) + } + + @Test + fun brandPaletteEmptyWhenEditionPublishesNoColors() { + assertTrue(EventFirmwareEdition(edition = "X").brandPalette().isEmpty()) + } + + @Test + fun brandHighlightPrefersAccentThenSecondary() { + val withAccent = + EventFirmwareEdition( + edition = "X", + theme = EventFirmwareTheme(colors = EventFirmwareThemeColors(primary = "#0D294A", accent = "#E0004E")), + ) + assertEquals(Color(red = 0xE0, green = 0x00, blue = 0x4E), withAccent.brandHighlightOrNull()) + + val secondaryOnly = + EventFirmwareEdition( + edition = "X", + theme = + EventFirmwareTheme(colors = EventFirmwareThemeColors(primary = "#0D294A", secondary = "#017FA4")), + ) + assertEquals(Color(red = 0x01, green = 0x7F, blue = 0xA4), secondaryOnly.brandHighlightOrNull()) + + // A primary alone is the wash color, not a highlight — it must not be promoted into one. + val primaryOnly = + EventFirmwareEdition( + edition = "X", + accentColor = "#BF1E2E", + theme = EventFirmwareTheme(colors = EventFirmwareThemeColors(primary = "#BF1E2E")), + ) + assertNull(primaryOnly.brandHighlightOrNull()) + } } From a679d2bfc81ba5c57d33cb42ab976a49845f4e01 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 27 Jul 2026 20:17:50 -0500 Subject: [PATCH 2/2] refactor(event): share the palette strip and de-dupe authored brand colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit nitpicks on #6483. The app bar rule and the info-sheet header strip built the same gradient-or-solid brush and drew the same full-width Box, each with its own private MIN_GRADIENT_STOPS; only the height differed. Extract EventPaletteStrip and pass the height. brandPalette() de-duplicated the named-color fallback but not the authored palette, so a repeated hex became a repeated gradient stop — flattening the gradient over that span. Both paths now de-dupe, preserving authored order. Co-Authored-By: Claude Opus 5 --- .../core/ui/component/EventInfoSheet.kt | 18 +------ .../core/ui/component/EventPaletteStrip.kt | 51 +++++++++++++++++++ .../core/ui/component/MainAppBar.kt | 30 +---------- .../core/ui/util/LocalEventBranding.kt | 4 +- .../core/ui/util/EventBrandingTest.kt | 14 +++++ 5 files changed, 70 insertions(+), 47 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventPaletteStrip.kt diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt index d8800f6dbd..ab64c3db03 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventInfoSheet.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -43,9 +42,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler @@ -204,17 +201,7 @@ private fun EventHeader(edition: EventFirmwareEdition, accent: Color?, palette: } } } - if (palette.isNotEmpty()) { - val brush = - remember(palette) { - if (palette.size >= MIN_GRADIENT_STOPS) { - Brush.horizontalGradient(palette) - } else { - SolidColor(palette.first()) - } - } - Box(Modifier.fillMaxWidth().height(PALETTE_STRIP_HEIGHT).background(brush)) - } + EventPaletteStrip(palette = palette, height = PALETTE_STRIP_HEIGHT) } } @@ -269,6 +256,3 @@ private const val THEME_NAME_ALPHA = 0.8f /** Height of the brand palette strip closing the header band. */ private val PALETTE_STRIP_HEIGHT = 6.dp - -/** A gradient needs two stops; a single-color palette is drawn as a flat strip instead. */ -private const val MIN_GRADIENT_STOPS = 2 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventPaletteStrip.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventPaletteStrip.kt new file mode 100644 index 0000000000..1ba128ca00 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EventPaletteStrip.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.Dp + +/** + * Full-width band of an event edition's brand [palette], left to right in authored order. + * + * The ambient accent wash can only ever carry one color, and for editions whose primary is very dark (DEF CON's navy) + * it is nearly invisible against a dark surface — this strip is where the rest of the palette actually shows up. Used + * as a hairline rule under [MainAppBar] and as the closing edge of the [EventInfoSheet] header. + * + * Draws nothing for an empty palette, and a flat band rather than a gradient when the edition publishes a single color. + */ +@Composable +fun EventPaletteStrip(palette: List, height: Dp, modifier: Modifier = Modifier) { + if (palette.isEmpty()) return + val brush = + remember(palette) { + if (palette.size >= MIN_GRADIENT_STOPS) Brush.horizontalGradient(palette) else SolidColor(palette.first()) + } + Box(modifier.fillMaxWidth().height(height).background(brush)) +} + +/** A gradient needs two stops; a single-color palette is drawn as a flat band instead. */ +private const val MIN_GRADIENT_STOPS = 2 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt index 09bbb1f32b..41ee09382e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt @@ -19,11 +19,8 @@ package org.meshtastic.core.ui.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -43,9 +40,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow @@ -68,9 +62,6 @@ private const val EVENT_ACCENT_ALPHA = 0.12f /** Height of the event brand rule under the app bar. A hairline: brand presence without stealing vertical space. */ private val EVENT_BRAND_RULE_HEIGHT = 3.dp -/** A gradient needs two stops; a single-color palette is drawn as a flat rule instead. */ -private const val MIN_GRADIENT_STOPS = 2 - @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun MainAppBar( @@ -142,29 +133,10 @@ fun MainAppBar( ) }, ) - EventBrandRule(palette = eventTheme?.palette.orEmpty()) + EventPaletteStrip(palette = eventTheme?.palette.orEmpty(), height = EVENT_BRAND_RULE_HEIGHT) } } -/** - * Hairline of the event's full brand palette under the app bar. The accent wash alone can only carry one color, and for - * editions whose primary is very dark (DEF CON's navy) it is nearly invisible against a dark surface — the rule is - * where the rest of the palette actually shows up. Draws nothing when the edition publishes no colors. - */ -@Composable -private fun EventBrandRule(palette: List) { - if (palette.isEmpty()) return - val brush = - remember(palette) { - if (palette.size >= MIN_GRADIENT_STOPS) { - Brush.horizontalGradient(palette) - } else { - SolidColor(palette.first()) - } - } - Box(Modifier.fillMaxWidth().height(EVENT_BRAND_RULE_HEIGHT).background(brush)) -} - /** Reads [LocalEventBranding] to show event branding (tap → [EventInfoSheet]), or the default Meshtastic logo. */ @Composable private fun EventAwareBranding() { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt index 7111a2a5be..b7f2c287c8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalEventBranding.kt @@ -125,7 +125,9 @@ fun EventFirmwareEdition.accentColorOrNull(): Color? = parseBrandColor(accentCol * so an edition that carries only one color still yields a usable, non-empty list where one exists. */ fun EventFirmwareEdition.brandPalette(): List { - val fromPalette = theme?.palette.orEmpty().mapNotNull(::parseBrandColor) + // distinct() on both paths: a repeated hex would otherwise become a repeated gradient stop, flattening the + // gradient over that span. Order is preserved, so the authored reading order still holds. + val fromPalette = theme?.palette.orEmpty().mapNotNull(::parseBrandColor).distinct() if (fromPalette.isNotEmpty()) return fromPalette val named = listOfNotNull(theme?.colors?.primary, theme?.colors?.secondary, theme?.colors?.accent, accentColor) return named.mapNotNull(::parseBrandColor).distinct() diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt index 42c3fff642..9e936b568f 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/EventBrandingTest.kt @@ -87,6 +87,20 @@ class EventBrandingTest { ) } + @Test + fun brandPaletteDeDupesAuthoredEntriesPreservingOrder() { + // A repeated hex would become a repeated gradient stop, flattening the gradient over that span. + val edition = + EventFirmwareEdition( + edition = "X", + theme = EventFirmwareTheme(palette = listOf("#E0004E", "#0D294A", "#e0004e")), + ) + assertEquals( + listOf(Color(red = 0xE0, green = 0x00, blue = 0x4E), Color(red = 0x0D, green = 0x29, blue = 0x4A)), + edition.brandPalette(), + ) + } + @Test fun brandPaletteFallsBackToNamedColorsThenAccent() { val named =