feat: event-theme fonts — capture + apply app-wide (Google flavor) with sheet opt-out (#6163)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-08 22:04:50 -05:00
committed by GitHub
parent 3e86c377b2
commit bd14228fb6
19 changed files with 322 additions and 9 deletions

View File

@@ -543,6 +543,7 @@ establishing_session
ethernet_config
ethernet_enabled
ethernet_ip
event_use_event_theme
exchange_position
expand_chart
expanded

View File

@@ -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)

View File

@@ -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 }
}

View File

@@ -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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2017 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
</item>
</string-array>
</resources>

View File

@@ -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<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.
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) },

View File

@@ -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)
}

View File

@@ -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<String> = 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)

View File

@@ -135,6 +135,13 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_SHOW_QUICK_CHAT_PREF] = show } }
}
override val eventThemeEnabled: StateFlow<Boolean> =
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<Boolean> =
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")

View File

@@ -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<Boolean>
fun setEventThemeEnabled(enabled: Boolean)
/** Whether BLE scanning should auto-start when the Connections screen is opened. */
val bleAutoScan: StateFlow<Boolean>

View File

@@ -567,6 +567,7 @@
<string name="ethernet_config">Ethernet Options</string>
<string name="ethernet_enabled">Ethernet enabled</string>
<string name="ethernet_ip">Ethernet IP:</string>
<string name="event_use_event_theme">Use event theme</string>
<string name="exchange_position">Exchange position</string>
<string name="expand_chart">Expand chart</string>
<string name="expanded">Expanded</string>

View File

@@ -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) {

View File

@@ -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)
}
}
}
}

View File

@@ -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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<EventTheme?> { 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),
)
}

View File

@@ -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()
}
}

View File

@@ -128,6 +128,11 @@ class UIViewModel(
val theme: StateFlow<Int> = uiPrefs.theme
/** Opt-out for applying an event edition's ambient theme (accent + typeface) app-wide. */
val eventThemeEnabled: StateFlow<Boolean> = uiPrefs.eventThemeEnabled
fun setEventThemeEnabled(enabled: Boolean) = uiPrefs.setEventThemeEnabled(enabled)
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
val eventEdition: StateFlow<EventFirmwareEdition?> =

View File

@@ -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" }