diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index a2de42c..4565119 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -32,8 +32,11 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -97,9 +100,20 @@ class MainActivity : AppCompatActivity() { val use24Hour = remember(settings.timeFormat, context) { settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) } + // The user's custom-font choice, resolved to a Material typography + // (issue #19). Recomputed only when a token changes; "system for both" + // returns the default scale untouched. + val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() + val typography = remember(fonts, context) { + calendulaTypography( + brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context), + plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context), + ) + } CalendulaTheme( darkTheme = darkTheme, dynamicColor = settings.dynamicColor, + typography = typography, ) { CompositionLocalProvider( LocalUse24HourFormat provides use24Hour, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/fonts/CustomFontStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/fonts/CustomFontStore.kt new file mode 100644 index 0000000..d27ec99 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/fonts/CustomFontStore.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.data.fonts + +import android.content.Context +import android.net.Uri +import de.jeanlucmakiola.calendula.domain.FontRole +import java.io.File +import java.util.Locale +import android.graphics.fonts.Font as PlatformFont + +/** + * Storage for user-loaded custom fonts (issue #19). A font a user picks via the + * system file picker is copied into the app's private storage — one file per + * [FontRole] — so the selection survives even if the original is later moved or + * deleted, and never leaves the app. The chosen file is validated as a real font + * before it replaces the previous one, so a bad pick can't wedge the app-wide + * typography. + */ +object CustomFontStore { + + private fun dir(context: Context): File = File(context.filesDir, "fonts") + + /** The stored font file for [role] (may not exist yet). */ + fun file(context: Context, role: FontRole): File = + File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf") + + fun exists(context: Context, role: FontRole): Boolean = + file(context, role).let { it.exists() && it.length() > 0 } + + /** + * Copy [uri] into per-role storage, first validating that it parses as a + * font. Returns true on success; on any failure the previous file is left + * untouched and false is returned (the caller keeps the old selection). + */ + fun import(context: Context, role: FontRole, uri: Uri): Boolean { + val target = file(context, role) + target.parentFile?.mkdirs() + val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp") + return try { + context.contentResolver.openInputStream(uri)?.use { input -> + tmp.outputStream().use { output -> input.copyTo(output) } + } ?: return false + // Font.Builder throws IOException for anything that isn't a valid + // font file (API 29+, which is our minSdk) — a cheap, reliable check. + PlatformFont.Builder(tmp).build() + if (target.exists() && !target.delete()) return false + tmp.renameTo(target) + } catch (_: Exception) { + tmp.delete() + false + } + } + + /** Forget the custom font for [role] (used when switching away from it). */ + fun clear(context: Context, role: FontRole) { + file(context, role).delete() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 1a1f1d5..9937ca7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -104,6 +105,27 @@ class SettingsPrefs @Inject constructor( store.edit { it[DYNAMIC_COLOR_KEY] = enabled } } + /** + * Custom-font tokens per Material typeface role (issue #19). Stored as opaque + * strings — "system", "custom", or a bundled font's token — resolved to a + * FontFamily at the theme layer; an unknown token degrades to the default. + */ + val brandFont: Flow = store.data.map { prefs -> + prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN + } + + val plainFont: Flow = store.data.map { prefs -> + prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN + } + + suspend fun setBrandFont(token: String) { + store.edit { it[BRAND_FONT_KEY] = token } + } + + suspend fun setPlainFont(token: String) { + store.edit { it[PLAIN_FONT_KEY] = token } + } + suspend fun setWeekStart(pref: WeekStartPref) { store.edit { it[WEEK_START_KEY] = pref.storageValue() } } @@ -436,6 +458,8 @@ class SettingsPrefs @Inject constructor( companion object { internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") + internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") + internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/FontRole.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/FontRole.kt new file mode 100644 index 0000000..92ddc93 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/FontRole.kt @@ -0,0 +1,8 @@ +package de.jeanlucmakiola.calendula.domain + +/** + * The two Material 3 typeface roles a user can set independently (issue #19): + * [BRAND] drives the display/headline styles (expression), [PLAIN] the + * title/body/label styles (readability). Each defaults to the system typeface. + */ +enum class FontRole { BRAND, PLAIN } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index d3fc450..95870ca 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -7,9 +7,11 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Icon +import android.net.Uri import android.os.Build import android.os.PowerManager import android.provider.Settings +import android.widget.Toast import androidx.annotation.RequiresApi import android.text.format.DateFormat import androidx.activity.compose.rememberLauncherForActivityResult @@ -51,6 +53,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -60,6 +63,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -74,6 +78,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -95,9 +100,11 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow @@ -115,6 +122,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel +import de.jeanlucmakiola.calendula.ui.theme.BundledFont +import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN +import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalTime import java.time.format.TextStyle as JavaTextStyle @@ -477,6 +488,18 @@ private fun AppearanceScreen( var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) } + var showBrandFont by remember { mutableStateOf(false) } + var showPlainFont by remember { mutableStateOf(false) } + + val fonts by viewModel.fontState.collectAsStateWithLifecycle() + // A picked file that didn't parse as a font: tell the user and keep the old choice. + val context = LocalContext.current + val importFailedMessage = stringResource(R.string.settings_font_import_failed) + LaunchedEffect(Unit) { + viewModel.fontImportFailed.collect { + Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show() + } + } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), @@ -513,6 +536,24 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) + // Fonts — the two Material typeface roles, each independently choosable + // (issue #19). Headings = brand (display/headline); body = plain + // (title/body/label). Both default to the system typeface. + GroupedRow( + title = stringResource(R.string.settings_font_headings), + summary = fontLabel(fonts.brand), + position = Position.Top, + onClick = { showBrandFont = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_font_body), + summary = fontLabel(fonts.plain), + position = Position.Bottom, + onClick = { showPlainFont = true }, + ) + + Spacer(Modifier.height(16.dp)) + // Calendar — view, week, and timeline formatting GroupedRow( title = stringResource(R.string.settings_default_view), @@ -604,6 +645,26 @@ private fun AppearanceScreen( onDismiss = { showTheme = false }, ) } + if (showBrandFont) { + FontPicker( + title = stringResource(R.string.settings_font_headings), + role = FontRole.BRAND, + selected = fonts.brand, + onSelect = { viewModel.setFont(FontRole.BRAND, it) }, + onImport = { viewModel.importCustomFont(FontRole.BRAND, it) }, + onDismiss = { showBrandFont = false }, + ) + } + if (showPlainFont) { + FontPicker( + title = stringResource(R.string.settings_font_body), + role = FontRole.PLAIN, + selected = fonts.plain, + onSelect = { viewModel.setFont(FontRole.PLAIN, it) }, + onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) }, + onDismiss = { showPlainFont = false }, + ) + } if (showWeekStart) { OptionPicker( title = stringResource(R.string.settings_week_start), @@ -1180,6 +1241,128 @@ private fun themeLabel(mode: ThemeMode): String = stringResource( }, ) +/** The summary label for a stored font token (issue #19). */ +@Composable +private fun fontLabel(token: String): String = when (token) { + FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system) + FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected) + else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) } + ?: stringResource(R.string.settings_font_system) +} + +/** + * Full-screen font chooser for one [FontRole]: the system default, each bundled + * font (previewed in its own face), and "Choose file…" which opens the system + * picker to load a .ttf/.otf. Selecting a system/bundled option applies at once; + * a file is validated and imported by the caller, switching to the custom font + * on success. + */ +@Composable +private fun FontPicker( + title: String, + role: FontRole, + selected: String, + onSelect: (String) -> Unit, + onImport: (Uri) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + onImport(uri) + onDismiss() + } + } + + // System default + the bundled fonts + the "Choose file…" row. + val rowCount = BundledFont.entries.size + 2 + val isCustom = selected == FONT_CUSTOM_TOKEN + + FullScreenPicker(title = title, onDismiss = onDismiss) { + FontOptionRow( + label = stringResource(R.string.settings_font_system), + preview = FontFamily.Default, + selected = selected == FONT_SYSTEM_TOKEN, + position = positionOf(0, rowCount), + onClick = { + onSelect(FONT_SYSTEM_TOKEN) + onDismiss() + }, + ) + BundledFont.entries.forEachIndexed { index, font -> + FontOptionRow( + label = stringResource(font.labelRes), + preview = font.family, + selected = selected == font.token, + position = positionOf(index + 1, rowCount), + onClick = { + onSelect(font.token) + onDismiss() + }, + ) + } + FontOptionRow( + label = if (isCustom) { + stringResource(R.string.settings_font_custom_selected) + } else { + stringResource(R.string.settings_font_choose_file) + }, + // A loaded font previews in its own face; otherwise show an upload cue. + preview = if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null, + leadingIcon = if (isCustom) null else Icons.Default.UploadFile, + selected = isCustom, + position = positionOf(rowCount - 1, rowCount), + onClick = { launcher.launch(arrayOf("*/*")) }, + ) + } +} + +/** + * One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in + * the option's own [preview] face (or an [leadingIcon] cue when there's nothing + * to preview), and a check when it's the current selection. + */ +@Composable +private fun FontOptionRow( + label: String, + preview: FontFamily?, + selected: Boolean, + position: Position, + onClick: () -> Unit, + leadingIcon: ImageVector? = null, +) { + GroupedRow( + title = label, + position = position, + selected = selected, + leading = { + if (preview != null) { + Text( + text = "Ag", + fontFamily = preview, + style = MaterialTheme.typography.titleLarge, + ) + } else if (leadingIcon != null) { + Icon(imageVector = leadingIcon, contentDescription = null) + } + }, + trailing = if (selected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + onClick = onClick, + ) +} + /** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ private val WEEK_START_OPTIONS: List = listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 557ab21..e2889e8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context +import android.net.Uri import android.os.Build import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.updateAppWidgetState @@ -10,6 +11,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs @@ -18,14 +21,20 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings +import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -35,12 +44,14 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, @ApplicationContext private val appContext: Context, ) : ViewModel() { @@ -131,6 +142,27 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) + /** + * The app-wide custom-font choice (issue #19). Its own flow — both this + * screen and [MainActivity] (which builds the typography) read it + * independently of the main settings combine. + */ + val fontState: StateFlow = combine( + prefs.brandFont, + prefs.plainFont, + ) { brand, plain -> + AppFontSettings(brand = brand, plain = plain) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = AppFontSettings(), + ) + + // Emitted when a picked font file couldn't be read as a font; the screen + // surfaces it and the previous selection stays put. + private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) + val fontImportFailed: SharedFlow = _fontImportFailed + // Serialises widget redraws so a rapid flip-and-flip-back can't run two // updateAll calls at once — concurrent calls coalesce around a stale read // and can leave the widget on the intermediate value. Held sequentially, @@ -174,6 +206,31 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setDynamicColor(enabled) } } + fun setFont(role: FontRole, token: String) { + viewModelScope.launch { + when (role) { + FontRole.BRAND -> prefs.setBrandFont(token) + FontRole.PLAIN -> prefs.setPlainFont(token) + } + } + } + + /** + * Import a user-picked font file for [role]. Copies and validates it off the + * main thread; on success the role switches to the custom font, otherwise the + * previous choice is kept and [fontImportFailed] fires. + */ + fun importCustomFont(role: FontRole, uri: Uri) { + viewModelScope.launch { + val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) } + if (ok) { + setFont(role, FONT_CUSTOM_TOKEN) + } else { + _fontImportFailed.emit(Unit) + } + } + } + fun setWeekStart(pref: WeekStartPref) { viewModelScope.launch { prefs.setWeekStart(pref) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt new file mode 100644 index 0000000..8b2b135 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore +import de.jeanlucmakiola.calendula.domain.FontRole +import java.io.File + +/** Stored token for "use the device default typeface" — the default for both roles. */ +const val FONT_SYSTEM_TOKEN = "system" + +/** Stored token for "use the font the user loaded from a file" (per role). */ +const val FONT_CUSTOM_TOKEN = "custom" + +/** + * A font checked into the app that a user can pick without providing a file + * (issue #19). [token] is the stable value persisted in preferences; [family] is + * the resolved Compose family. Kept deliberately small and readability-focused. + */ +enum class BundledFont( + val token: String, + @StringRes val labelRes: Int, + val family: FontFamily, +) { + /** Purpose-built for low-vision legibility (Braille Institute). */ + AtkinsonHyperlegible( + token = "atkinson_hyperlegible", + labelRes = R.string.font_atkinson_hyperlegible, + family = FontFamily( + Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal), + Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold), + ), + ), + + /** A serif alternative; shipped as a variable font, weights via its wght axis. */ + Lora( + token = "lora", + labelRes = R.string.font_lora, + family = FontFamily( + Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)), + Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)), + Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)), + ), + ), + + /** A monospace option. */ + JetBrainsMono( + token = "jetbrains_mono", + labelRes = R.string.font_jetbrains_mono, + family = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold), + ), + ); + + companion object { + fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token } + } +} + +private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight)) + +/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */ +data class AppFontSettings( + val brand: String = FONT_SYSTEM_TOKEN, + val plain: String = FONT_SYSTEM_TOKEN, +) + +/** + * Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean + * "leave the Material default" (the system typeface). A custom token whose file + * is missing or unreadable also resolves to null, so a deleted file degrades to + * the system font rather than crashing text layout. + */ +fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) { + FONT_SYSTEM_TOKEN -> null + FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role)) + else -> BundledFont.fromToken(token)?.family +} + +private fun customFontFamily(file: File): FontFamily? = + if (file.exists() && file.length() > 0) { + runCatching { FontFamily(Font(file)) }.getOrNull() + } else { + null + } + +/** + * Build the app typography from the chosen [brand] and [plain] families. A null + * family leaves that role's Material default (system typeface) in place, so the + * common "system for both" case returns [CalendulaTypography] untouched. Brand + * drives display + headline; plain drives title + body + label (M3's two + * typeface roles). + */ +fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography { + if (brand == null && plain == null) return CalendulaTypography + val base = CalendulaTypography + fun TextStyle.withFamily(family: FontFamily?): TextStyle = + if (family != null) copy(fontFamily = family) else this + return base.copy( + displayLarge = base.displayLarge.withFamily(brand), + displayMedium = base.displayMedium.withFamily(brand), + displaySmall = base.displaySmall.withFamily(brand), + headlineLarge = base.headlineLarge.withFamily(brand), + headlineMedium = base.headlineMedium.withFamily(brand), + headlineSmall = base.headlineSmall.withFamily(brand), + titleLarge = base.titleLarge.withFamily(plain), + titleMedium = base.titleMedium.withFamily(plain), + titleSmall = base.titleSmall.withFamily(plain), + bodyLarge = base.bodyLarge.withFamily(plain), + bodyMedium = base.bodyMedium.withFamily(plain), + bodySmall = base.bodySmall.withFamily(plain), + labelLarge = base.labelLarge.withFamily(plain), + labelMedium = base.labelMedium.withFamily(plain), + labelSmall = base.labelSmall.withFamily(plain), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt index 8b42f63..652aa77 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme +import androidx.compose.material3.Typography import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable @@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext * - System light/dark. * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme * derived from [CalendulaSeed]. + * - A user-chosen [typography] (custom fonts, issue #19), defaulting to the + * Material scale on the system typeface. * - * The Settings screen (later) can override useDynamicColor and themePreference, - * but the V1 foundation just follows the system. + * The Settings screen overrides darkTheme, dynamicColor, and typography; the + * bare defaults (used by the crash screen) just follow the system. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CalendulaTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + typography: Typography = CalendulaTypography, content: @Composable () -> Unit, ) { val colorScheme = when { @@ -42,7 +46,7 @@ fun CalendulaTheme( // the bouncy variant felt overdone in review (2026-06-11). MaterialExpressiveTheme( colorScheme = colorScheme, - typography = CalendulaTypography, + typography = typography, motionScheme = MotionScheme.standard(), content = content, ) diff --git a/app/src/main/res/font/atkinson_hyperlegible_bold.ttf b/app/src/main/res/font/atkinson_hyperlegible_bold.ttf new file mode 100644 index 0000000..ca3f613 Binary files /dev/null and b/app/src/main/res/font/atkinson_hyperlegible_bold.ttf differ diff --git a/app/src/main/res/font/atkinson_hyperlegible_regular.ttf b/app/src/main/res/font/atkinson_hyperlegible_regular.ttf new file mode 100644 index 0000000..b3cc420 Binary files /dev/null and b/app/src/main/res/font/atkinson_hyperlegible_regular.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_bold.ttf b/app/src/main/res/font/jetbrains_mono_bold.ttf new file mode 100644 index 0000000..cd1bee0 Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_bold.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_regular.ttf b/app/src/main/res/font/jetbrains_mono_regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_regular.ttf differ diff --git a/app/src/main/res/font/lora.ttf b/app/src/main/res/font/lora.ttf new file mode 100644 index 0000000..ee1914c Binary files /dev/null and b/app/src/main/res/font/lora.ttf differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9b83e46..100a87b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -252,6 +252,15 @@ Dunkel Dynamische Farben Erfordert Android 12 oder neuer + Schriftart für Überschriften + Schriftart für Text + Systemstandard + Atkinson Hyperlegible + Lora + JetBrains Mono + Datei auswählen… + Eigene Schriftart + Diese Datei konnte nicht als Schriftart gelesen werden Wochenstart Automatisch Zeitformat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d121f90..4db478a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,6 +274,15 @@ Default view Dynamic colour Requires Android 12 or newer + Headings font + Body font + System default + Atkinson Hyperlegible + Lora + JetBrains Mono + Choose file… + Custom font + Couldn\'t read that file as a font Week starts on Automatic Time format diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt new file mode 100644 index 0000000..998ef53 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class FontsTest { + + @Test + fun `fromToken round-trips every bundled font`() { + BundledFont.entries.forEach { font -> + assertThat(BundledFont.fromToken(font.token)).isEqualTo(font) + } + } + + @Test + fun `fromToken is null for system, custom, and unknown tokens`() { + assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull() + assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull() + assertThat(BundledFont.fromToken("not-a-font")).isNull() + assertThat(BundledFont.fromToken(null)).isNull() + } + + @Test + fun `system for both roles returns the untouched default typography`() { + assertThat(calendulaTypography(brand = null, plain = null)) + .isSameInstanceAs(CalendulaTypography) + } + + @Test + fun `brand drives display and headline, plain drives title, body, and label`() { + val brand = BundledFont.Lora.family + val plain = BundledFont.JetBrainsMono.family + val typography = calendulaTypography(brand = brand, plain = plain) + + assertThat(typography.displayLarge.fontFamily).isEqualTo(brand) + assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand) + assertThat(typography.titleLarge.fontFamily).isEqualTo(plain) + assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain) + assertThat(typography.labelSmall.fontFamily).isEqualTo(plain) + } + + @Test + fun `a null role keeps that role's default family while the other is applied`() { + val plain = BundledFont.Lora.family + val typography = calendulaTypography(brand = null, plain = plain) + + // Brand styles keep the Material default; plain styles switch. + assertThat(typography.displayLarge.fontFamily) + .isEqualTo(CalendulaTypography.displayLarge.fontFamily) + assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain) + } +} diff --git a/licenses/fonts/AtkinsonHyperlegible-OFL.txt b/licenses/fonts/AtkinsonHyperlegible-OFL.txt new file mode 100644 index 0000000..2befc89 --- /dev/null +++ b/licenses/fonts/AtkinsonHyperlegible-OFL.txt @@ -0,0 +1,92 @@ +Copyright 2020 Braille Institute of America, Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/fonts/JetBrainsMono-OFL.txt b/licenses/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/licenses/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/fonts/Lora-OFL.txt b/licenses/fonts/Lora-OFL.txt new file mode 100644 index 0000000..d0ce7fe --- /dev/null +++ b/licenses/fonts/Lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/fonts/README.md b/licenses/fonts/README.md new file mode 100644 index 0000000..d413db2 --- /dev/null +++ b/licenses/fonts/README.md @@ -0,0 +1,17 @@ +# Bundled fonts + +Calendula bundles the following fonts as optional user-selectable typefaces +(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font +License 1.1; the full license text is included alongside this file. + +| Font | Files (`app/src/main/res/font/`) | Copyright | License | +|------|----------------------------------|-----------|---------| +| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) | +| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) | +| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) | + +The device's default typeface (Roboto on most Android builds) remains the +default and is not bundled. + +Fonts a user loads from their own device via "Choose file…" are copied into the +app's private storage and are not redistributed.