(feat) custom typography schemes

This commit is contained in:
MM20
2025-06-18 22:28:13 +02:00
parent 4bf9ee8b0c
commit da8416a58c
31 changed files with 2856 additions and 117 deletions
@@ -1,10 +1,5 @@
package de.mm20.launcher2.themes
import de.mm20.launcher2.themes.colors.Color
import de.mm20.launcher2.themes.colors.ColorRef
import de.mm20.launcher2.themes.colors.ColorScheme
import de.mm20.launcher2.themes.colors.CorePaletteColor
import de.mm20.launcher2.themes.colors.StaticColor
import java.util.UUID
@@ -17,4 +12,8 @@ val ExtraRoundShapesId = UUID(0L, 1L)
val CutShapesId = UUID(0L, 2L)
val RectShapesId = UUID(0L, 3L)
val SemiTransparentId = UUID(0L, 1L)
val SemiTransparentId = UUID(0L, 1L)
val SystemFontId = UUID(0L, 1L)
val MonospaceId = UUID(0L, 2L)
val SerifId = UUID(0L, 3L)
@@ -3,6 +3,7 @@ package de.mm20.launcher2.themes
import de.mm20.launcher2.themes.colors.Color
import de.mm20.launcher2.themes.colors.StaticColor
import de.mm20.launcher2.themes.shapes.Shape
import de.mm20.launcher2.themes.typography.FontWeight
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -26,8 +27,9 @@ val ThemeJson = Json {
coerceInputValues = true
}
internal object ColorSerializer: KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ColorSerializer", PrimitiveKind.STRING)
internal object ColorSerializer : KSerializer<Color> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ColorSerializer", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
@@ -42,8 +44,9 @@ internal object ColorSerializer: KSerializer<Color> {
}
}
internal object ShapeSerializer: KSerializer<Shape> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ShapeSerializer", PrimitiveKind.STRING)
internal object ShapeSerializer : KSerializer<Shape> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ShapeSerializer", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
@@ -55,4 +58,34 @@ internal object ShapeSerializer: KSerializer<Shape> {
override fun deserialize(decoder: Decoder): Shape {
return Shape.fromString(decoder.decodeString())!!
}
}
internal object FontWeightSerializer : KSerializer<FontWeight?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("FontWeightSerializer", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
value: FontWeight?
) {
if (value is FontWeight.Absolute) {
encoder.encodeString(value.weight.toString())
} else if (value is FontWeight.Relative) {
encoder.encodeString(
(if (value.relativeWeight >= 0) "+" else "-") + value.relativeWeight.toString()
)
}
}
override fun deserialize(decoder: Decoder): FontWeight? {
val str = decoder.decodeString()
if (str.isBlank()) return null
return when {
str.startsWith("+") -> FontWeight.Relative(str.substring(1).toInt())
str.startsWith("-") -> FontWeight.Relative(-str.substring(1).toInt())
else -> FontWeight.Absolute(str.toInt())
}
}
}
@@ -8,6 +8,7 @@ import de.mm20.launcher2.themes.colors.Colors
import de.mm20.launcher2.themes.colors.ColorsRepository
import de.mm20.launcher2.themes.shapes.ShapesRepository
import de.mm20.launcher2.themes.transparencies.TransparenciesRepository
import de.mm20.launcher2.themes.typography.TypographyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -21,6 +22,7 @@ class ThemeRepository(
val colors = ColorsRepository(context, database)
val shapes = ShapesRepository(context, database)
val transparencies = TransparenciesRepository(context, database)
val typographies = TypographyRepository(context, database)
override suspend fun backup(toDir: File) = withContext(Dispatchers.IO) {
@@ -0,0 +1,127 @@
package de.mm20.launcher2.themes.typography
val DefaultTextStyles = TextStyles(
displayLarge = TextStyle(
fontFamily = "brand",
fontSize = 57,
fontWeight = FontWeight.Absolute(500),
lineHeight = 64,
letterSpacing = -0.25f
),
displayMedium = TextStyle(
fontFamily = "brand",
fontSize = 45,
fontWeight = FontWeight.Absolute(500),
lineHeight = 52,
letterSpacing = 0f
),
displaySmall = TextStyle(
fontFamily = "brand",
fontSize = 36,
fontWeight = FontWeight.Absolute(500),
lineHeight = 44,
letterSpacing = 0f
),
headlineLarge = TextStyle(
fontFamily = "brand",
fontSize = 32,
fontWeight = FontWeight.Absolute(600),
lineHeight = 40,
letterSpacing = 0f
),
headlineMedium = TextStyle(
fontFamily = "brand",
fontSize = 28,
fontWeight = FontWeight.Absolute(600),
lineHeight = 36,
letterSpacing = 0f
),
headlineSmall = TextStyle(
fontFamily = "brand",
fontSize = 24,
fontWeight = FontWeight.Absolute(600),
lineHeight = 32,
letterSpacing = 0f
),
titleLarge = TextStyle(
fontFamily = "brand",
fontSize = 22,
fontWeight = FontWeight.Absolute(600),
lineHeight = 28,
letterSpacing = 0f
),
titleMedium = TextStyle(
fontFamily = "brand",
fontSize = 16,
fontWeight = FontWeight.Absolute(600),
lineHeight = 24,
letterSpacing = 0.15f
),
titleSmall = TextStyle(
fontFamily = "brand",
fontSize = 14,
fontWeight = FontWeight.Absolute(600),
lineHeight = 20,
letterSpacing = 0.1f
),
bodyLarge = TextStyle(
fontFamily = "plain",
fontSize = 16,
fontWeight = FontWeight.Absolute(400),
lineHeight = 24,
letterSpacing = 0.5f
),
bodyMedium = TextStyle(
fontFamily = "plain",
fontSize = 14,
fontWeight = FontWeight.Absolute(400),
lineHeight = 20,
letterSpacing = 0.25f
),
bodySmall = TextStyle(
fontFamily = "plain",
fontSize = 12,
fontWeight = FontWeight.Absolute(400),
lineHeight = 16,
letterSpacing = 0.4f
),
labelLarge = TextStyle(
fontFamily = "brand",
fontSize = 14,
fontWeight = FontWeight.Absolute(500),
lineHeight = 20,
letterSpacing = 0.1f
),
labelMedium = TextStyle(
fontFamily = "brand",
fontSize = 12,
fontWeight = FontWeight.Absolute(500),
lineHeight = 16,
letterSpacing = 0.5f
),
labelSmall = TextStyle(
fontFamily = "brand",
fontSize = 11,
fontWeight = FontWeight.Absolute(500),
lineHeight = 16,
letterSpacing = 0.5f
)
)
val DefaultEmphasizedTextStyles = TextStyles(
displayLarge = TextStyle(fontWeight = FontWeight.Relative(100)),
displayMedium = TextStyle(fontWeight = FontWeight.Relative(100)),
displaySmall = TextStyle(fontWeight = FontWeight.Relative(100)),
headlineLarge = TextStyle(fontWeight = FontWeight.Relative(100)),
headlineMedium = TextStyle(fontWeight = FontWeight.Relative(100)),
headlineSmall = TextStyle(fontWeight = FontWeight.Relative(100)),
titleLarge = TextStyle(fontWeight = FontWeight.Relative(100)),
titleMedium = TextStyle(fontWeight = FontWeight.Relative(100)),
titleSmall = TextStyle(fontWeight = FontWeight.Relative(100)),
bodyLarge = TextStyle(fontWeight = FontWeight.Relative(100)),
bodyMedium = TextStyle(fontWeight = FontWeight.Relative(100)),
bodySmall = TextStyle(fontWeight = FontWeight.Relative(100)),
labelLarge = TextStyle(fontWeight = FontWeight.Relative(200)),
labelMedium = TextStyle(fontWeight = FontWeight.Relative(200)),
labelSmall = TextStyle(fontWeight = FontWeight.Relative(200))
)
@@ -0,0 +1,45 @@
package de.mm20.launcher2.themes.typography
import android.content.Context
import android.graphics.Typeface
import android.graphics.fonts.SystemFonts
import android.os.Build
import android.util.Log
import androidx.core.graphics.TypefaceCompat
data class FontList(
val builtIn: List<FontFamily>,
val generic: List<FontFamily>,
val deviceDefault: List<FontFamily>,
val system: List<FontFamily>,
)
class FontManager(
private val context: Context
) {
fun getInstalledFonts(): FontList {
val deviceHeadlineResId = context.resources
.getIdentifier("config_headlineFontFamily", "string", "android")
val deviceBodyResId = context.resources
.getIdentifier("config_bodyFontFamily", "string", "android")
val deviceHeadlineExists = deviceHeadlineResId != 0 &&
context.resources.getString(deviceHeadlineResId).isNotBlank()
val deviceBodyExists = deviceBodyResId != 0 &&
context.resources.getString(deviceBodyResId).isNotBlank()
return FontList(
builtIn = listOf(FontFamily.LauncherDefault),
generic = listOf(
FontFamily.SansSerif,
FontFamily.Serif,
FontFamily.Monospace,
),
deviceDefault = listOfNotNull(
if (deviceHeadlineExists) FontFamily.DeviceHeadline else null,
if (deviceBodyExists) FontFamily.DeviceBody else null,
),
system = emptyList(),
)
}
}
@@ -0,0 +1,228 @@
package de.mm20.launcher2.themes.typography
import de.mm20.launcher2.database.entities.TypographyEntity
import de.mm20.launcher2.serialization.UUIDSerializer
import de.mm20.launcher2.themes.FontWeightSerializer
import de.mm20.launcher2.themes.ThemeJson
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class Typography(
@Serializable(with = UUIDSerializer::class) val id: UUID = UUID.randomUUID(),
val builtIn: Boolean = false,
val name: String,
/**
* Map of font families used in this typography.
* `null` refers to the default font (sans-serif).
*/
val fonts: Map<String, FontFamily?> = mapOf("brand" to null, "plain" to null),
val styles: TextStyles<@Contextual FontWeight.Absolute?> = TextStyles(),
val emphasizedStyles: TextStyles<FontWeight?> = TextStyles(),
) {
internal constructor(entity: TypographyEntity) : this(
id = entity.id,
builtIn = false,
name = entity.name,
fonts = entity.fonts?.let { ThemeJson.decodeFromString(it) } ?: mapOf(
"brand" to null,
"plain" to null
),
styles = TextStyles(
bodySmall = TextStyle.fromString(entity.bodySmall),
bodyMedium = TextStyle.fromString(entity.bodyMedium),
bodyLarge = TextStyle.fromString(entity.bodyLarge),
labelSmall = TextStyle.fromString(entity.labelSmall),
labelMedium = TextStyle.fromString(entity.labelMedium),
labelLarge = TextStyle.fromString(entity.labelLarge),
titleSmall = TextStyle.fromString(entity.titleSmall),
titleMedium = TextStyle.fromString(entity.titleMedium),
titleLarge = TextStyle.fromString(entity.titleLarge),
headlineSmall = TextStyle.fromString(entity.headlineSmall),
headlineMedium = TextStyle.fromString(entity.headlineMedium),
headlineLarge = TextStyle.fromString(entity.headlineLarge),
displaySmall = TextStyle.fromString(entity.displaySmall),
displayMedium = TextStyle.fromString(entity.displayMedium),
displayLarge = TextStyle.fromString(entity.displayLarge)
),
emphasizedStyles = TextStyles(
bodySmall = TextStyle.fromString(entity.emphasizedBodySmall),
bodyMedium = TextStyle.fromString(entity.emphasizedBodyMedium),
bodyLarge = TextStyle.fromString(entity.emphasizedBodyLarge),
labelSmall = TextStyle.fromString(entity.emphasizedLabelSmall),
labelMedium = TextStyle.fromString(entity.emphasizedLabelMedium),
labelLarge = TextStyle.fromString(entity.emphasizedLabelLarge),
titleSmall = TextStyle.fromString(entity.emphasizedTitleSmall),
titleMedium = TextStyle.fromString(entity.emphasizedTitleMedium),
titleLarge = TextStyle.fromString(entity.emphasizedTitleLarge),
headlineSmall = TextStyle.fromString(entity.emphasizedHeadlineSmall),
headlineMedium = TextStyle.fromString(entity.emphasizedHeadlineMedium),
headlineLarge = TextStyle.fromString(entity.emphasizedHeadlineLarge),
displaySmall = TextStyle.fromString(entity.emphasizedDisplaySmall),
displayMedium = TextStyle.fromString(entity.emphasizedDisplayMedium),
displayLarge = TextStyle.fromString(entity.emphasizedDisplayLarge)
)
)
internal fun toEntity(): TypographyEntity {
return TypographyEntity(
id = id,
name = name,
fonts = ThemeJson.encodeToString(fonts),
displayLarge = styles.displayLarge?.toString(),
displayMedium = styles.displayMedium?.toString(),
displaySmall = styles.displaySmall?.toString(),
headlineLarge = styles.headlineLarge?.toString(),
headlineMedium = styles.headlineMedium?.toString(),
headlineSmall = styles.headlineSmall?.toString(),
titleLarge = styles.titleLarge?.toString(),
titleMedium = styles.titleMedium?.toString(),
titleSmall = styles.titleSmall?.toString(),
bodyLarge = styles.bodyLarge?.toString(),
bodyMedium = styles.bodyMedium?.toString(),
bodySmall = styles.bodySmall?.toString(),
labelLarge = styles.labelLarge?.toString(),
labelMedium = styles.labelMedium?.toString(),
labelSmall = styles.labelSmall?.toString(),
emphasizedDisplayLarge = emphasizedStyles.displayLarge?.toString(),
emphasizedDisplayMedium = emphasizedStyles.displayMedium?.toString(),
emphasizedDisplaySmall = emphasizedStyles.displaySmall?.toString(),
emphasizedHeadlineLarge = emphasizedStyles.headlineLarge?.toString(),
emphasizedHeadlineMedium = emphasizedStyles.headlineMedium?.toString(),
emphasizedHeadlineSmall = emphasizedStyles.headlineSmall?.toString(),
emphasizedTitleLarge = emphasizedStyles.titleLarge?.toString(),
emphasizedTitleMedium = emphasizedStyles.titleMedium?.toString(),
emphasizedTitleSmall = emphasizedStyles.titleSmall?.toString(),
emphasizedBodyLarge = emphasizedStyles.bodyLarge?.toString(),
emphasizedBodyMedium = emphasizedStyles.bodyMedium?.toString(),
emphasizedBodySmall = emphasizedStyles.bodySmall?.toString(),
emphasizedLabelLarge = emphasizedStyles.labelLarge?.toString(),
emphasizedLabelMedium = emphasizedStyles.labelMedium?.toString(),
emphasizedLabelSmall = emphasizedStyles.labelSmall?.toString(),
)
}
}
@Serializable
data class TextStyles<out W : FontWeight?>(
val bodySmall: TextStyle<W>? = null,
val bodyMedium: TextStyle<W>? = null,
val bodyLarge: TextStyle<W>? = null,
val labelSmall: TextStyle<W>? = null,
val labelMedium: TextStyle<W>? = null,
val labelLarge: TextStyle<W>? = null,
val titleSmall: TextStyle<W>? = null,
val titleMedium: TextStyle<W>? = null,
val titleLarge: TextStyle<W>? = null,
val headlineSmall: TextStyle<W>? = null,
val headlineMedium: TextStyle<W>? = null,
val headlineLarge: TextStyle<W>? = null,
val displaySmall: TextStyle<W>? = null,
val displayMedium: TextStyle<W>? = null,
val displayLarge: TextStyle<W>? = null,
)
@Serializable
data class TextStyle<out W : FontWeight?>(
/**
* Index of the font family to use for this text style.
*/
val fontFamily: String? = null,
/**
* The font size in sp.
*/
val fontSize: Int? = null,
/**
* The font weight, e.g. 400 for normal, 700 for bold.
*/
@Contextual val fontWeight: W? = null,
/**
* The line height in em.
*/
val lineHeight: Float? = null,
/**
* The letter spacing in em.
*/
val letterSpacing: Float? = null,
) {
/**
* Secondary constructor that uses absolute units for line height and letter spacing.
*/
constructor(
fontFamily: String?,
fontSize: Int,
fontWeight: W?,
lineHeight: Int?,
letterSpacing: Float?,
) : this(
fontFamily = fontFamily,
fontSize = fontSize,
fontWeight = fontWeight,
lineHeight = lineHeight?.toFloat()?.div(fontSize),
letterSpacing = letterSpacing?.div(fontSize)
)
override fun toString(): String {
return ThemeJson.encodeToString<TextStyle<FontWeight?>>(this)
}
companion object {
fun <T : FontWeight> fromString(string: String?): TextStyle<T>? {
if (string.isNullOrEmpty()) return null
return ThemeJson.decodeFromString<TextStyle<FontWeight>>(string) as TextStyle<T>
}
}
}
@Serializable
sealed interface FontFamily {
@Serializable
@SerialName("launcher_default")
data object LauncherDefault : FontFamily
@Serializable
@SerialName("device_headline")
data object DeviceHeadline : FontFamily
@Serializable
@SerialName("device_body")
data object DeviceBody : FontFamily
@Serializable
@SerialName("sans-serif")
data object SansSerif : FontFamily
@Serializable
@SerialName("serif")
data object Serif : FontFamily
@Serializable
@SerialName("monospace")
data object Monospace : FontFamily
@Serializable
@SerialName("system")
data class System(val name: String) : FontFamily
}
@Serializable(with = FontWeightSerializer::class)
sealed interface FontWeight {
/**
* Absolute font weight, e.g. 400 for normal, 700 for bold.
*/
@JvmInline
value class Absolute(val weight: Int) : FontWeight
/**
* Relative font weight, in relation to another font style.
* This is used for emphasized styles, i.e. if the base style has a weight of 400,
* the emphasized style with a relative weight of 100 will have a weight of 500,
*/
@JvmInline
value class Relative(val relativeWeight: Int) : FontWeight
}
@@ -0,0 +1,125 @@
package de.mm20.launcher2.themes.typography
import android.content.Context
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.themes.DefaultThemeId
import de.mm20.launcher2.themes.MonospaceId
import de.mm20.launcher2.themes.R
import de.mm20.launcher2.themes.SerifId
import de.mm20.launcher2.themes.SystemFontId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.UUID
class TypographyRepository(
private val context: Context,
private val database: AppDatabase,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
fun getAll(): Flow<List<Typography>> {
return database.themeDao().getAllTypographies().map {
getBuiltIn() + it.map { Typography(it) }
}
}
fun get(id: UUID): Flow<Typography?> {
if (id == DefaultThemeId) return flowOf(default)
if (id == SystemFontId) return flowOf(systemFont)
if (id == SerifId) return flowOf(serif)
if (id == MonospaceId) return flowOf(monospace)
return database.themeDao().getTypography(id).map { it?.let { Typography(it) } }
}
fun create(typography: Typography) {
scope.launch {
database.themeDao().insertTypography(typography.toEntity())
}
}
fun update(typography: Typography) {
scope.launch {
database.themeDao().updateTypography(typography.toEntity())
}
}
fun delete(typography: Typography) {
scope.launch {
database.themeDao().deleteTypography(typography.id)
}
}
fun getOrDefault(id: UUID?): Flow<Typography> {
if (id == null) return flowOf(default)
return get(id).map { it ?: default }
}
private fun getBuiltIn(): List<Typography> {
return listOf(
default,
systemFont,
serif,
monospace,
)
}
private val default: Typography
get() = Typography(
id = DefaultThemeId,
builtIn = true,
name = "Outfit",
fonts = mapOf(
"brand" to FontFamily.LauncherDefault,
"plain" to null,
),
styles = DefaultTextStyles,
emphasizedStyles = DefaultEmphasizedTextStyles,
)
private val systemFont: Typography
get() = Typography(
id = SystemFontId,
builtIn = true,
name = context.getString(R.string.preference_font_system),
fonts = mapOf(
"brand" to FontFamily.DeviceHeadline,
"plain" to FontFamily.DeviceBody,
),
styles = DefaultTextStyles,
emphasizedStyles = DefaultEmphasizedTextStyles,
)
private val serif: Typography
get() = Typography(
id = SerifId,
builtIn = true,
name = "Serif",
fonts = mapOf(
"brand" to FontFamily.Serif,
"plain" to FontFamily.Serif,
),
styles = DefaultTextStyles,
emphasizedStyles = DefaultEmphasizedTextStyles,
)
private val monospace: Typography
get() = Typography(
id = MonospaceId,
builtIn = true,
name = "Monospace",
fonts = mapOf(
"brand" to FontFamily.Monospace,
"plain" to FontFamily.Monospace,
),
styles = DefaultTextStyles,
emphasizedStyles = DefaultEmphasizedTextStyles,
)
}