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