mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-10 05:15:46 -04:00
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:
1
.skills/compose-ui/strings-index.txt
generated
1
.skills/compose-ui/strings-index.txt
generated
@@ -543,6 +543,7 @@ establishing_session
|
||||
ethernet_config
|
||||
ethernet_enabled
|
||||
ethernet_ip
|
||||
event_use_event_theme
|
||||
exchange_position
|
||||
expand_chart
|
||||
expanded
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
32
androidApp/src/google/res/values/font_certs.xml
Normal file
32
androidApp/src/google/res/values/font_certs.xml
Normal 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>
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?> =
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user