Location: add icons for available payment methods (#1408)

* add backend for PaymentMethod.kt

* change type of acceptedPaymentMethods to Map<PaymentMethod, Boolean>

* add icons to UI and reduce PaymentMethod.kt to Cash and Card

* make OSM parsing for acceptedPaymentMethods correct (in an opinionated way)

* use Toll and TollOff icons

* fix seperator on empty payment methods

* fix sharedElement and animateEnterExit labels that are not part of the sharedElement

* (feat) shape schemes

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
shtrophic
2025-06-01 17:27:18 +02:00
committed by GitHub
co-authored by MM20
parent 1efffcf586
commit 78fa1d71dc
50 changed files with 2313 additions and 965 deletions
@@ -18,7 +18,8 @@ import de.mm20.launcher2.database.entities.IconPackEntity
import de.mm20.launcher2.database.entities.PluginEntity
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.database.entities.ThemeEntity
import de.mm20.launcher2.database.entities.ColorsEntity
import de.mm20.launcher2.database.entities.ShapesEntity
import de.mm20.launcher2.database.entities.WidgetEntity
import de.mm20.launcher2.database.migrations.Migration_10_11
import de.mm20.launcher2.database.migrations.Migration_11_12
@@ -37,6 +38,7 @@ import de.mm20.launcher2.database.migrations.Migration_23_24
import de.mm20.launcher2.database.migrations.Migration_24_25
import de.mm20.launcher2.database.migrations.Migration_25_26
import de.mm20.launcher2.database.migrations.Migration_26_27
import de.mm20.launcher2.database.migrations.Migration_27_28
import de.mm20.launcher2.database.migrations.Migration_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9
@@ -54,9 +56,10 @@ import java.util.UUID
WidgetEntity::class,
CustomAttributeEntity::class,
SearchActionEntity::class,
ThemeEntity::class,
ColorsEntity::class,
PluginEntity::class,
], version = 27, exportSchema = true
ShapesEntity::class,
], version = 28, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class)
abstract class AppDatabase : RoomDatabase() {
@@ -156,6 +159,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_24_25(),
Migration_25_26(),
Migration_26_27(),
Migration_27_28(),
).build()
if (_instance == null) _instance = instance
return instance
@@ -4,30 +4,52 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.mm20.launcher2.database.entities.ThemeEntity
import de.mm20.launcher2.database.entities.ColorsEntity
import de.mm20.launcher2.database.entities.ShapesEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
interface ThemeDao {
@Query("SELECT * FROM Theme")
fun getAll(): Flow<List<ThemeEntity>>
fun getAllColors(): Flow<List<ColorsEntity>>
@Query("SELECT * FROM Shapes")
fun getAllShapes(): Flow<List<ShapesEntity>>
@Query("SELECT * FROM Theme WHERE id = :id LIMIT 1")
fun get(id: UUID): Flow<ThemeEntity?>
fun getColors(id: UUID): Flow<ColorsEntity?>
@Query("SELECT * FROM Shapes WHERE id = :id LIMIT 1")
fun getShapes(id: UUID): Flow<ShapesEntity?>
@Insert
suspend fun insert(theme: ThemeEntity)
suspend fun insertColors(colors: ColorsEntity)
@Insert
suspend fun insertShapes(shapes: ShapesEntity)
@Update
suspend fun update(theme: ThemeEntity)
suspend fun updateColors(colors: ColorsEntity)
@Update
suspend fun updateShapes(shapes: ShapesEntity)
@Query("DELETE FROM Theme WHERE id = :id")
suspend fun delete(id: UUID)
suspend fun deleteColors(id: UUID)
@Query("DELETE FROM Shapes WHERE id = :id")
suspend fun deleteShapes(id: UUID)
@Query("DELETE FROM Theme")
suspend fun deleteAll()
suspend fun deleteAllColors()
@Query("DELETE FROM Shapes")
suspend fun deleteAllShapes()
@Insert
fun insertAll(themes: List<ThemeEntity>)
fun insertAllColors(colors: List<ColorsEntity>)
@Insert
fun insertAllShapes(shapes: List<ShapesEntity>)
}
@@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "Theme")
data class ThemeEntity(
data class ColorsEntity(
@PrimaryKey val id: UUID,
val name: String,
@@ -0,0 +1,22 @@
package de.mm20.launcher2.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "Shapes")
data class ShapesEntity(
@PrimaryKey val id: UUID,
val name: String,
val baseShape: String,
val extraSmall: String? = null,
val small: String? = null,
val medium: String? = null,
val large: String? = null,
val largeIncreased: String? = null,
val extraLarge: String? = null,
val extraLargeIncreased: String? = null,
val extraExtraLarge: String? = null,
)
@@ -0,0 +1,27 @@
package de.mm20.launcher2.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration_27_28: Migration(27, 28) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `Shapes` (
`id` BLOB NOT NULL PRIMARY KEY,
`name` TEXT NOT NULL,
`baseShape` TEXT NOT NULL,
`extraSmall` TEXT,
`small` TEXT,
`medium` TEXT,
`large` TEXT,
`largeIncreased` TEXT,
`extraLarge` TEXT,
`extraLargeIncreased` TEXT,
`extraExtraLarge` TEXT
)
""".trimIndent()
)
}
}
@@ -1,7 +1,6 @@
package de.mm20.launcher2.locations
import android.content.Context
import android.util.Log
import de.mm20.launcher2.locations.providers.PluginLocation
import de.mm20.launcher2.locations.providers.PluginLocationProvider
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocation
@@ -18,10 +17,10 @@ import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.search.location.PaymentMethod
import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@Serializable
internal data class SerializedLocation(
@@ -42,6 +41,7 @@ internal data class SerializedLocation(
val departures: List<Departure>? = null,
val fixMeUrl: String? = null,
val attribution: Attribution? = null,
val acceptedPaymentMethods: Map<PaymentMethod, Boolean>? = null,
val authority: String? = null,
val storageStrategy: StorageStrategy? = null,
)
@@ -67,6 +67,7 @@ internal class OsmLocationSerializer : SearchableSerializer {
timestamp = searchable.timestamp,
departures = searchable.departures,
fixMeUrl = searchable.fixMeUrl,
acceptedPaymentMethods = searchable.acceptedPaymentMethods
)
)
}
@@ -96,6 +97,7 @@ internal class OsmLocationDeserializer(
userRating = json.userRating,
openingSchedule = json.openingSchedule,
timestamp = json.timestamp ?: return null,
acceptedPaymentMethods = json.acceptedPaymentMethods,
updatedSelf = {
osmProvider.update(id)
}
@@ -134,6 +136,7 @@ internal class PluginLocationSerializer : SearchableSerializer {
openingSchedule = searchable.openingSchedule,
timestamp = searchable.timestamp,
departures = searchable.departures,
acceptedPaymentMethods = searchable.acceptedPaymentMethods,
fixMeUrl = searchable.fixMeUrl,
authority = searchable.authority,
storageStrategy = searchable.storageStrategy,
@@ -185,6 +188,7 @@ internal class PluginLocationDeserializer(
departures = json.departures,
fixMeUrl = json.fixMeUrl,
attribution = json.attribution,
acceptedPaymentMethods = json.acceptedPaymentMethods,
authority = authority,
storageStrategy = strategy,
updatedSelf = {
@@ -14,6 +14,7 @@ import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.search.location.PaymentMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -34,6 +35,7 @@ data class PluginLocation(
override val label: String,
override val timestamp: Long,
override val attribution: Attribution?,
override val acceptedPaymentMethods: Map<PaymentMethod, Boolean>?,
override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<Location>)?,
override val labelOverride: String? = null,
val authority: String,
@@ -84,6 +84,7 @@ internal class PluginLocationProvider(
userRatingCount = cursor[LocationColumns.UserRatingCount],
departures = cursor[LocationColumns.Departures],
attribution = cursor[LocationColumns.Attribution],
acceptedPaymentMethods = cursor[LocationColumns.AcceptedPaymentMethods],
authority = pluginAuthority,
updatedSelf = {
if (it !is PluginLocation) UpdateResult.TemporarilyUnavailable()
@@ -117,6 +118,7 @@ internal class PluginLocationProvider(
set(LocationColumns.UserRatingCount, userRatingCount)
set(LocationColumns.Departures, departures)
set(LocationColumns.Attribution, attribution)
set(LocationColumns.AcceptedPaymentMethods, acceptedPaymentMethods)
}
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import de.mm20.launcher2.ktx.ifNullOrEmpty
import de.mm20.launcher2.ktx.into
import de.mm20.launcher2.ktx.map
import de.mm20.launcher2.ktx.stripStartOrNull
import de.mm20.launcher2.locations.OsmLocationSerializer
import de.mm20.launcher2.openstreetmaps.R
import de.mm20.launcher2.search.Location
@@ -16,6 +17,7 @@ import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningHours
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.search.location.PaymentMethod
import de.westnordost.osm_opening_hours.model.ClockTime
import de.westnordost.osm_opening_hours.model.ExtendedClockTime
import de.westnordost.osm_opening_hours.model.LastNth
@@ -62,7 +64,8 @@ internal data class OsmLocation(
override val labelOverride: String? = null,
override val timestamp: Long,
override var updatedSelf: (suspend (SavableSearchable) -> UpdateResult<Location>)? = null,
override val userRating: Float?
override val userRating: Float?,
override val acceptedPaymentMethods: Map<PaymentMethod, Boolean>?
) : Location, UpdatableSearchable<Location> {
override val domain: String
@@ -115,7 +118,25 @@ internal data class OsmLocation(
emailAddress = it.tags["email"] ?: it.tags["contact:email"],
timestamp = System.currentTimeMillis(),
userRating = it.tags["stars"]?.runCatching { this.toInt() }?.getOrNull()
?.let { min(it, 5) / 5.0f }
?.let { min(it, 5) / 5.0f },
acceptedPaymentMethods = with(
it.tags.mapNotNull { (key, value) ->
(key.stripStartOrNull("payment:") ?: return@mapNotNull null) to value
}.toMap()
) {
// best-effort way to take any method payment as it being available,
// otherwise as being unavailable, or undefined
mapOf(
PaymentMethod.Card to listOf("credit_cards", "debit_cards", "cards"),
PaymentMethod.Cash to listOf("cash")
).mapNotNull { (method, values) ->
when {
values.any { this[it] in listOf("yes", "only") } -> method to true
values.any { this[it] == "no" } -> method to false
else -> null
}
}.toMap().takeUnless { it.isEmpty() }
}
)
}
}
@@ -1,138 +1,13 @@
package de.mm20.launcher2.themes
import de.mm20.launcher2.database.entities.ThemeEntity
import de.mm20.launcher2.database.entities.ColorsEntity
import hct.Hct
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
enum class CorePaletteColor {
Primary,
Secondary,
Tertiary,
Neutral,
NeutralVariant,
Error;
override fun toString(): String {
return when (this) {
Primary -> "p"
Secondary -> "s"
Tertiary -> "t"
Neutral -> "n"
NeutralVariant -> "nv"
Error -> "e"
}
}
}
fun CorePaletteColor(color: String): CorePaletteColor? {
return when (color) {
"p" -> CorePaletteColor.Primary
"s" -> CorePaletteColor.Secondary
"t" -> CorePaletteColor.Tertiary
"n" -> CorePaletteColor.Neutral
"nv" -> CorePaletteColor.NeutralVariant
"e" -> CorePaletteColor.Error
else -> null
}
}
sealed interface Color
internal fun Color(string: String?): Color? {
if (string == null) return null
if (string.startsWith("#")) {
return StaticColor(string.substring(1).toLongOrNull(16)?.toInt() ?: return null)
}
if (string.startsWith("$")) {
val parts = string.substring(1).split(".").takeIf { it.size == 2 } ?: return null
val color = CorePaletteColor(parts[0]) ?: return null
return ColorRef(
color = color,
tone = parts[1].toIntOrNull() ?: return null,
)
}
return null
}
data class ColorRef(
val color: CorePaletteColor,
val tone: Int,
) : Color {
override fun toString(): String {
return "\$$color.$tone"
}
}
@JvmInline
value class StaticColor(val color: Int) : Color {
override fun toString(): String {
return "#${color.toUInt().toString(16).padStart(8, '0')}"
}
}
@Serializable
data class CorePalette<out T : Int?>(
val primary: T,
val secondary: T,
val tertiary: T,
val neutral: T,
val neutralVariant: T,
val error: T,
)
val EmptyCorePalette = CorePalette<Int?>(null, null, null, null, null, null)
typealias FullCorePalette = CorePalette<Int>
typealias PartialCorePalette = CorePalette<Int?>
@Serializable
data class ColorScheme<out T : Color?>(
val primary: T,
val onPrimary: T,
val primaryContainer: T,
val onPrimaryContainer: T,
val secondary: T,
val onSecondary: T,
val secondaryContainer: T,
val onSecondaryContainer: T,
val tertiary: T,
val onTertiary: T,
val tertiaryContainer: T,
val onTertiaryContainer: T,
val error: T,
val onError: T,
val errorContainer: T,
val onErrorContainer: T,
val surface: T,
val onSurface: T,
val onSurfaceVariant: T,
val outline: T,
val outlineVariant: T,
val inverseSurface: T,
val inverseOnSurface: T,
val inversePrimary: T,
val surfaceDim: T,
val surfaceBright: T,
val surfaceContainerLowest: T,
val surfaceContainerLow: T,
val surfaceContainer: T,
val surfaceContainerHigh: T,
val surfaceContainerHighest: T,
val background: T,
val onBackground: T,
val surfaceTint: T,
val scrim: T,
val surfaceVariant: T,
)
typealias FullColorScheme = ColorScheme<Color>
typealias PartialColorScheme = ColorScheme<Color?>
@Serializable
data class Theme(
data class Colors(
@Transient val id: UUID = UUID.randomUUID(),
val builtIn: Boolean = false,
val name: String,
@@ -141,7 +16,7 @@ data class Theme(
val darkColorScheme: PartialColorScheme = DefaultDarkColorScheme,
) {
constructor(entity: ThemeEntity) : this(
constructor(entity: ColorsEntity) : this(
id = entity.id,
builtIn = false,
name = entity.name,
@@ -154,86 +29,86 @@ data class Theme(
error = entity.corePaletteE,
),
lightColorScheme = ColorScheme(
primary = Color(entity.lightPrimary),
onPrimary = Color(entity.lightOnPrimary),
primaryContainer = Color(entity.lightPrimaryContainer),
onPrimaryContainer = Color(entity.lightOnPrimaryContainer),
secondary = Color(entity.lightSecondary),
onSecondary = Color(entity.lightOnSecondary),
secondaryContainer = Color(entity.lightSecondaryContainer),
onSecondaryContainer = Color(entity.lightOnSecondaryContainer),
tertiary = Color(entity.lightTertiary),
onTertiary = Color(entity.lightOnTertiary),
tertiaryContainer = Color(entity.lightTertiaryContainer),
onTertiaryContainer = Color(entity.lightOnTertiaryContainer),
error = Color(entity.lightError),
onError = Color(entity.lightOnError),
errorContainer = Color(entity.lightErrorContainer),
onErrorContainer = Color(entity.lightOnErrorContainer),
surface = Color(entity.lightSurface),
onSurface = Color(entity.lightOnSurface),
onSurfaceVariant = Color(entity.lightOnSurfaceVariant),
outline = Color(entity.lightOutline),
outlineVariant = Color(entity.lightOutlineVariant),
inverseSurface = Color(entity.lightInverseSurface),
inverseOnSurface = Color(entity.lightInverseOnSurface),
inversePrimary = Color(entity.lightInversePrimary),
surfaceDim = Color(entity.lightSurfaceDim),
surfaceBright = Color(entity.lightSurfaceBright),
surfaceContainerLowest = Color(entity.lightSurfaceContainerLowest),
surfaceContainerLow = Color(entity.lightSurfaceContainerLow),
surfaceContainer = Color(entity.lightSurfaceContainer),
surfaceContainerHigh = Color(entity.lightSurfaceContainerHigh),
surfaceContainerHighest = Color(entity.lightSurfaceContainerHighest),
background = Color(entity.lightBackground),
onBackground = Color(entity.lightOnBackground),
surfaceTint = Color(entity.lightSurfaceTint),
scrim = Color(entity.lightScrim),
surfaceVariant = Color(entity.lightSurfaceVariant),
primary = Color.fromString(entity.lightPrimary),
onPrimary = Color.fromString(entity.lightOnPrimary),
primaryContainer = Color.fromString(entity.lightPrimaryContainer),
onPrimaryContainer = Color.fromString(entity.lightOnPrimaryContainer),
secondary = Color.fromString(entity.lightSecondary),
onSecondary = Color.fromString(entity.lightOnSecondary),
secondaryContainer = Color.fromString(entity.lightSecondaryContainer),
onSecondaryContainer = Color.fromString(entity.lightOnSecondaryContainer),
tertiary = Color.fromString(entity.lightTertiary),
onTertiary = Color.fromString(entity.lightOnTertiary),
tertiaryContainer = Color.fromString(entity.lightTertiaryContainer),
onTertiaryContainer = Color.fromString(entity.lightOnTertiaryContainer),
error = Color.fromString(entity.lightError),
onError = Color.fromString(entity.lightOnError),
errorContainer = Color.fromString(entity.lightErrorContainer),
onErrorContainer = Color.fromString(entity.lightOnErrorContainer),
surface = Color.fromString(entity.lightSurface),
onSurface = Color.fromString(entity.lightOnSurface),
onSurfaceVariant = Color.fromString(entity.lightOnSurfaceVariant),
outline = Color.fromString(entity.lightOutline),
outlineVariant = Color.fromString(entity.lightOutlineVariant),
inverseSurface = Color.fromString(entity.lightInverseSurface),
inverseOnSurface = Color.fromString(entity.lightInverseOnSurface),
inversePrimary = Color.fromString(entity.lightInversePrimary),
surfaceDim = Color.fromString(entity.lightSurfaceDim),
surfaceBright = Color.fromString(entity.lightSurfaceBright),
surfaceContainerLowest = Color.fromString(entity.lightSurfaceContainerLowest),
surfaceContainerLow = Color.fromString(entity.lightSurfaceContainerLow),
surfaceContainer = Color.fromString(entity.lightSurfaceContainer),
surfaceContainerHigh = Color.fromString(entity.lightSurfaceContainerHigh),
surfaceContainerHighest = Color.fromString(entity.lightSurfaceContainerHighest),
background = Color.fromString(entity.lightBackground),
onBackground = Color.fromString(entity.lightOnBackground),
surfaceTint = Color.fromString(entity.lightSurfaceTint),
scrim = Color.fromString(entity.lightScrim),
surfaceVariant = Color.fromString(entity.lightSurfaceVariant),
),
darkColorScheme = ColorScheme(
primary = Color(entity.darkPrimary),
onPrimary = Color(entity.darkOnPrimary),
primaryContainer = Color(entity.darkPrimaryContainer),
onPrimaryContainer = Color(entity.darkOnPrimaryContainer),
secondary = Color(entity.darkSecondary),
onSecondary = Color(entity.darkOnSecondary),
secondaryContainer = Color(entity.darkSecondaryContainer),
onSecondaryContainer = Color(entity.darkOnSecondaryContainer),
tertiary = Color(entity.darkTertiary),
onTertiary = Color(entity.darkOnTertiary),
tertiaryContainer = Color(entity.darkTertiaryContainer),
onTertiaryContainer = Color(entity.darkOnTertiaryContainer),
error = Color(entity.darkError),
onError = Color(entity.darkOnError),
errorContainer = Color(entity.darkErrorContainer),
onErrorContainer = Color(entity.darkOnErrorContainer),
surface = Color(entity.darkSurface),
onSurface = Color(entity.darkOnSurface),
onSurfaceVariant = Color(entity.darkOnSurfaceVariant),
outline = Color(entity.darkOutline),
outlineVariant = Color(entity.darkOutlineVariant),
inverseSurface = Color(entity.darkInverseSurface),
inverseOnSurface = Color(entity.darkInverseOnSurface),
inversePrimary = Color(entity.darkInversePrimary),
surfaceDim = Color(entity.darkSurfaceDim),
surfaceBright = Color(entity.darkSurfaceBright),
surfaceContainerLowest = Color(entity.darkSurfaceContainerLowest),
surfaceContainerLow = Color(entity.darkSurfaceContainerLow),
surfaceContainer = Color(entity.darkSurfaceContainer),
surfaceContainerHigh = Color(entity.darkSurfaceContainerHigh),
surfaceContainerHighest = Color(entity.darkSurfaceContainerHighest),
background = Color(entity.darkBackground),
onBackground = Color(entity.darkOnBackground),
surfaceTint = Color(entity.darkSurfaceTint),
scrim = Color(entity.darkScrim),
surfaceVariant = Color(entity.darkSurfaceVariant),
primary = Color.fromString(entity.darkPrimary),
onPrimary = Color.fromString(entity.darkOnPrimary),
primaryContainer = Color.fromString(entity.darkPrimaryContainer),
onPrimaryContainer = Color.fromString(entity.darkOnPrimaryContainer),
secondary = Color.fromString(entity.darkSecondary),
onSecondary = Color.fromString(entity.darkOnSecondary),
secondaryContainer = Color.fromString(entity.darkSecondaryContainer),
onSecondaryContainer = Color.fromString(entity.darkOnSecondaryContainer),
tertiary = Color.fromString(entity.darkTertiary),
onTertiary = Color.fromString(entity.darkOnTertiary),
tertiaryContainer = Color.fromString(entity.darkTertiaryContainer),
onTertiaryContainer = Color.fromString(entity.darkOnTertiaryContainer),
error = Color.fromString(entity.darkError),
onError = Color.fromString(entity.darkOnError),
errorContainer = Color.fromString(entity.darkErrorContainer),
onErrorContainer = Color.fromString(entity.darkOnErrorContainer),
surface = Color.fromString(entity.darkSurface),
onSurface = Color.fromString(entity.darkOnSurface),
onSurfaceVariant = Color.fromString(entity.darkOnSurfaceVariant),
outline = Color.fromString(entity.darkOutline),
outlineVariant = Color.fromString(entity.darkOutlineVariant),
inverseSurface = Color.fromString(entity.darkInverseSurface),
inverseOnSurface = Color.fromString(entity.darkInverseOnSurface),
inversePrimary = Color.fromString(entity.darkInversePrimary),
surfaceDim = Color.fromString(entity.darkSurfaceDim),
surfaceBright = Color.fromString(entity.darkSurfaceBright),
surfaceContainerLowest = Color.fromString(entity.darkSurfaceContainerLowest),
surfaceContainerLow = Color.fromString(entity.darkSurfaceContainerLow),
surfaceContainer = Color.fromString(entity.darkSurfaceContainer),
surfaceContainerHigh = Color.fromString(entity.darkSurfaceContainerHigh),
surfaceContainerHighest = Color.fromString(entity.darkSurfaceContainerHighest),
background = Color.fromString(entity.darkBackground),
onBackground = Color.fromString(entity.darkOnBackground),
surfaceTint = Color.fromString(entity.darkSurfaceTint),
scrim = Color.fromString(entity.darkScrim),
surfaceVariant = Color.fromString(entity.darkSurfaceVariant),
),
)
internal fun toEntity(): ThemeEntity {
return ThemeEntity(
internal fun toEntity(): ColorsEntity {
return ColorsEntity(
id = id,
name = name,
corePaletteA1 = corePalette.primary,
@@ -320,6 +195,137 @@ data class Theme(
}
}
enum class CorePaletteColor {
Primary,
Secondary,
Tertiary,
Neutral,
NeutralVariant,
Error;
override fun toString(): String {
return when (this) {
Primary -> "p"
Secondary -> "s"
Tertiary -> "t"
Neutral -> "n"
NeutralVariant -> "nv"
Error -> "e"
}
}
companion object {
fun fromString(string: String): CorePaletteColor? {
return when (string) {
"p" -> Primary
"s" -> Secondary
"t" -> Tertiary
"n" -> Neutral
"nv" -> NeutralVariant
"e" -> Error
else -> null
}
}
}
}
@Serializable(with = ColorSerializer::class)
sealed interface Color {
companion object {
fun fromString(string: String?): Color? {
if (string == null) return null
if (string.startsWith("#")) {
return StaticColor(string.substring(1).toLongOrNull(16)?.toInt() ?: return null)
}
if (string.startsWith("$")) {
val parts = string.substring(1).split(".").takeIf { it.size == 2 } ?: return null
val color = CorePaletteColor.fromString(parts[0]) ?: return null
return ColorRef(
color = color,
tone = parts[1].toIntOrNull() ?: return null,
)
}
return null
}
}
}
data class ColorRef(
val color: CorePaletteColor,
val tone: Int,
) : Color {
override fun toString(): String {
return "$$color.$tone"
}
}
@JvmInline
value class StaticColor(val color: Int) : Color {
override fun toString(): String {
return "#${color.toUInt().toString(16).padStart(8, '0')}"
}
}
@Serializable
data class CorePalette<out T : Int?>(
val primary: T,
val secondary: T,
val tertiary: T,
val neutral: T,
val neutralVariant: T,
val error: T,
)
val EmptyCorePalette = CorePalette<Int?>(null, null, null, null, null, null)
typealias FullCorePalette = CorePalette<Int>
typealias PartialCorePalette = CorePalette<Int?>
@Serializable
data class ColorScheme<out T : Color?>(
val primary: T,
val onPrimary: T,
val primaryContainer: T,
val onPrimaryContainer: T,
val secondary: T,
val onSecondary: T,
val secondaryContainer: T,
val onSecondaryContainer: T,
val tertiary: T,
val onTertiary: T,
val tertiaryContainer: T,
val onTertiaryContainer: T,
val error: T,
val onError: T,
val errorContainer: T,
val onErrorContainer: T,
val surface: T,
val onSurface: T,
val onSurfaceVariant: T,
val outline: T,
val outlineVariant: T,
val inverseSurface: T,
val inverseOnSurface: T,
val inversePrimary: T,
val surfaceDim: T,
val surfaceBright: T,
val surfaceContainerLowest: T,
val surfaceContainerLow: T,
val surfaceContainer: T,
val surfaceContainerHigh: T,
val surfaceContainerHighest: T,
val background: T,
val onBackground: T,
val surfaceTint: T,
val scrim: T,
val surfaceVariant: T,
)
typealias FullColorScheme = ColorScheme<Color>
typealias PartialColorScheme = ColorScheme<Color?>
fun <T : Int?> CorePalette<T>.get(color: CorePaletteColor): T {
return when (color) {
CorePaletteColor.Primary -> primary
@@ -4,6 +4,10 @@ import java.util.UUID
val DefaultThemeId = UUID(0L, 0L)
val BlackAndWhiteThemeId = UUID(0L, 1L)
val ExtraRoundShapesId = UUID(0L, 1L)
val CutShapesId = UUID(0L, 2L)
val RectShapesId = UUID(0L, 3L)
val DefaultLightColorScheme = ColorScheme<Color>(
primary = ColorRef(CorePaletteColor.Primary, 40),
@@ -83,7 +87,6 @@ val DefaultDarkColorScheme = ColorScheme<Color>(
scrim = ColorRef(CorePaletteColor.Neutral, 0),
)
val BlackAndWhiteThemeId = UUID(0L, 1L)
val BlackAndWhiteLightColorScheme = ColorScheme<Color?>(
primary = StaticColor(0xFF000000.toInt()),
@@ -0,0 +1,67 @@
package de.mm20.launcher2.themes
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
@Deprecated("Only used for backwards compatibility with old themes. New themes should use the new serialization format.")
internal class LegacyColorRefSerializer: KSerializer<ColorRef> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("$", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ColorRef {
return Color.fromString(decoder.decodeString()) as ColorRef
}
override fun serialize(encoder: Encoder, value: ColorRef) {
encoder.encodeString(value.toString())
}
}
@Deprecated("Only used for backwards compatibility with old themes. New themes should use the new serialization format.")
internal class LegacyStaticColorSerializer: KSerializer<StaticColor> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("#", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): StaticColor {
return Color.fromString(decoder.decodeString()) as StaticColor
}
override fun serialize(encoder: Encoder, value: StaticColor) {
encoder.encodeString(value.toString())
}
}
@Deprecated("Only used for backwards compatibility with old themes. New themes should use the new serialization format.")
internal val legacyModule = SerializersModule {
polymorphic(Color::class) {
subclass(ColorRef::class, LegacyColorRefSerializer())
subclass(StaticColor::class, LegacyStaticColorSerializer())
}
}
@Deprecated("Only used for backwards compatibility with old themes. New themes should use the new serialization format.")
val LegacyThemeJson = Json {
serializersModule = legacyModule
useArrayPolymorphism = true
}
@Deprecated("Only used for backwards compatibility with old themes. New themes should use the new serialization format.")
fun Colors.toLegacyJson(): String {
return LegacyThemeJson.encodeToString(this)
}
@Deprecated("Only used for backwards compatibility with old themes. New themes should use the new serialization format.")
fun Colors.Companion.fromLegacyJson(json: String): Colors {
return try {
LegacyThemeJson.decodeFromString(json)
} catch (e: SerializationException) {
throw IllegalArgumentException(e)
}
}
@@ -1,62 +1,39 @@
package de.mm20.launcher2.themes
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encodeToString
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import kotlinx.serialization.modules.polymorphic
internal class ColorRefSerializer: KSerializer<ColorRef> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("$", PrimitiveKind.STRING)
internal class ColorSerializer: KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ColorSerializer", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ColorRef {
return Color(decoder.decodeString()) as ColorRef
}
override fun serialize(encoder: Encoder, value: ColorRef) {
override fun serialize(
encoder: Encoder,
value: Color
) {
encoder.encodeString(value.toString())
}
}
internal class StaticColorSerializer: KSerializer<StaticColor> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("#", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): StaticColor {
return Color(decoder.decodeString()) as StaticColor
override fun deserialize(decoder: Decoder): Color {
TODO("Not yet implemented")
}
override fun serialize(encoder: Encoder, value: StaticColor) {
}
internal class ShapeSerializer: KSerializer<Shape> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ShapeSerializer", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
value: Shape
) {
encoder.encodeString(value.toString())
}
}
internal val module = SerializersModule {
polymorphic(Color::class) {
subclass(ColorRef::class, ColorRefSerializer())
subclass(StaticColor::class, StaticColorSerializer())
}
}
val ThemeJson = Json {
serializersModule = module
useArrayPolymorphism = true
}
fun Theme.toJson(): String {
return ThemeJson.encodeToString(this)
}
fun Theme.Companion.fromJson(json: String): Theme {
return try {
ThemeJson.decodeFromString(json)
} catch (e: SerializationException) {
throw IllegalArgumentException(e)
override fun deserialize(decoder: Decoder): Shape {
return Shape.fromString(decoder.decodeString())!!
}
}
@@ -0,0 +1,120 @@
package de.mm20.launcher2.themes
import de.mm20.launcher2.database.entities.ShapesEntity
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
@Serializable
data class Shapes(
@Transient val id: UUID = UUID.randomUUID(),
val builtIn: Boolean = false,
val name: String,
val baseShape: Shape = Shape(
corners = CornerStyle.Rounded,
radii = intArrayOf(12, 12, 12, 12),
),
val extraSmall: Shape? = null,
val small: Shape? = null,
val medium: Shape? = null,
val large: Shape? = null,
val largeIncreased: Shape? = null,
val extraLarge: Shape? = null,
val extraLargeIncreased: Shape? = null,
val extraExtraLarge: Shape? = null,
) {
constructor(entity: ShapesEntity) : this(
id = entity.id,
builtIn = false,
name = entity.name,
baseShape = Shape.fromString(entity.baseShape) ?: Shape(
corners = CornerStyle.Rounded,
radii = intArrayOf(12, 12, 12, 12)
),
extraSmall = Shape.fromString(entity.extraSmall),
small = Shape.fromString(entity.small),
medium = Shape.fromString(entity.medium),
large = Shape.fromString(entity.large),
largeIncreased = Shape.fromString(entity.largeIncreased),
extraLarge = Shape.fromString(entity.extraLarge),
extraLargeIncreased = Shape.fromString(entity.extraLargeIncreased),
extraExtraLarge = Shape.fromString(entity.extraExtraLarge),
)
internal fun toEntity(): ShapesEntity {
return ShapesEntity(
id = id,
name = name,
baseShape = baseShape.toString(),
extraSmall = extraSmall?.toString(),
small = small?.toString(),
medium = medium?.toString(),
large = large?.toString(),
largeIncreased = largeIncreased?.toString(),
extraLarge = extraLarge?.toString(),
extraLargeIncreased = extraLargeIncreased?.toString(),
extraExtraLarge = extraExtraLarge?.toString(),
)
}
}
@Serializable(with = ShapeSerializer::class)
data class Shape(
/**
* The style of the corners.
* null to inherit the corner style from the base shape.
*/
val corners: CornerStyle? = null,
/**
* Radii in dp, in the order of top-start, top-end, bottom-end, bottom-start.
* null to inherit the radius from the base shape.
*/
val radii: IntArray? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Shape) return false
return corners == other.corners &&
radii.contentEquals(other.radii)
}
override fun hashCode(): Int {
var result = corners.hashCode()
result = 31 * result + radii.contentHashCode()
return result
}
override fun toString(): String {
val type = when (corners) {
CornerStyle.Rounded -> "r"
CornerStyle.Cut -> "c"
null -> "$"
}
val radii = radii?.joinToString("|") ?: ""
return "$type.${radii}"
}
companion object {
fun fromString(string: String?): Shape? {
if (string == null) return null
val parts = string.split('.')
val corners = when (parts[0]) {
"r" -> CornerStyle.Rounded
"c" -> CornerStyle.Cut
else -> null
}
val radii = if (parts.size > 1 && parts[1].isNotEmpty()) {
parts[1].split("|").map { it.toInt() }.toIntArray()
} else {
null
}
return Shape(corners, radii)
}
}
}
enum class CornerStyle {
Rounded,
Cut,
}
@@ -4,7 +4,8 @@ import android.content.Context
import de.mm20.launcher2.backup.Backupable
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.preferences.ThemeDescriptor
import de.mm20.launcher2.preferences.ColorsDescriptor
import de.mm20.launcher2.preferences.ShapesDescriptor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -16,7 +17,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import java.io.File
import java.util.UUID
@@ -26,50 +26,50 @@ class ThemeRepository(
) : Backupable {
private val scope = CoroutineScope(Dispatchers.IO + Job())
fun getThemes(): Flow<List<Theme>> {
return database.themeDao().getAll().map {
getBuiltInThemes() + it.map { Theme(it) }
fun getAllColors(): Flow<List<Colors>> {
return database.themeDao().getAllColors().map {
getBuiltInColors() + it.map { Colors(it) }
}
}
fun getTheme(id: UUID): Flow<Theme?> {
if (id == DefaultThemeId) return flowOf(getDefaultTheme())
if (id == BlackAndWhiteThemeId) return flowOf(getBlackAndWhiteTheme())
return database.themeDao().get(id).map { it?.let { Theme(it) } }.flowOn(Dispatchers.Default)
fun getColors(id: UUID): Flow<Colors?> {
if (id == DefaultThemeId) return flowOf(getDefaultColors())
if (id == BlackAndWhiteThemeId) return flowOf(getBlackAndWhiteColors())
return database.themeDao().getColors(id).map { it?.let { Colors(it) } }.flowOn(Dispatchers.Default)
}
fun createTheme(theme: Theme) {
fun createColors(colors: Colors) {
scope.launch {
database.themeDao().insert(theme.toEntity())
database.themeDao().insertColors(colors.toEntity())
}
}
fun updateTheme(theme: Theme) {
fun updateColors(colors: Colors) {
scope.launch {
database.themeDao().update(theme.toEntity())
database.themeDao().updateColors(colors.toEntity())
}
}
fun getThemeOrDefault(theme: ThemeDescriptor?): Flow<Theme> {
fun getColorsOrDefault(theme: ColorsDescriptor?): Flow<Colors> {
return when(theme) {
is ThemeDescriptor.BlackAndWhite -> flowOf(getBlackAndWhiteTheme())
is ThemeDescriptor.Custom -> {
is ColorsDescriptor.BlackAndWhite -> flowOf(getBlackAndWhiteColors())
is ColorsDescriptor.Custom -> {
val id = UUID.fromString(theme.id)
getTheme(id).map { it ?: getDefaultTheme() }
getColors(id).map { it ?: getDefaultColors() }
}
else -> flowOf(getDefaultTheme())
else -> flowOf(getDefaultColors())
}
}
private fun getBuiltInThemes(): List<Theme> {
private fun getBuiltInColors(): List<Colors> {
return listOf(
getDefaultTheme(),
getBlackAndWhiteTheme(),
getDefaultColors(),
getBlackAndWhiteColors(),
)
}
fun getDefaultTheme(): Theme {
return Theme(
private fun getDefaultColors(): Colors {
return Colors(
id = DefaultThemeId,
builtIn = true,
name = context.getString(R.string.preference_colors_default),
@@ -79,8 +79,8 @@ class ThemeRepository(
)
}
private fun getBlackAndWhiteTheme(): Theme {
return Theme(
private fun getBlackAndWhiteColors(): Colors {
return Colors(
id = BlackAndWhiteThemeId,
builtIn = true,
name = context.getString(R.string.preference_colors_bw),
@@ -90,16 +90,127 @@ class ThemeRepository(
)
}
fun deleteTheme(theme: Theme) {
fun deleteColors(colors: Colors) {
scope.launch {
database.themeDao().delete(theme.id)
database.themeDao().deleteColors(colors.id)
}
}
fun getAllShapes(): Flow<List<Shapes>> {
return database.themeDao().getAllShapes().map {
getBuiltInShapes() + it.map { Shapes(it) }
}
}
fun getShapes(id: UUID): Flow<Shapes?> {
if (id == DefaultThemeId) return flowOf(getDefaultShapes())
if (id == ExtraRoundShapesId) return flowOf(getExtraRoundShapes())
if (id == RectShapesId) return flowOf(getRectShapes())
if (id == CutShapesId) return flowOf(getCutShapes())
return database.themeDao().getShapes(id).map { it?.let { Shapes(it) } }.flowOn(Dispatchers.Default)
}
fun createShapes(shapes: Shapes) {
scope.launch {
database.themeDao().insertShapes(shapes.toEntity())
}
}
fun updateShapes(shapes: Shapes) {
scope.launch {
database.themeDao().updateShapes(shapes.toEntity())
}
}
fun getShapesOrDefault(theme: ShapesDescriptor?): Flow<Shapes> {
return when(theme) {
is ShapesDescriptor.Custom -> {
val id = UUID.fromString(theme.id)
getShapes(id).map { it ?: getDefaultShapes() }
}
is ShapesDescriptor.ExtraRound -> flowOf(getExtraRoundShapes())
is ShapesDescriptor.Rect -> flowOf(getRectShapes())
is ShapesDescriptor.Cut -> flowOf(getCutShapes())
else -> flowOf(getDefaultShapes())
}
}
private fun getBuiltInShapes(): List<Shapes> {
return listOf(
getDefaultShapes(),
getExtraRoundShapes(),
getRectShapes(),
getCutShapes(),
)
}
private fun getDefaultShapes(): Shapes {
return Shapes(
id = DefaultThemeId,
builtIn = true,
name = context.getString(R.string.preference_shapes_default),
baseShape = Shape(
corners = CornerStyle.Rounded,
radii = intArrayOf(12, 12, 12, 12),
)
)
}
private fun getCutShapes(): Shapes {
return Shapes(
id = CutShapesId,
builtIn = true,
name = context.getString(R.string.preference_cards_shape_cut),
baseShape = Shape(
corners = CornerStyle.Cut,
radii = intArrayOf(12, 12, 12, 12),
)
)
}
private fun getExtraRoundShapes(): Shapes {
return Shapes(
id = ExtraRoundShapesId,
builtIn = true,
name = context.getString(R.string.preference_shapes_extra_round),
baseShape = Shape(
corners = CornerStyle.Rounded,
radii = intArrayOf(24, 24, 24, 24),
),
extraLarge = Shape(
radii = intArrayOf(36, 36, 36, 36),
),
extraLargeIncreased = Shape(
radii = intArrayOf(40, 40, 40, 40),
),
extraExtraLarge = Shape(
radii = intArrayOf(56, 56, 56, 56),
)
)
}
private fun getRectShapes(): Shapes {
return Shapes(
id = RectShapesId,
builtIn = true,
name = context.getString(R.string.preference_shapes_rect),
baseShape = Shape(
corners = CornerStyle.Rounded,
radii = intArrayOf(0, 0, 0, 0),
)
)
}
fun deleteShapes(shapes: Shapes) {
scope.launch {
database.themeDao().deleteShapes(shapes.id)
}
}
override suspend fun backup(toDir: File) = withContext(Dispatchers.IO) {
val dao = database.themeDao()
val themes = dao.getAll().first().map { Theme(it) }
val data = ThemeJson.encodeToString(themes)
val colors = dao.getAllColors().first().map { Colors(it) }
val data = LegacyThemeJson.encodeToString(colors)
val file = File(toDir, "themes.0000")
file.bufferedWriter().use {
@@ -109,7 +220,7 @@ class ThemeRepository(
override suspend fun restore(fromDir: File) = withContext(Dispatchers.IO) {
val dao = database.themeDao()
dao.deleteAll()
dao.deleteAllColors()
val files =
fromDir.listFiles { _, name -> name.startsWith("themes.") }
@@ -117,8 +228,8 @@ class ThemeRepository(
for (file in files) {
val data = file.inputStream().reader().readText()
val themes: List<Theme> = try {
ThemeJson.decodeFromString(data)
val colors: List<Colors> = try {
LegacyThemeJson.decodeFromString(data)
} catch (e: SerializationException) {
CrashReporter.logException(e)
continue
@@ -126,7 +237,7 @@ class ThemeRepository(
CrashReporter.logException(e)
continue
}
dao.insertAll(themes.map { it.toEntity() })
dao.insertAllColors(colors.map { it.toEntity() })
}
}