diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index ce8bd78bf..cb38fcc83 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -543,6 +543,7 @@ establishing_session
ethernet_config
ethernet_enabled
ethernet_ip
+event_use_event_theme
exchange_position
expand_chart
expanded
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 3eaf74002..efd73d9cd 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -305,6 +305,8 @@ dependencies {
// maps-compose-widgets requests androidx.compose.material:material version-less (expects a BOM
// we exclude). Name it with a version so the version is published in the app's graph metadata.
googleImplementation(libs.androidx.compose.material)
+ // Downloadable Google Fonts for event-firmware branding (Play Services font provider — Google flavor only).
+ googleImplementation(libs.androidx.compose.ui.text.google.fonts)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
googleImplementation(libs.dd.sdk.android.session.replay)
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
index a659cc5b4..56ea1f9cc 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -19,10 +19,14 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.ui.theme.EventFontResolver
@Module(includes = [FDroidNetworkModule::class, FdroidAiModule::class])
class FlavorModule {
@Single
@Named("googleServicesAvailable")
fun googleServicesAvailable(): Boolean = false
+
+ /** No Play Services font provider on F-Droid — event fonts stay off; UI falls back to the app typeface. */
+ @Single fun eventFontResolver(): EventFontResolver = EventFontResolver { null }
}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index d59a5890e..71ea8d1d5 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -19,7 +19,10 @@
package org.meshtastic.app.di
import org.koin.core.annotation.Module
+import org.koin.core.annotation.Single
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
+import org.meshtastic.app.theme.GoogleFontsEventFontResolver
+import org.meshtastic.core.ui.theme.EventFontResolver
import org.meshtastic.feature.car.di.FeatureCarModule
@Module(
@@ -32,4 +35,7 @@ import org.meshtastic.feature.car.di.FeatureCarModule
FeatureCarModule::class,
],
)
-class FlavorModule
+class FlavorModule {
+ /** Downloadable Google Fonts for event branding — Google flavor only. */
+ @Single fun eventFontResolver(): EventFontResolver = GoogleFontsEventFontResolver()
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/theme/GoogleFontsEventFontResolver.kt b/androidApp/src/google/kotlin/org/meshtastic/app/theme/GoogleFontsEventFontResolver.kt
new file mode 100644
index 000000000..a8b9f40b1
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/theme/GoogleFontsEventFontResolver.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.app.theme
+
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.googlefonts.Font
+import androidx.compose.ui.text.googlefonts.GoogleFont
+import org.meshtastic.app.R
+import org.meshtastic.core.model.EventFirmwareFonts
+import org.meshtastic.core.ui.theme.EventFontResolver
+import org.meshtastic.core.ui.theme.EventFonts
+
+/**
+ * Google-flavor [EventFontResolver]: turns an edition's Google Font family names into downloadable [FontFamily]s via
+ * the Play Services font provider. Unknown/typo names or an unavailable provider fail gracefully — Compose falls back
+ * to the default typeface. The cert array is provided transitively by play-services (basement) that the Google flavor
+ * already pulls for Maps. F-Droid never binds this (no provider); it keeps the null default resolver.
+ */
+class GoogleFontsEventFontResolver : EventFontResolver {
+
+ override fun resolve(fonts: EventFirmwareFonts?): EventFonts? {
+ if (fonts == null) return null
+ val heading = fonts.heading.toFontFamily(FontWeight.Bold)
+ val body = fonts.body.toFontFamily(FontWeight.Normal)
+ return if (heading == null && body == null) null else EventFonts(heading = heading, body = body)
+ }
+
+ private fun String?.toFontFamily(weight: FontWeight): FontFamily? = this?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { FontFamily(Font(googleFont = GoogleFont(it), fontProvider = PROVIDER, weight = weight)) }
+
+ private companion object {
+ private val PROVIDER =
+ GoogleFont.Provider(
+ providerAuthority = "com.google.android.gms.fonts",
+ providerPackage = "com.google.android.gms",
+ certificates = R.array.com_google_android_gms_fonts_certs,
+ )
+ }
+}
diff --git a/androidApp/src/google/res/values/font_certs.xml b/androidApp/src/google/res/values/font_certs.xml
new file mode 100644
index 000000000..a09ad32f9
--- /dev/null
+++ b/androidApp/src/google/res/values/font_certs.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ - @array/com_google_android_gms_fonts_certs_dev
+ - @array/com_google_android_gms_fonts_certs_prod
+
+
+ -
+ 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
+
+
+
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 288b7bb9c..b9afb9633 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -37,6 +37,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -50,6 +51,7 @@ import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
@@ -68,6 +70,11 @@ import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.startService
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.theme.EventFontResolver
+import org.meshtastic.core.ui.theme.EventTheme
+import org.meshtastic.core.ui.theme.EventThemeToggle
+import org.meshtastic.core.ui.theme.LocalEventTheme
+import org.meshtastic.core.ui.theme.LocalEventThemeToggle
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
@@ -86,6 +93,7 @@ import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
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.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
@@ -192,8 +200,23 @@ class MainActivity : AppCompatActivity() {
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
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.
+ val eventAccent = eventEdition?.accentColorOrNull()
+ 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)
+ } else {
+ null
+ },
+ LocalEventThemeToggle provides
+ EventThemeToggle(enabled = eventThemeEnabled, onChange = model::setEventThemeEnabled),
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalNfcWriterProvider provides { url, onResult, onDisabled -> NfcWriterEffect(url, onResult, onDisabled) },
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
index 5fb8cc0f6..e71b614b9 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
@@ -26,6 +26,7 @@ import org.meshtastic.core.data.datasource.EventFirmwareEditionLocalDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareBuild
import org.meshtastic.core.model.EventFirmwareEdition
+import org.meshtastic.core.model.EventFirmwareFonts
import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.EventFirmwareTheme
import org.meshtastic.core.model.EventFirmwareThemeColors
@@ -187,6 +188,7 @@ class EventFirmwareRepositoryImplTest {
name = "Radio Adventure",
palette = listOf("#BF1E2E"),
colors = EventFirmwareThemeColors(primary = "#BF1E2E"),
+ fonts = EventFirmwareFonts(heading = "Lato", body = "Atkinson Hyperlegible"),
),
firmware = EventFirmwareBuild(version = "2.7.23.07741e6", zipUrl = "https://example/f.zip"),
),
@@ -199,6 +201,8 @@ class EventFirmwareRepositoryImplTest {
assertEquals("Radio Adventure", got.theme?.name)
assertEquals("#BF1E2E", got.theme?.colors?.primary)
assertEquals(listOf("#BF1E2E"), got.theme?.palette)
+ assertEquals("Lato", got.theme?.fonts?.heading)
+ assertEquals("Atkinson Hyperlegible", got.theme?.fonts?.body)
assertEquals("2.7.23.07741e6", got.firmware?.version)
assertEquals("https://example/f.zip", got.firmware?.zipUrl)
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt
index 613c48fa1..51658bd96 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EventFirmware.kt
@@ -81,10 +81,7 @@ data class EventFirmwareEdition(
@JsonIgnoreUnknownKeys
data class EventFirmwareLink(val label: String = "", val url: String = "")
-/**
- * Richer event branding beyond a single accent color. `fonts` is intentionally omitted — it is `null` in the current
- * feed and its shape is unspecified, so it is left to [JsonIgnoreUnknownKeys] rather than modeled prematurely.
- */
+/** Richer event branding beyond a single accent color. Not all editions carry a full theme. */
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@@ -93,8 +90,19 @@ data class EventFirmwareTheme(
val tagline: String? = null,
val colors: EventFirmwareThemeColors? = null,
val palette: List = emptyList(),
+ val fonts: EventFirmwareFonts? = null,
)
+/**
+ * Font-family names for event branding — Google Font family names (e.g. `Lato` / `Atkinson Hyperlegible`), not URLs.
+ * Captured so the data is available; actually rendering with these fonts is a separate, platform-specific concern (a
+ * downloadable-font provider is Google-flavor only), so consumers may ignore them and fall back to the app typeface.
+ */
+@Serializable
+@OptIn(ExperimentalSerializationApi::class)
+@JsonIgnoreUnknownKeys
+data class EventFirmwareFonts(val heading: String? = null, val body: String? = null)
+
/** Named brand colors (`#RRGGBB`) for an event theme; any may be absent. */
@Serializable
@OptIn(ExperimentalSerializationApi::class)
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
index 13fc9d442..4b0acb511 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -135,6 +135,13 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_SHOW_QUICK_CHAT_PREF] = show } }
}
+ override val eventThemeEnabled: StateFlow =
+ dataStore.data.map { it[KEY_EVENT_THEME_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setEventThemeEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_EVENT_THEME_ENABLED] = enabled } }
+ }
+
override val bleAutoScan: StateFlow =
dataStore.data.map { it[KEY_BLE_AUTO_SCAN] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
@@ -268,6 +275,7 @@ class UiPrefsImpl(
companion object {
val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning")
val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat")
+ val KEY_EVENT_THEME_ENABLED = booleanPreferencesKey("event-theme-enabled")
val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed")
val KEY_THEME = intPreferencesKey("theme")
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 134063e66..a84cac27d 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -124,6 +124,13 @@ interface UiPrefs {
fun setShowQuickChat(show: Boolean)
+ /**
+ * Whether to apply an event edition's ambient theme (accent wash + custom typeface) app-wide (opt-out; default on).
+ */
+ val eventThemeEnabled: StateFlow
+
+ fun setEventThemeEnabled(enabled: Boolean)
+
/** Whether BLE scanning should auto-start when the Connections screen is opened. */
val bleAutoScan: StateFlow
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 95ef6cc5b..3d56bbffa 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -567,6 +567,7 @@
Ethernet Options
Ethernet enabled
Ethernet IP:
+ Use event theme
Exchange position
Expand chart
Expanded
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index a6fe955e0..f8695f97d 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -151,6 +151,12 @@ class FakeUiPrefs : UiPrefs {
showQuickChat.value = show
}
+ override val eventThemeEnabled = MutableStateFlow(true)
+
+ override fun setEventThemeEnabled(enabled: Boolean) {
+ eventThemeEnabled.value = enabled
+ }
+
override val bleAutoScan = MutableStateFlow(false)
override fun setBleAutoScan(enabled: Boolean) {
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 77dfd0903..8fcb3bca5 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
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -32,6 +33,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -42,15 +44,20 @@ import androidx.compose.ui.graphics.Color
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.unit.dp
+import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.EventFirmwareEdition
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.event_use_event_theme
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.ChevronRight
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.util.EventBrandingIcon
import org.meshtastic.core.ui.util.accentColorOrNull
@@ -90,6 +97,30 @@ fun EventInfoSheet(edition: EventFirmwareEdition, onDismiss: () -> Unit) {
}
}
}
+
+ // 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)
+ }
}
}
}
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 0b1fb79cc..6ff9f2fd4 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
@@ -50,9 +50,9 @@ import org.meshtastic.core.resources.ic_meshtastic
import org.meshtastic.core.resources.navigate_back
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.theme.LocalEventTheme
import org.meshtastic.core.ui.util.EventBrandingIcon
import org.meshtastic.core.ui.util.LocalEventBranding
-import org.meshtastic.core.ui.util.accentColorOrNull
/** 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
@@ -71,8 +71,9 @@ fun MainAppBar(
onClickChip: (Node) -> Unit,
brandingContent: @Composable () -> Unit = { EventAwareBranding() },
) {
- // Ambient event theming: when connected to event firmware, tint the bar with a faint wash of its accent color.
- val accent = LocalEventBranding.current?.accentColorOrNull()
+ // 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 colors =
if (accent != null) {
TopAppBarDefaults.topAppBarColors(
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
new file mode 100644
index 000000000..95c231814
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/EventFonts.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.material3.Typography
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import org.meshtastic.core.model.EventFirmwareFonts
+
+/**
+ * Resolved heading/body typefaces for an active event edition. Either component may be `null` (fall back to default).
+ */
+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.
+ */
+data class EventTheme(val accent: Color? = null, val fonts: EventFonts? = null)
+
+/**
+ * Resolves an edition's [EventFirmwareFonts] (Google Font *family names*, e.g. `Lato`) into loadable [FontFamily]s.
+ *
+ * The default binding returns `null` (no custom fonts). The **Google** flavor binds a downloadable-fonts
+ * implementation; **F-Droid** keeps the null default (no Play font provider). Resolved via Koin and applied at the app
+ * theme via [LocalEventTheme].
+ */
+fun interface EventFontResolver {
+ fun resolve(fonts: EventFirmwareFonts?): EventFonts?
+}
+
+/** The applied ambient event theme, or `null` for the default look. See [EventTheme]. */
+@Suppress("CompositionLocalAllowlist")
+val LocalEventTheme = compositionLocalOf { null }
+
+/**
+ * Sheet-level opt-out for the ambient event theme (accent wash + fonts). Shown whenever the event info sheet is open
+ * (which only happens for an active event, and every event carries an accent), so no availability gating is needed.
+ */
+data class EventThemeToggle(val enabled: Boolean = true, val onChange: (Boolean) -> Unit = {})
+
+@Suppress("CompositionLocalAllowlist")
+val LocalEventThemeToggle = compositionLocalOf { EventThemeToggle() }
+
+private fun TextStyle.family(family: FontFamily?): TextStyle = if (family != null) copy(fontFamily = family) else this
+
+/**
+ * Applies [fonts] across the full M3 typescale: [EventFonts.heading] to display/headline/title styles,
+ * [EventFonts.body] to body/label styles. A `null` component leaves those styles unchanged.
+ */
+fun Typography.withEventFonts(fonts: EventFonts): Typography {
+ val h = fonts.heading
+ val b = fonts.body
+ return copy(
+ displayLarge = displayLarge.family(h),
+ displayMedium = displayMedium.family(h),
+ displaySmall = displaySmall.family(h),
+ headlineLarge = headlineLarge.family(h),
+ headlineMedium = headlineMedium.family(h),
+ headlineSmall = headlineSmall.family(h),
+ titleLarge = titleLarge.family(h),
+ titleMedium = titleMedium.family(h),
+ titleSmall = titleSmall.family(h),
+ bodyLarge = bodyLarge.family(b),
+ bodyMedium = bodyMedium.family(b),
+ bodySmall = bodySmall.family(b),
+ labelLarge = labelLarge.family(b),
+ labelMedium = labelMedium.family(b),
+ labelSmall = labelSmall.family(b),
+ )
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt
index acce06a76..a856728be 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt
@@ -27,7 +27,12 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFontFamilyResolver
+import co.touchlab.kermit.Logger
+import kotlin.coroutines.cancellation.CancellationException
private val lightScheme =
lightColorScheme(
@@ -128,7 +133,30 @@ fun AppTheme(
null
} ?: if (darkTheme) darkScheme else lightScheme
- MaterialExpressiveTheme(colorScheme = colorScheme, typography = AppTypography, motionScheme = expressive()) {
+ // When a device is on event firmware (and the event theme isn't opted out), swap the whole typescale to the event
+ // typeface. Null everywhere else (desktop, F-Droid, non-event, opted out) → default typography.
+ val eventFonts = LocalEventTheme.current?.fonts
+ val typography = remember(eventFonts) { eventFonts?.let { AppTypography.withEventFonts(it) } ?: AppTypography }
+
+ // Downloadable (Google) fonts referenced only through the theme's Typography are NOT auto-fetched during text
+ // layout — Compose silently renders the fallback. Preloading them into the font cache is the documented way to make
+ // them actually apply; once cached, the themed Text re-resolves to the real typeface. No-op when there are no event
+ // fonts (desktop / F-Droid / non-event / opted out).
+ val fontResolver = LocalFontFamilyResolver.current
+ LaunchedEffect(eventFonts, fontResolver) {
+ val fonts = eventFonts ?: return@LaunchedEffect
+ listOfNotNull(fonts.heading, fonts.body).forEach { family ->
+ try {
+ fontResolver.preload(family)
+ } catch (e: CancellationException) {
+ throw e // preload suspends; never swallow structured-concurrency cancellation
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "Event font preload failed for $family; falling back to the default typeface" }
+ }
+ }
+ }
+
+ MaterialExpressiveTheme(colorScheme = colorScheme, typography = typography, motionScheme = expressive()) {
content()
}
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index 076543237..6fd72c5b3 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -128,6 +128,11 @@ class UIViewModel(
val theme: StateFlow = uiPrefs.theme
+ /** Opt-out for applying an event edition's ambient theme (accent + typeface) app-wide. */
+ val eventThemeEnabled: StateFlow = uiPrefs.eventThemeEnabled
+
+ fun setEventThemeEnabled(enabled: Boolean) = uiPrefs.setEventThemeEnabled(enabled)
+
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
val eventEdition: StateFlow =
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 04026e539..681ebea47 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -165,6 +165,7 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani
# maps-compose-widgets (google), declared explicitly so the version propagates to consumers of the
# app's graph (e.g. :baselineprofile); see androidApp/build.gradle.kts.
androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose-bom-aligned" }
+androidx-compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "androidx-compose-bom-aligned" }
# Compose Multiplatform
compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" }