diff --git a/androidApp/src/main/assets/event_firmware.json b/androidApp/src/main/assets/event_firmware.json index 8eec0923cd1..e1c0b2c3e61 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 8b1d09f62d6..b328ebcda8d 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 00000000000..037ec0cac7d Binary files /dev/null and b/core/resources/src/commonMain/composeResources/drawable/img_event_defcon.png differ 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 8fcb3bca508..ab64c3db039 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 @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Arrangement 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,6 +38,7 @@ 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 @@ -47,6 +49,7 @@ 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 +61,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 +77,131 @@ 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) - } - - 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) - } - } - } - - // 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) - } + EventHeader(edition, accent, palette) + EventDetails(edition = edition, palette = palette, onOpenUri = uriHandler::openUri) + } + } +} + +/** 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) + } + + 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), + ) + } + } + } + EventPaletteStrip(palette = palette, height = PALETTE_STRIP_HEIGHT) } } @@ -195,3 +250,9 @@ 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 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 00000000000..1ba128ca007 --- /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 6ff9f2fd44d..41ee09382e3 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 @@ -20,6 +20,8 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -57,6 +59,9 @@ 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 + @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun MainAppBar( @@ -73,7 +78,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 +89,52 @@ 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, ) } - } - } else { - { brandingContent() } - }, - actions = { - TopBarActions(ourNode = ourNode, showNodeChip = showNodeChip, actions = actions, onClickChip = onClickChip) - }, - ) + }, + 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, + ) + }, + ) + EventPaletteStrip(palette = eventTheme?.palette.orEmpty(), height = EVENT_BRAND_RULE_HEIGHT) + } } /** 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 3c0d3354f37..1be7babcb71 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 95c231814b9..5b0fd791feb 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 246fc161990..b7f2c287c8e 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,31 @@ 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 { + // 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() +} + +/** + * 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 00000000000..36fcf463636 --- /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 d610bc49709..9e936b568f9 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,82 @@ 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 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 = + 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()) + } }