Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion androidApp/src/main/assets/event_firmware.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
15 changes: 12 additions & 3 deletions androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -193,16 +195,23 @@ class MainActivity : AppCompatActivity() {
val eventEdition by model.eventEdition.collectAsStateWithLifecycle()
val eventThemeEnabled by model.eventThemeEnabled.collectAsStateWithLifecycle()
val eventFontResolver = koinInject<EventFontResolver>()
// 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
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<Color>, 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<Color>) {
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)
}
}

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Color>, 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
Loading
Loading