├── dynamic_theme ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── t8rin │ │ └── dynamic │ │ └── theme │ │ ├── quantize │ │ ├── Quantizer.kt │ │ ├── QuantizerResult.kt │ │ ├── PointProvider.kt │ │ ├── QuantizerMap.kt │ │ ├── PointProviderLab.kt │ │ ├── QuantizerCelebi.kt │ │ ├── QuantizerWsmeans.kt │ │ └── QuantizerWu.kt │ │ ├── scheme │ │ ├── Variant.kt │ │ ├── SchemeMonochrome.kt │ │ ├── SchemeNeutral.kt │ │ ├── SchemeTonalSpot.kt │ │ ├── SchemeVibrant.kt │ │ ├── SchemeExpressive.kt │ │ ├── SchemeFidelity.kt │ │ ├── SchemeContent.kt │ │ ├── DynamicScheme.kt │ │ └── Scheme.kt │ │ ├── dynamiccolor │ │ ├── TonePolarity.kt │ │ └── ToneDeltaConstraint.kt │ │ ├── utils │ │ ├── StringUtils.kt │ │ ├── MathUtils.kt │ │ └── ColorUtils.kt │ │ ├── dislike │ │ └── DislikeAnalyzer.kt │ │ ├── palettes │ │ ├── TonalPalette.kt │ │ └── CorePalette.kt │ │ ├── blend │ │ └── Blend.kt │ │ ├── hct │ │ ├── Hct.kt │ │ ├── ViewingConditions.kt │ │ └── Cam16.java │ │ ├── score │ │ └── Score.kt │ │ ├── contrast │ │ └── Contrast.kt │ │ ├── temperature │ │ └── TemperatureCache.kt │ │ └── DynamicTheme.kt ├── proguard-rules.pro └── build.gradle.kts ├── .idea ├── .gitignore ├── compiler.xml ├── vcs.xml ├── misc.xml ├── gradle.xml └── inspectionProfiles │ └── Project_Default.xml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── gradle.properties ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE /dynamic_theme/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /dynamic_theme/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/T8RIN/DynamicTheme/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /dynamic_theme/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Apr 01 18:59:29 MSK 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "DynamicTheme" 16 | include ":dynamic_theme" -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /dynamic_theme/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/Quantizer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | internal interface Quantizer { 19 | fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult 20 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/QuantizerResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | /** 19 | * Represents result of a quantizer run 20 | */ 21 | class QuantizerResult internal constructor(val colorToCount: Map) -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/Variant.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | /** 19 | * Themes for Dynamic Color. 20 | */ 21 | enum class Variant { 22 | MONOCHROME, NEUTRAL, TONAL_SPOT, VIBRANT, EXPRESSIVE, FIDELITY, CONTENT 23 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/dynamiccolor/TonePolarity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.dynamiccolor 17 | 18 | /** 19 | * Describes the relationship in lightness between two colors. 20 | */ 21 | enum class TonePolarity { 22 | DARKER, LIGHTER, NO_PREFERENCE 23 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/PointProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | /** 19 | * An interface to allow use of different color spaces by quantizers. 20 | */ 21 | interface PointProvider { 22 | fun fromInt(argb: Int): DoubleArray 23 | fun toInt(point: DoubleArray?): Int 24 | fun distance(a: DoubleArray?, b: DoubleArray?): Double 25 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/utils/StringUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.utils 17 | 18 | /** 19 | * Utility methods for string representations of colors. 20 | */ 21 | internal object StringUtils { 22 | /** 23 | * Hex string representing color, ex. #ff0000 for red. 24 | * 25 | * @param argb ARGB representation of a color. 26 | */ 27 | fun hexFromArgb(argb: Int): String { 28 | val red = ColorUtils.redFromArgb(argb) 29 | val blue = ColorUtils.blueFromArgb(argb) 30 | val green = ColorUtils.greenFromArgb(argb) 31 | return String.format("#%02x%02x%02x", red, green, blue) 32 | } 33 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/dynamiccolor/ToneDeltaConstraint.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.dynamiccolor 17 | 18 | /** 19 | * Documents a constraint between two DynamicColors, in which their tones must have a certain 20 | * distance from each other. 21 | */ 22 | class ToneDeltaConstraint 23 | /** 24 | * @param delta the difference in tone required 25 | * @param keepAway the color to distance in tone from 26 | * @param keepAwayPolarity whether the color to keep away from must be lighter, darker, or no 27 | * preference, in which case it should 28 | */(val delta: Double, val keepAway: DynamicColor, val keepAwayPolarity: TonePolarity) -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeMonochrome.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 20 | 21 | /** 22 | * A monochrome theme, colors are purely black / white / gray. 23 | */ 24 | class SchemeMonochrome(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 25 | sourceColorHct, 26 | Variant.MONOCHROME, 27 | isDark, 28 | contrastLevel, 29 | fromHueAndChroma(sourceColorHct.hue, 0.0), 30 | fromHueAndChroma(sourceColorHct.hue, 0.0), 31 | fromHueAndChroma(sourceColorHct.hue, 0.0), 32 | fromHueAndChroma(sourceColorHct.hue, 0.0), 33 | fromHueAndChroma(sourceColorHct.hue, 0.0) 34 | ) -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/QuantizerMap.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | /** 19 | * Creates a dictionary with keys of colors, and values of count of the color 20 | */ 21 | class QuantizerMap : Quantizer { 22 | var colorToCount: Map? = null 23 | override fun quantize(pixels: IntArray, colorCount: Int): QuantizerResult { 24 | val pixelByCount: MutableMap = LinkedHashMap() 25 | for (pixel in pixels) { 26 | val currentPixelCount = pixelByCount[pixel] 27 | val newPixelCount = if (currentPixelCount == null) 1 else currentPixelCount + 1 28 | pixelByCount[pixel] = newPixelCount 29 | } 30 | colorToCount = pixelByCount 31 | return QuantizerResult(pixelByCount) 32 | } 33 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeNeutral.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 20 | 21 | /** 22 | * A theme that's slightly more chromatic than monochrome, which is purely black / white / gray. 23 | */ 24 | class SchemeNeutral(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 25 | sourceColorHct, 26 | Variant.NEUTRAL, 27 | isDark, 28 | contrastLevel, 29 | fromHueAndChroma(sourceColorHct.hue, 12.0), 30 | fromHueAndChroma(sourceColorHct.hue, 8.0), 31 | fromHueAndChroma(sourceColorHct.hue, 16.0), 32 | fromHueAndChroma(sourceColorHct.hue, 2.0), 33 | fromHueAndChroma(sourceColorHct.hue, 2.0) 34 | ) -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeTonalSpot.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 20 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesDouble 21 | 22 | /** 23 | * A calm theme, sedated colors that aren't particularly chromatic. 24 | */ 25 | class SchemeTonalSpot(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 26 | sourceColorHct, 27 | Variant.TONAL_SPOT, 28 | isDark, 29 | contrastLevel, 30 | fromHueAndChroma(sourceColorHct.hue, 40.0), 31 | fromHueAndChroma(sourceColorHct.hue, 16.0), 32 | fromHueAndChroma( 33 | sanitizeDegreesDouble(sourceColorHct.hue + 60.0), 24.0 34 | ), 35 | fromHueAndChroma(sourceColorHct.hue, 6.0), 36 | fromHueAndChroma(sourceColorHct.hue, 8.0) 37 | ) -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeVibrant.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 20 | 21 | /** 22 | * A loud theme, colorfulness is maximum for Primary palette, increased for others. 23 | */ 24 | class SchemeVibrant(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 25 | sourceColorHct, 26 | Variant.VIBRANT, 27 | isDark, 28 | contrastLevel, 29 | fromHueAndChroma(sourceColorHct.hue, 200.0), 30 | fromHueAndChroma( 31 | DynamicScheme.Companion.getRotatedHue(sourceColorHct, HUES, SECONDARY_ROTATIONS), 24.0 32 | ), 33 | fromHueAndChroma( 34 | DynamicScheme.Companion.getRotatedHue(sourceColorHct, HUES, TERTIARY_ROTATIONS), 32.0 35 | ), 36 | fromHueAndChroma(sourceColorHct.hue, 8.0), 37 | fromHueAndChroma(sourceColorHct.hue, 12.0) 38 | ) { 39 | companion object { 40 | private val HUES = doubleArrayOf(0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0) 41 | private val SECONDARY_ROTATIONS = 42 | doubleArrayOf(18.0, 15.0, 10.0, 12.0, 15.0, 18.0, 15.0, 12.0, 12.0) 43 | private val TERTIARY_ROTATIONS = 44 | doubleArrayOf(35.0, 30.0, 20.0, 25.0, 30.0, 35.0, 30.0, 25.0, 25.0) 45 | } 46 | } -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 32 | -------------------------------------------------------------------------------- /dynamic_theme/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("org.jetbrains.kotlin.android") 4 | id("maven-publish") 5 | } 6 | 7 | android { 8 | namespace = "com.cookhelper.dynamic.theme" 9 | compileSdk = 33 10 | 11 | defaultConfig { 12 | minSdk = 21 13 | targetSdk = 33 14 | 15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 16 | consumerProguardFiles("consumer-rules.pro") 17 | } 18 | 19 | buildTypes { 20 | release { 21 | isMinifyEnabled = false 22 | proguardFiles( 23 | getDefaultProguardFile("proguard-android-optimize.txt"), 24 | "proguard-rules.pro" 25 | ) 26 | } 27 | } 28 | compileOptions { 29 | isCoreLibraryDesugaringEnabled = true 30 | sourceCompatibility = JavaVersion.VERSION_11 31 | targetCompatibility = JavaVersion.VERSION_11 32 | } 33 | kotlinOptions { 34 | jvmTarget = "11" 35 | } 36 | buildFeatures { 37 | compose = true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion = "1.4.2" 41 | } 42 | publishing { 43 | singleVariant("release") { 44 | withSourcesJar() 45 | withJavadocJar() 46 | } 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation(platform("androidx.compose:compose-bom:2023.01.00")) 52 | implementation("androidx.core:core-ktx:1.10.0") 53 | implementation("androidx.compose.material3:material3") 54 | implementation("androidx.palette:palette:1.0.0") 55 | implementation("com.google.accompanist:accompanist-systemuicontroller:0.30.1") 56 | coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") 57 | } 58 | 59 | publishing { 60 | publications { 61 | register("release") { 62 | groupId = "com.github.t8rin" 63 | artifactId = "dynamictheme" 64 | version = "1.0.3" 65 | 66 | afterEvaluate { 67 | from(components["release"]) 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeExpressive.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 20 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesDouble 21 | 22 | /** 23 | * A playful theme - the source color's hue does not appear in the theme. 24 | */ 25 | class SchemeExpressive(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 26 | sourceColorHct, 27 | Variant.EXPRESSIVE, 28 | isDark, 29 | contrastLevel, 30 | fromHueAndChroma( 31 | sanitizeDegreesDouble(sourceColorHct.hue + 120.0), 40.0 32 | ), 33 | fromHueAndChroma( 34 | DynamicScheme.Companion.getRotatedHue(sourceColorHct, HUES, SECONDARY_ROTATIONS), 24.0 35 | ), 36 | fromHueAndChroma( 37 | DynamicScheme.Companion.getRotatedHue(sourceColorHct, HUES, TERTIARY_ROTATIONS), 32.0 38 | ), 39 | fromHueAndChroma(sourceColorHct.hue, 8.0), 40 | fromHueAndChroma(sourceColorHct.hue, 12.0) 41 | ) { 42 | companion object { 43 | // NOMUTANTS--arbitrary increments/decrements, correctly, still passes tests. 44 | private val HUES = doubleArrayOf(0.0, 21.0, 51.0, 121.0, 151.0, 191.0, 271.0, 321.0, 360.0) 45 | private val SECONDARY_ROTATIONS = 46 | doubleArrayOf(45.0, 95.0, 45.0, 20.0, 45.0, 90.0, 45.0, 45.0, 45.0) 47 | private val TERTIARY_ROTATIONS = 48 | doubleArrayOf(120.0, 120.0, 20.0, 45.0, 20.0, 15.0, 20.0, 120.0, 120.0) 49 | } 50 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/dislike/DislikeAnalyzer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.dislike 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | 20 | /** 21 | * Check and/or fix universally disliked colors. 22 | * 23 | * 24 | * Color science studies of color preference indicate universal distaste for dark yellow-greens, 25 | * and also show this is correlated to distate for biological waste and rotting food. 26 | * 27 | * 28 | * See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color 29 | * Psychology (2015). 30 | */ 31 | class DislikeAnalyzer private constructor() { 32 | init { 33 | throw UnsupportedOperationException() 34 | } 35 | 36 | companion object { 37 | /** 38 | * Returns true if color is disliked. 39 | * 40 | * 41 | * Disliked is defined as a dark yellow-green that is not neutral. 42 | */ 43 | fun isDisliked(hct: Hct): Boolean { 44 | val huePasses = Math.round(hct.hue) >= 90.0 && Math.round(hct.hue) <= 111.0 45 | val chromaPasses = Math.round(hct.chroma) > 16.0 46 | val tonePasses = Math.round(hct.tone) < 70.0 47 | return huePasses && chromaPasses && tonePasses 48 | } 49 | 50 | /** 51 | * If color is disliked, lighten it to make it likable. 52 | */ 53 | @JvmStatic 54 | fun fixIfDisliked(hct: Hct): Hct { 55 | return if (isDisliked(hct)) { 56 | Hct.from(hct.hue, hct.chroma, 70.0) 57 | } else hct 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeFidelity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.dislike.DislikeAnalyzer.Companion.fixIfDisliked 19 | import com.t8rin.dynamic.theme.hct.Hct 20 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHct 21 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 22 | import com.t8rin.dynamic.theme.temperature.TemperatureCache 23 | 24 | /** 25 | * A scheme that places the source color in Scheme.primaryContainer. 26 | * 27 | * 28 | * Primary Container is the source color, adjusted for color relativity. It maintains constant 29 | * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in 30 | * dark mode. 31 | * 32 | * 33 | * Tertiary Container is the complement to the source color, using TemperatureCache. It also 34 | * maintains constant appearance. 35 | */ 36 | class SchemeFidelity(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 37 | sourceColorHct, 38 | Variant.FIDELITY, 39 | isDark, 40 | contrastLevel, 41 | fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma), 42 | fromHueAndChroma( 43 | sourceColorHct.hue, 44 | Math.max(sourceColorHct.chroma - 32.0, sourceColorHct.chroma * 0.5) 45 | ), 46 | fromHct( 47 | fixIfDisliked(TemperatureCache(sourceColorHct).complement!!) 48 | ), 49 | fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma / 8.0), 50 | fromHueAndChroma( 51 | sourceColorHct.hue, sourceColorHct.chroma / 8.0 + 4.0 52 | ) 53 | ) -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/PointProviderLab.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | import com.t8rin.dynamic.theme.utils.ColorUtils.argbFromLab 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.labFromArgb 20 | 21 | /** 22 | * Provides conversions needed for K-Means quantization. Converting input to points, and converting 23 | * the final state of the K-Means algorithm to colors. 24 | */ 25 | class PointProviderLab : PointProvider { 26 | /** 27 | * Convert a color represented in ARGB to a 3-element array of L*a*b* coordinates of the color. 28 | */ 29 | override fun fromInt(argb: Int): DoubleArray { 30 | val lab = labFromArgb(argb) 31 | return doubleArrayOf(lab[0], lab[1], lab[2]) 32 | } 33 | 34 | /** 35 | * Convert a 3-element array to a color represented in ARGB. 36 | */ 37 | override fun toInt(lab: DoubleArray?): Int { 38 | return argbFromLab(lab!![0], lab[1], lab[2]) 39 | } 40 | 41 | /** 42 | * Standard CIE 1976 delta E formula also takes the square root, unneeded here. This method is 43 | * used by quantization algorithms to compare distance, and the relative ordering is the same, 44 | * with or without a square root. 45 | * 46 | * 47 | * This relatively minor optimization is helpful because this method is called at least once 48 | * for each pixel in an image. 49 | */ 50 | override fun distance(one: DoubleArray?, two: DoubleArray?): Double { 51 | val dL = one!![0] - two!![0] 52 | val dA = one[1] - two[1] 53 | val dB = one[2] - two[2] 54 | return dL * dL + dA * dA + dB * dB 55 | } 56 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/QuantizerCelebi.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | /** 19 | * An image quantizer that improves on the quality of a standard K-Means algorithm by setting the 20 | * K-Means initial state to the output of a Wu quantizer, instead of random centroids. Improves on 21 | * speed by several optimizations, as implemented in Wsmeans, or Weighted Square Means, K-Means with 22 | * those optimizations. 23 | * 24 | * 25 | * This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving 26 | * the Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 27 | */ 28 | object QuantizerCelebi { 29 | /** 30 | * Reduce the number of colors needed to represented the input, minimizing the difference between 31 | * the original image and the recolored image. 32 | * 33 | * @param pixels Colors in ARGB format. 34 | * @param maxColors The number of colors to divide the image into. A lower number of colors may be 35 | * returned. 36 | * @return Map with keys of colors in ARGB format, and values of number of pixels in the original 37 | * image that correspond to the color in the quantized image. 38 | */ 39 | fun quantize(pixels: IntArray, maxColors: Int): Map { 40 | val wu = QuantizerWu() 41 | val wuResult = wu.quantize(pixels, maxColors) 42 | val wuClustersAsObjects: Set = wuResult.colorToCount.keys 43 | var index = 0 44 | val wuClusters = IntArray(wuClustersAsObjects.size) 45 | for (argb in wuClustersAsObjects) { 46 | wuClusters[index++] = argb 47 | } 48 | return QuantizerWsmeans.quantize(pixels, wuClusters, maxColors) 49 | } 50 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/SchemeContent.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.dislike.DislikeAnalyzer.Companion.fixIfDisliked 19 | import com.t8rin.dynamic.theme.hct.Hct 20 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHct 21 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 22 | import com.t8rin.dynamic.theme.temperature.TemperatureCache 23 | 24 | /** 25 | * A scheme that places the source color in Scheme.primaryContainer. 26 | * 27 | * 28 | * Primary Container is the source color, adjusted for color relativity. It maintains constant 29 | * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in 30 | * dark mode. 31 | * 32 | * 33 | * Tertiary Container is an analogous color, specifically, the analog of a color wheel divided 34 | * into 6, and the precise analog is the one found by increasing hue. This is a scientifically 35 | * grounded equivalent to rotating hue clockwise by 60 degrees. It also maintains constant 36 | * appearance. 37 | */ 38 | class SchemeContent(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : DynamicScheme( 39 | sourceColorHct, 40 | Variant.CONTENT, 41 | isDark, 42 | contrastLevel, 43 | fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma), 44 | fromHueAndChroma( 45 | sourceColorHct.hue, 46 | Math.max(sourceColorHct.chroma - 32.0, sourceColorHct.chroma * 0.5) 47 | ), 48 | fromHct( 49 | fixIfDisliked( 50 | TemperatureCache(sourceColorHct) 51 | .getAnalogousColors( /* count= */3, /* divisions= */6)[2] 52 | ) 53 | ), 54 | fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma / 8.0), 55 | fromHueAndChroma( 56 | sourceColorHct.hue, sourceColorHct.chroma / 8.0 + 4.0 57 | ) 58 | ) -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/palettes/TonalPalette.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.palettes 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | 20 | /** 21 | * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. 22 | */ 23 | class TonalPalette private constructor(hue: Double, chroma: Double) { 24 | var cache: MutableMap = HashMap() 25 | var hue: Double 26 | var chroma: Double 27 | 28 | init { 29 | this.hue = hue 30 | this.chroma = chroma 31 | } 32 | 33 | /** 34 | * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. 35 | * 36 | * @param tone HCT tone, measured from 0 to 100. 37 | * @return ARGB representation of a color with that tone. 38 | */ 39 | // AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923) 40 | fun tone(tone: Int): Int { 41 | var color = cache[tone] 42 | if (color == null) { 43 | color = Hct.from(hue, chroma, tone.toDouble()).toInt() 44 | cache[tone] = color 45 | } 46 | return color 47 | } 48 | 49 | fun getHct(tone: Double): Hct { 50 | return Hct.from(hue, chroma, tone) 51 | } 52 | 53 | companion object { 54 | /** 55 | * Create tones using the HCT hue and chroma from a color. 56 | * 57 | * @param argb ARGB representation of a color 58 | * @return Tones matching that color's hue and chroma. 59 | */ 60 | fun fromInt(argb: Int): TonalPalette { 61 | return fromHct(Hct.fromInt(argb)) 62 | } 63 | 64 | /** 65 | * Create tones using a HCT color. 66 | * 67 | * @param hct HCT representation of a color. 68 | * @return Tones matching that color's hue and chroma. 69 | */ 70 | @JvmStatic 71 | fun fromHct(hct: Hct): TonalPalette { 72 | return fromHueAndChroma(hct.hue, hct.chroma) 73 | } 74 | 75 | /** 76 | * Create tones from a defined HCT hue and chroma. 77 | * 78 | * @param hue HCT hue 79 | * @param chroma HCT chroma 80 | * @return Tones matching hue and chroma. 81 | */ 82 | @JvmStatic 83 | fun fromHueAndChroma(hue: Double, chroma: Double): TonalPalette { 84 | return TonalPalette(hue, chroma) 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/palettes/CorePalette.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.palettes 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | 20 | /** 21 | * An intermediate concept between the key color for a UI theme, and a full color scheme. 5 sets of 22 | * tones are generated, all except one use the same hue as the key color, and all vary in chroma. 23 | */ 24 | class CorePalette private constructor(argb: Int, isContent: Boolean) { 25 | @JvmField 26 | var a1: TonalPalette? = null 27 | 28 | @JvmField 29 | var a2: TonalPalette? = null 30 | 31 | @JvmField 32 | var a3: TonalPalette? = null 33 | 34 | @JvmField 35 | var n1: TonalPalette? = null 36 | 37 | @JvmField 38 | var n2: TonalPalette? = null 39 | 40 | @JvmField 41 | var error: TonalPalette 42 | 43 | init { 44 | val hct = Hct.fromInt(argb) 45 | val hue = hct.hue 46 | val chroma = hct.chroma 47 | if (isContent) { 48 | a1 = TonalPalette.Companion.fromHueAndChroma(hue, chroma) 49 | a2 = TonalPalette.Companion.fromHueAndChroma(hue, chroma / 3.0) 50 | a3 = TonalPalette.Companion.fromHueAndChroma(hue + 60.0, chroma / 2.0) 51 | n1 = TonalPalette.Companion.fromHueAndChroma(hue, Math.min(chroma / 12.0, 4.0)) 52 | n2 = TonalPalette.Companion.fromHueAndChroma(hue, Math.min(chroma / 6.0, 8.0)) 53 | } else { 54 | a1 = TonalPalette.Companion.fromHueAndChroma(hue, Math.max(48.0, chroma)) 55 | a2 = TonalPalette.Companion.fromHueAndChroma(hue, 16.0) 56 | a3 = TonalPalette.Companion.fromHueAndChroma(hue + 60.0, 24.0) 57 | n1 = TonalPalette.Companion.fromHueAndChroma(hue, 4.0) 58 | n2 = TonalPalette.Companion.fromHueAndChroma(hue, 8.0) 59 | } 60 | error = TonalPalette.Companion.fromHueAndChroma(25.0, 84.0) 61 | } 62 | 63 | companion object { 64 | /** 65 | * Create key tones from a color. 66 | * 67 | * @param argb ARGB representation of a color 68 | */ 69 | @JvmStatic 70 | fun of(argb: Int): CorePalette { 71 | return CorePalette(argb, false) 72 | } 73 | 74 | /** 75 | * Create content key tones from a color. 76 | * 77 | * @param argb ARGB representation of a color 78 | */ 79 | @JvmStatic 80 | fun contentOf(argb: Int): CorePalette { 81 | return CorePalette(argb, true) 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/DynamicScheme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.scheme 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.palettes.TonalPalette 20 | import com.t8rin.dynamic.theme.palettes.TonalPalette.Companion.fromHueAndChroma 21 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesDouble 22 | 23 | /** 24 | * Provides important settings for creating colors dynamically, and 6 color palettes. Requires: 1. A 25 | * color. (source color) 2. A theme. (Variant) 3. Whether or not its dark mode. 4. Contrast level. 26 | * (-1 to 1, currently contrast ratio 3.0 and 7.0) 27 | */ 28 | open class DynamicScheme( 29 | sourceColorHct: Hct, 30 | variant: Variant, 31 | isDark: Boolean, 32 | contrastLevel: Double, 33 | primaryPalette: TonalPalette, 34 | secondaryPalette: TonalPalette, 35 | tertiaryPalette: TonalPalette, 36 | neutralPalette: TonalPalette, 37 | neutralVariantPalette: TonalPalette 38 | ) { 39 | val sourceColorArgb: Int 40 | val sourceColorHct: Hct 41 | val variant: Variant 42 | val isDark: Boolean 43 | val contrastLevel: Double 44 | val primaryPalette: TonalPalette 45 | val secondaryPalette: TonalPalette 46 | val tertiaryPalette: TonalPalette 47 | val neutralPalette: TonalPalette 48 | val neutralVariantPalette: TonalPalette 49 | val errorPalette: TonalPalette 50 | 51 | init { 52 | sourceColorArgb = sourceColorHct.toInt() 53 | this.sourceColorHct = sourceColorHct 54 | this.variant = variant 55 | this.isDark = isDark 56 | this.contrastLevel = contrastLevel 57 | this.primaryPalette = primaryPalette 58 | this.secondaryPalette = secondaryPalette 59 | this.tertiaryPalette = tertiaryPalette 60 | this.neutralPalette = neutralPalette 61 | this.neutralVariantPalette = neutralVariantPalette 62 | errorPalette = fromHueAndChroma(25.0, 84.0) 63 | } 64 | 65 | companion object { 66 | fun getRotatedHue(sourceColorHct: Hct, hues: DoubleArray, rotations: DoubleArray): Double { 67 | val sourceHue = sourceColorHct.hue 68 | if (rotations.size == 1) { 69 | return sanitizeDegreesDouble(sourceHue + rotations[0]) 70 | } 71 | val size = hues.size 72 | for (i in 0..size - 2) { 73 | val thisHue = hues[i] 74 | val nextHue = hues[i + 1] 75 | if (thisHue < sourceHue && sourceHue < nextHue) { 76 | return sanitizeDegreesDouble(sourceHue + rotations[i]) 77 | } 78 | } 79 | // If this statement executes, something is wrong, there should have been a rotation 80 | // found using the arrays. 81 | return sourceHue 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/blend/Blend.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // This file is automatically generated. Do not modify it. 17 | package com.t8rin.dynamic.theme.blend 18 | 19 | import com.t8rin.dynamic.theme.hct.Cam16 20 | import com.t8rin.dynamic.theme.hct.Hct 21 | import com.t8rin.dynamic.theme.utils.ColorUtils.lstarFromArgb 22 | import com.t8rin.dynamic.theme.utils.MathUtils.differenceDegrees 23 | import com.t8rin.dynamic.theme.utils.MathUtils.rotationDirection 24 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesDouble 25 | 26 | /** 27 | * Functions for blending in HCT and CAM16. 28 | */ 29 | object Blend { 30 | /** 31 | * Blend the design color's HCT hue towards the key color's HCT hue, in a way that leaves the 32 | * original color recognizable and recognizably shifted towards the key color. 33 | * 34 | * @param designColor ARGB representation of an arbitrary color. 35 | * @param sourceColor ARGB representation of the main theme color. 36 | * @return The design color with a hue shifted towards the system's color, a slightly 37 | * warmer/cooler variant of the design color's hue. 38 | */ 39 | fun harmonize(designColor: Int, sourceColor: Int): Int { 40 | val fromHct = Hct.fromInt(designColor) 41 | val toHct = Hct.fromInt(sourceColor) 42 | val differenceDegrees = differenceDegrees(fromHct.hue, toHct.hue) 43 | val rotationDegrees = Math.min(differenceDegrees * 0.5, 15.0) 44 | val outputHue = sanitizeDegreesDouble( 45 | fromHct.hue 46 | + rotationDegrees * rotationDirection(fromHct.hue, toHct.hue) 47 | ) 48 | return Hct.from(outputHue, fromHct.chroma, fromHct.tone).toInt() 49 | } 50 | 51 | /** 52 | * Blends hue from one color into another. The chroma and tone of the original color are 53 | * maintained. 54 | * 55 | * @param from ARGB representation of color 56 | * @param to ARGB representation of color 57 | * @param amount how much blending to perform; 0.0 >= and <= 1.0 58 | * @return from, with a hue blended towards to. Chroma and tone are constant. 59 | */ 60 | fun hctHue(from: Int, to: Int, amount: Double): Int { 61 | val ucs = cam16Ucs(from, to, amount) 62 | val ucsCam = Cam16.fromInt(ucs) 63 | val fromCam = Cam16.fromInt(from) 64 | val blended = Hct.from(ucsCam.hue, fromCam.chroma, lstarFromArgb(from)) 65 | return blended.toInt() 66 | } 67 | 68 | /** 69 | * Blend in CAM16-UCS space. 70 | * 71 | * @param from ARGB representation of color 72 | * @param to ARGB representation of color 73 | * @param amount how much blending to perform; 0.0 >= and <= 1.0 74 | * @return from, blended towards to. Hue, chroma, and tone will change. 75 | */ 76 | fun cam16Ucs(from: Int, to: Int, amount: Double): Int { 77 | val fromCam = Cam16.fromInt(from) 78 | val toCam = Cam16.fromInt(to) 79 | val fromJ = fromCam.jstar 80 | val fromA = fromCam.astar 81 | val fromB = fromCam.bstar 82 | val toJ = toCam.jstar 83 | val toA = toCam.astar 84 | val toB = toCam.bstar 85 | val jstar = fromJ + (toJ - fromJ) * amount 86 | val astar = fromA + (toA - fromA) * amount 87 | val bstar = fromB + (toB - fromB) * amount 88 | return Cam16.fromUcs(jstar, astar, bstar).toInt() 89 | } 90 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | DynamicTheme 5 | 6 |

7 | 8 | 9 |

10 | material 11 | API 12 | Kotlin 13 | Jetpack Compose 14 | Hits 15 |

16 | 17 | 18 |

🎨 Jetpack Compose material theming library, which falls back onto a custom dynamic colors implementation based on wallpapers to support older API levels. 19 | Yes! Dynamic colors are now available even on old android devices, enjoy!

20 | 21 | # Examples (See [ImageResizer](https://github.com/t8rin/imageResizer)) 22 | 23 |

24 | material 25 | API 26 | Kotlin 27 |

28 | 29 | 30 | Attention: ColorPicker not included in the package 31 | 32 | # Features 33 | * Declaring custom color scheme based on up to three colors 34 | * Content based colorscheme by providing bitmap 35 | * Dynamic themeing from android 5 36 | * Amoled mode support 37 | * Flexible api to retrieve secondary and tertiary colors from given primary color 38 | * Animating color scheme changes 39 | 40 | :fire::fire::fire::fire::fire::fire::fire::fire::fire::fire::fire::fire::fire::fire: 41 | 42 | ## Usage 43 | 44 | ### 1. Add dependencies 45 | 46 | ![latestVersion](https://img.shields.io/github/v/release/t8rin/DynamicTheme?style=for-the-badge) 47 | 48 | #### Kotlin (kts) 49 | ```kotlin 50 | repositories { 51 | maven { setUrl("https://jitpack.io") } // Add jitpack 52 | } 53 | dependencies { 54 | implementation("com.github.t8rin:dynamictheme:LATEST_VERSION") // Replace "LATEST_VERSION" with preferrend version tag 55 | } 56 | ``` 57 | 58 | #### Groovy 59 | ```groovy 60 | repositories { 61 | maven { url 'https://jitpack.io' } // Add jitpack 62 | } 63 | dependencies { 64 | implementation 'com.github.t8rin:dynamictheme:LATEST_VERSION' // Replace "LATEST_VERSION" with preferrend version tag 65 | } 66 | ``` 67 | 68 | ### 2. Add `DynamicTheme` call 69 | 70 | ```kotlin 71 | @Composable 72 | fun DynamicThemeComposable() { 73 | 74 | DynamicTheme( 75 | state = rememberDynamicThemeState(), 76 | defaultColorTuple = ColorTuple( 77 | primary = /*your value*/, 78 | secondary = /*your value (optional)*/, 79 | tertiary = /*your value (optional)*/ 80 | ), 81 | content = { 82 | /*your content 83 | * there you can get you provided state value by LocalDynamicThemeState.current 84 | */ 85 | } 86 | ) 87 | 88 | //... 89 | // Also you can use ColorTupleItem to get represenation of your color scheme by three colors 90 | ``` 91 | 92 | ## Roadmap 93 | - [ ] Rewrite to Kotlin 94 | 95 | ## Find this repository useful? :heart: 96 | Support it by joining __[stargazers](https://github.com/t8rin/DynamicTheme/stargazers)__ for this repository. :star:
97 | And __[follow](https://github.com/t8rin)__ me for my next creations! 🤩 98 | 99 | # License 100 | ```xml 101 | Designed and developed by 2023 T8RIN 102 | 103 | Licensed under the Apache License, Version 2.0 (the "License"); 104 | you may not use this file except in compliance with the License. 105 | You may obtain a copy of the License at 106 | 107 | http://www.apache.org/licenses/LICENSE-2.0 108 | 109 | Unless required by applicable law or agreed to in writing, software 110 | distributed under the License is distributed on an "AS IS" BASIS, 111 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 112 | See the License for the specific language governing permissions and 113 | limitations under the License. 114 | ``` 115 | -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/utils/MathUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // This file is automatically generated. Do not modify it. 17 | package com.t8rin.dynamic.theme.utils 18 | 19 | /** 20 | * Utility methods for mathematical operations. 21 | */ 22 | object MathUtils { 23 | /** 24 | * The signum function. 25 | * 26 | * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 27 | */ 28 | @JvmStatic 29 | fun signum(num: Double): Int { 30 | return if (num < 0) { 31 | -1 32 | } else if (num == 0.0) { 33 | 0 34 | } else { 35 | 1 36 | } 37 | } 38 | 39 | /** 40 | * The linear interpolation function. 41 | * 42 | * @return start if amount = 0 and stop if amount = 1 43 | */ 44 | @JvmStatic 45 | fun lerp(start: Double, stop: Double, amount: Double): Double { 46 | return (1.0 - amount) * start + amount * stop 47 | } 48 | 49 | /** 50 | * Clamps an integer between two integers. 51 | * 52 | * @return input when min <= input <= max, and either min or max otherwise. 53 | */ 54 | @JvmStatic 55 | fun clampInt(min: Int, max: Int, input: Int): Int { 56 | if (input < min) { 57 | return min 58 | } else if (input > max) { 59 | return max 60 | } 61 | return input 62 | } 63 | 64 | /** 65 | * Clamps an integer between two floating-point numbers. 66 | * 67 | * @return input when min <= input <= max, and either min or max otherwise. 68 | */ 69 | @JvmStatic 70 | fun clampDouble(min: Double, max: Double, input: Double): Double { 71 | if (input < min) { 72 | return min 73 | } else if (input > max) { 74 | return max 75 | } 76 | return input 77 | } 78 | 79 | /** 80 | * Sanitizes a degree measure as an integer. 81 | * 82 | * @return a degree measure between 0 (inclusive) and 360 (exclusive). 83 | */ 84 | @JvmStatic 85 | fun sanitizeDegreesInt(degrees: Int): Int { 86 | var degrees = degrees 87 | degrees = degrees % 360 88 | if (degrees < 0) { 89 | degrees = degrees + 360 90 | } 91 | return degrees 92 | } 93 | 94 | /** 95 | * Sanitizes a degree measure as a floating-point number. 96 | * 97 | * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). 98 | */ 99 | @JvmStatic 100 | fun sanitizeDegreesDouble(degrees: Double): Double { 101 | var degrees = degrees 102 | degrees = degrees % 360.0 103 | if (degrees < 0) { 104 | degrees = degrees + 360.0 105 | } 106 | return degrees 107 | } 108 | 109 | /** 110 | * Sign of direction change needed to travel from one angle to another. 111 | * 112 | * 113 | * For angles that are 180 degrees apart from each other, both directions have the same travel 114 | * distance, so either direction is shortest. The value 1.0 is returned in this case. 115 | * 116 | * @param from The angle travel starts from, in degrees. 117 | * @param to The angle travel ends at, in degrees. 118 | * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads 119 | * to the shortest travel distance. 120 | */ 121 | @JvmStatic 122 | fun rotationDirection(from: Double, to: Double): Double { 123 | val increasingDifference = sanitizeDegreesDouble(to - from) 124 | return if (increasingDifference <= 180.0) 1.0 else -1.0 125 | } 126 | 127 | /** 128 | * Distance of two points on a circle, represented using degrees. 129 | */ 130 | @JvmStatic 131 | fun differenceDegrees(a: Double, b: Double): Double { 132 | return 180.0 - Math.abs(Math.abs(a - b) - 180.0) 133 | } 134 | 135 | /** 136 | * Multiplies a 1x3 row vector with a 3x3 matrix. 137 | */ 138 | @JvmStatic 139 | fun matrixMultiply(row: DoubleArray, matrix: Array): DoubleArray { 140 | val a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2] 141 | val b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2] 142 | val c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] 143 | return doubleArrayOf(a, b, c) 144 | } 145 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/hct/Hct.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.hct 17 | 18 | import com.t8rin.dynamic.theme.utils.ColorUtils.lstarFromArgb 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.lstarFromY 20 | 21 | /** 22 | * A color system built using CAM16 hue and chroma, and L* from L*a*b*. 23 | * 24 | * 25 | * Using L* creates a link between the color system, contrast, and thus accessibility. Contrast 26 | * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can 27 | * be calculated from Y. 28 | * 29 | * 30 | * Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. 31 | * 32 | * 33 | * Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A 34 | * difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 35 | * guarantees a contrast ratio >= 4.5. 36 | */ 37 | /** 38 | * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color 39 | * measurement system that can also accurately render what colors will appear as in different 40 | * lighting environments. 41 | */ 42 | class Hct private constructor(argb: Int) { 43 | var hue = 0.0 44 | private set 45 | var chroma = 0.0 46 | private set 47 | var tone = 0.0 48 | private set 49 | var argb = 0 50 | private set 51 | 52 | init { 53 | setInternalState(argb) 54 | } 55 | 56 | /** 57 | * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any 58 | * given hue and tone. 59 | * 60 | * @param newHue 0 <= newHue < 360; invalid values are corrected. 61 | */ 62 | fun setHue(newHue: Double) { 63 | setInternalState(HctSolver.solveToInt(newHue, chroma, tone)) 64 | } 65 | 66 | 67 | /** 68 | * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for 69 | * any given hue and tone. 70 | * 71 | * @param newChroma 0 <= newChroma < ? 72 | */ 73 | fun setChroma(newChroma: Double) { 74 | setInternalState(HctSolver.solveToInt(hue, newChroma, tone)) 75 | } 76 | 77 | /** 78 | * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any 79 | * given hue and tone. 80 | * 81 | * @param newTone 0 <= newTone <= 100; invalid valids are corrected. 82 | */ 83 | fun setTone(newTone: Double) { 84 | setInternalState(HctSolver.solveToInt(hue, chroma, newTone)) 85 | } 86 | 87 | fun toInt(): Int { 88 | return argb 89 | } 90 | 91 | /** 92 | * Translate a color into different ViewingConditions. 93 | * 94 | * 95 | * Colors change appearance. They look different with lights on versus off, the same color, as 96 | * in hex code, on white looks different when on black. This is called color relativity, most 97 | * famously explicated by Josef Albers in Interaction of Color. 98 | * 99 | * 100 | * In color science, color appearance models can account for this and calculate the appearance 101 | * of a color in different settings. HCT is based on CAM16, a color appearance model, and uses it 102 | * to make these calculations. 103 | * 104 | * 105 | * See ViewingConditions.make for parameters affecting color appearance. 106 | */ 107 | fun inViewingConditions(vc: ViewingConditions): Hct { 108 | // 1. Use CAM16 to find XYZ coordinates of color in specified VC. 109 | val cam16 = Cam16.fromInt(toInt()) 110 | val viewedInVc = cam16.xyzInViewingConditions(vc, null) 111 | 112 | // 2. Create CAM16 of those XYZ coordinates in default VC. 113 | val recastInVc = Cam16.fromXyzInViewingConditions( 114 | viewedInVc[0], viewedInVc[1], viewedInVc[2], ViewingConditions.DEFAULT 115 | ) 116 | 117 | // 3. Create HCT from: 118 | // - CAM16 using default VC with XYZ coordinates in specified VC. 119 | // - L* converted from Y in XYZ coordinates in specified VC. 120 | return from( 121 | recastInVc.hue, recastInVc.chroma, lstarFromY( 122 | viewedInVc[1] 123 | ) 124 | ) 125 | } 126 | 127 | private fun setInternalState(argb: Int) { 128 | this.argb = argb 129 | val cam = Cam16.fromInt(argb) 130 | hue = cam.hue 131 | chroma = cam.chroma 132 | tone = lstarFromArgb(argb) 133 | } 134 | 135 | companion object { 136 | /** 137 | * Create an HCT color from hue, chroma, and tone. 138 | * 139 | * @param hue 0 <= hue < 360; invalid values are corrected. 140 | * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than 141 | * the requested chroma. Chroma has a different maximum for any given hue and tone. 142 | * @param tone 0 <= tone <= 100; invalid values are corrected. 143 | * @return HCT representation of a color in default viewing conditions. 144 | */ 145 | fun from(hue: Double, chroma: Double, tone: Double): Hct { 146 | val argb = HctSolver.solveToInt(hue, chroma, tone) 147 | return Hct(argb) 148 | } 149 | 150 | /** 151 | * Create an HCT color from a color. 152 | * 153 | * @param argb ARGB representation of a color. 154 | * @return HCT representation of a color in default viewing conditions 155 | */ 156 | fun fromInt(argb: Int): Hct { 157 | return Hct(argb) 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/score/Score.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.score 17 | 18 | import com.t8rin.dynamic.theme.hct.Cam16 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.lstarFromArgb 20 | import com.t8rin.dynamic.theme.utils.MathUtils.differenceDegrees 21 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesInt 22 | import java.util.* 23 | import kotlin.math.roundToInt 24 | 25 | /** 26 | * Given a large set of colors, remove colors that are unsuitable for a UI theme, and rank the rest 27 | * based on suitability. 28 | * 29 | * 30 | * Enables use of a high cluster count for image quantization, thus ensuring colors aren't 31 | * muddied, while curating the high cluster count to a much smaller number of appropriate choices. 32 | */ 33 | object Score { 34 | private const val CUTOFF_CHROMA = 15.0 35 | private const val CUTOFF_EXCITED_PROPORTION = 0.01 36 | private const val CUTOFF_TONE = 10.0 37 | private const val TARGET_CHROMA = 48.0 38 | private const val WEIGHT_PROPORTION = 0.7 39 | private const val WEIGHT_CHROMA_ABOVE = 0.3 40 | private const val WEIGHT_CHROMA_BELOW = 0.1 41 | 42 | /** 43 | * Given a map with keys of colors and values of how often the color appears, rank the colors 44 | * based on suitability for being used for a UI theme. 45 | * 46 | * @param colorsToPopulation map with keys of colors and values of how often the color appears, 47 | * usually from a source image. 48 | * @return Colors sorted by suitability for a UI theme. The most suitable color is the first item, 49 | * the least suitable is the last. There will always be at least one color returned. If all 50 | * the input colors were not suitable for a theme, a default fallback color will be provided, 51 | * Google Blue. 52 | */ 53 | fun score(colorsToPopulation: Map): List { 54 | // Determine the total count of all colors. 55 | var populationSum = 0.0 56 | for ((_, value) in colorsToPopulation) { 57 | populationSum += value.toDouble() 58 | } 59 | 60 | // Turn the count of each color into a proportion by dividing by the total 61 | // count. Also, fill a cache of CAM16 colors representing each color, and 62 | // record the proportion of colors for each CAM16 hue. 63 | val colorsToCam: MutableMap = HashMap() 64 | val hueProportions = DoubleArray(361) 65 | for ((color, value) in colorsToPopulation) { 66 | val population = value.toDouble() 67 | val proportion = population / populationSum 68 | val cam = Cam16.fromInt(color) 69 | colorsToCam[color] = cam 70 | val hue = cam.hue.roundToInt().toInt() 71 | hueProportions[hue] += proportion 72 | } 73 | 74 | // Determine the proportion of the colors around each color, by summing the 75 | // proportions around each color's hue. 76 | val colorsToExcitedProportion: MutableMap = HashMap() 77 | for ((color, cam) in colorsToCam) { 78 | val hue = cam.hue.roundToInt().toInt() 79 | var excitedProportion = 0.0 80 | for (j in hue - 15 until hue + 15) { 81 | val neighborHue = sanitizeDegreesInt(j) 82 | excitedProportion += hueProportions[neighborHue] 83 | } 84 | colorsToExcitedProportion[color] = excitedProportion 85 | } 86 | 87 | // Score the colors by their proportion, as well as how chromatic they are. 88 | val colorsToScore: MutableMap = HashMap() 89 | for ((color, cam) in colorsToCam) { 90 | val proportion = colorsToExcitedProportion[color]!! 91 | val proportionScore = proportion * 100.0 * WEIGHT_PROPORTION 92 | val chromaWeight = 93 | if (cam.chroma < TARGET_CHROMA) WEIGHT_CHROMA_BELOW else WEIGHT_CHROMA_ABOVE 94 | val chromaScore = (cam.chroma - TARGET_CHROMA) * chromaWeight 95 | val score = proportionScore + chromaScore 96 | colorsToScore[color] = score 97 | } 98 | 99 | // Remove colors that are unsuitable, ex. very dark or unchromatic colors. 100 | // Also, remove colors that are very similar in hue. 101 | val filteredColors = filter(colorsToExcitedProportion, colorsToCam) 102 | val filteredColorsToScore: MutableMap = HashMap() 103 | for (color in filteredColors) { 104 | filteredColorsToScore[color] = colorsToScore[color] ?: 0.0 105 | } 106 | 107 | // Ensure the list of colors returned is sorted such that the first in the 108 | // list is the most suitable, and the last is the least suitable. 109 | val entryList: List> = 110 | ArrayList>(filteredColorsToScore.entries) 111 | Collections.sort(entryList, ScoredComparator()) 112 | val colorsByScoreDescending: MutableList = ArrayList() 113 | for ((color) in entryList) { 114 | val cam = colorsToCam[color] 115 | var duplicateHue = false 116 | for (alreadyChosenColor in colorsByScoreDescending) { 117 | val alreadyChosenCam = colorsToCam[alreadyChosenColor] 118 | if (differenceDegrees(cam!!.hue, alreadyChosenCam!!.hue) < 15) { 119 | duplicateHue = true 120 | break 121 | } 122 | } 123 | if (duplicateHue) { 124 | continue 125 | } 126 | colorsByScoreDescending.add(color) 127 | } 128 | 129 | // Ensure that at least one color is returned. 130 | if (colorsByScoreDescending.isEmpty()) { 131 | colorsByScoreDescending.add(-0xbd7a0c) // Google Blue 132 | } 133 | return colorsByScoreDescending 134 | } 135 | 136 | private fun filter( 137 | colorsToExcitedProportion: Map, colorsToCam: Map 138 | ): List { 139 | val filtered: MutableList = ArrayList() 140 | for ((color, cam) in colorsToCam) { 141 | val proportion = colorsToExcitedProportion[color]!! 142 | if (cam.chroma >= CUTOFF_CHROMA && lstarFromArgb(color) >= CUTOFF_TONE && proportion >= CUTOFF_EXCITED_PROPORTION) { 143 | filtered.add(color) 144 | } 145 | } 146 | return filtered 147 | } 148 | 149 | internal class ScoredComparator : Comparator> { 150 | override fun compare( 151 | entry1: Map.Entry, 152 | entry2: Map.Entry 153 | ): Int { 154 | return -entry1.value.compareTo(entry2.value) 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/hct/ViewingConditions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.hct 17 | 18 | import com.t8rin.dynamic.theme.utils.ColorUtils.whitePointD65 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.yFromLstar 20 | import com.t8rin.dynamic.theme.utils.MathUtils.clampDouble 21 | import com.t8rin.dynamic.theme.utils.MathUtils.lerp 22 | 23 | /** 24 | * In traditional color spaces, a color can be identified solely by the observer's measurement of 25 | * the color. Color appearance models such as CAM16 also use information about the environment where 26 | * the color was observed, known as the viewing conditions. 27 | * 28 | * 29 | * For example, white under the traditional assumption of a midday sun white point is accurately 30 | * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) 31 | * 32 | * 33 | * This class caches intermediate values of the CAM16 conversion process that depend only on 34 | * viewing conditions, enabling speed ups. 35 | */ 36 | class ViewingConditions 37 | /** 38 | * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand 39 | * for technical color science terminology, this class would not benefit from documenting them 40 | * individually. A brief overview is available in the CAM16 specification, and a complete overview 41 | * requires a color science textbook, such as Fairchild's Color Appearance Models. 42 | */ private constructor( 43 | val n: Double, 44 | val aw: Double, 45 | val nbb: Double, 46 | val ncb: Double, 47 | val c: Double, 48 | val nc: Double, 49 | val rgbD: DoubleArray, 50 | val fl: Double, 51 | val flRoot: Double, 52 | val z: Double 53 | ) { 54 | 55 | companion object { 56 | /** 57 | * sRGB-like viewing conditions. 58 | */ 59 | @JvmField 60 | val DEFAULT = defaultWithBackgroundLstar(50.0) 61 | 62 | /** 63 | * Create ViewingConditions from a simple, physically relevant, set of parameters. 64 | * 65 | * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day 66 | * afternoon 67 | * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in 68 | * the room where the color is viewed. Can be calculated from lux by multiplying lux by 69 | * 0.0586. default = 11.72, or 200 lux. 70 | * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in 71 | * L*a*b*. default = 50.0 72 | * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, 73 | * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at 74 | * night. 2.0 means there is no difference between the lighting on the color and around it. 75 | * default = 2.0 76 | * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, 77 | * such as knowing an apple is still red in green light. default = false, the eye does not 78 | * perform this process on self-luminous objects like displays. 79 | */ 80 | fun make( 81 | whitePoint: DoubleArray, 82 | adaptingLuminance: Double, 83 | backgroundLstar: Double, 84 | surround: Double, 85 | discountingIlluminant: Boolean 86 | ): ViewingConditions { 87 | // A background of pure black is non-physical and leads to infinities that represent the idea 88 | // that any color viewed in pure black can't be seen. 89 | var backgroundLstar = backgroundLstar 90 | backgroundLstar = Math.max(0.1, backgroundLstar) 91 | // Transform white point XYZ to 'cone'/'rgb' responses 92 | val matrix = Cam16.XYZ_TO_CAM16RGB 93 | val rW = 94 | whitePoint[0] * matrix[0][0] + whitePoint[1] * matrix[0][1] + whitePoint[2] * matrix[0][2] 95 | val gW = 96 | whitePoint[0] * matrix[1][0] + whitePoint[1] * matrix[1][1] + whitePoint[2] * matrix[1][2] 97 | val bW = 98 | whitePoint[0] * matrix[2][0] + whitePoint[1] * matrix[2][1] + whitePoint[2] * matrix[2][2] 99 | val f = 0.8 + surround / 10.0 100 | val c = if (f >= 0.9) lerp(0.59, 0.69, (f - 0.9) * 10.0) else lerp( 101 | 0.525, 102 | 0.59, 103 | (f - 0.8) * 10.0 104 | ) 105 | var d = 106 | if (discountingIlluminant) 1.0 else f * (1.0 - 1.0 / 3.6 * Math.exp((-adaptingLuminance - 42.0) / 92.0)) 107 | d = clampDouble(0.0, 1.0, d) 108 | val rgbD = doubleArrayOf( 109 | d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d 110 | ) 111 | val k = 1.0 / (5.0 * adaptingLuminance + 1.0) 112 | val k4 = k * k * k * k 113 | val k4F = 1.0 - k4 114 | val fl = k4 * adaptingLuminance + 0.1 * k4F * k4F * Math.cbrt(5.0 * adaptingLuminance) 115 | val n = yFromLstar(backgroundLstar) / whitePoint[1] 116 | val z = 1.48 + Math.sqrt(n) 117 | val nbb = 0.725 / Math.pow(n, 0.2) 118 | val rgbAFactors = doubleArrayOf( 119 | Math.pow(fl * rgbD[0] * rW / 100.0, 0.42), 120 | Math.pow(fl * rgbD[1] * gW / 100.0, 0.42), 121 | Math.pow(fl * rgbD[2] * bW / 100.0, 0.42) 122 | ) 123 | val rgbA = doubleArrayOf( 124 | 400.0 * rgbAFactors[0] / (rgbAFactors[0] + 27.13), 125 | 400.0 * rgbAFactors[1] / (rgbAFactors[1] + 27.13), 126 | 400.0 * rgbAFactors[2] / (rgbAFactors[2] + 27.13) 127 | ) 128 | val aw = (2.0 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb 129 | return ViewingConditions( 130 | n, 131 | aw, 132 | nbb, 133 | nbb, 134 | c, 135 | f, 136 | rgbD, 137 | fl, 138 | Math.pow(fl, 0.25), 139 | z 140 | ) 141 | } 142 | 143 | /** 144 | * Create sRGB-like viewing conditions with a custom background lstar. 145 | * 146 | * 147 | * Default viewing conditions have a lstar of 50, midgray. 148 | */ 149 | fun defaultWithBackgroundLstar(lstar: Double): ViewingConditions { 150 | return make( 151 | whitePointD65(), 152 | 200.0 / Math.PI * yFromLstar(50.0) / 100f, 153 | lstar, 154 | 2.0, 155 | false 156 | ) 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/QuantizerWsmeans.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | import java.util.* 19 | import kotlin.math.abs 20 | import kotlin.math.sqrt 21 | 22 | /** 23 | * An image quantizer that improves on the speed of a standard K-Means algorithm by implementing 24 | * several optimizations, including deduping identical pixels and a triangle inequality rule that 25 | * reduces the number of comparisons needed to identify which cluster a point should be moved to. 26 | * 27 | * 28 | * Wsmeans stands for Weighted Square Means. 29 | * 30 | * 31 | * This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving 32 | * the Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 33 | */ 34 | object QuantizerWsmeans { 35 | private const val MAX_ITERATIONS = 10 36 | private const val MIN_MOVEMENT_DISTANCE = 3.0 37 | 38 | /** 39 | * Reduce the number of colors needed to represented the input, minimizing the difference between 40 | * the original image and the recolored image. 41 | * 42 | * @param inputPixels Colors in ARGB format. 43 | * @param startingClusters Defines the initial state of the quantizer. Passing an empty array is 44 | * fine, the implementation will create its own initial state that leads to reproducible 45 | * results for the same inputs. Passing an array that is the result of Wu quantization leads 46 | * to higher quality results. 47 | * @param maxColors The number of colors to divide the image into. A lower number of colors may be 48 | * returned. 49 | * @return Map with keys of colors in ARGB format, values of how many of the input pixels belong 50 | * to the color. 51 | */ 52 | fun quantize( 53 | inputPixels: IntArray, startingClusters: IntArray, maxColors: Int 54 | ): Map { 55 | // Uses a seeded random number generator to ensure consistent results. 56 | val random = Random(0x42688) 57 | val pixelToCount: MutableMap = LinkedHashMap() 58 | val points = arrayOfNulls(inputPixels.size) 59 | val pixels = IntArray(inputPixels.size) 60 | val pointProvider: PointProvider = PointProviderLab() 61 | var pointCount = 0 62 | for (i in inputPixels.indices) { 63 | val inputPixel = inputPixels[i] 64 | val pixelCount = pixelToCount[inputPixel] 65 | if (pixelCount == null) { 66 | points[pointCount] = pointProvider.fromInt(inputPixel) 67 | pixels[pointCount] = inputPixel 68 | pointCount++ 69 | pixelToCount[inputPixel] = 1 70 | } else { 71 | pixelToCount[inputPixel] = pixelCount + 1 72 | } 73 | } 74 | val counts = IntArray(pointCount) 75 | for (i in 0 until pointCount) { 76 | val pixel = pixels[i] 77 | val count = pixelToCount[pixel]!! 78 | counts[i] = count 79 | } 80 | var clusterCount = Math.min(maxColors, pointCount) 81 | if (startingClusters.size != 0) { 82 | clusterCount = Math.min(clusterCount, startingClusters.size) 83 | } 84 | val clusters = arrayOfNulls(clusterCount) 85 | var clustersCreated = 0 86 | for (i in startingClusters.indices) { 87 | clusters[i] = pointProvider.fromInt(startingClusters[i]) 88 | clustersCreated++ 89 | } 90 | val additionalClustersNeeded = clusterCount - clustersCreated 91 | if (additionalClustersNeeded > 0) { 92 | for (i in 0 until additionalClustersNeeded) { 93 | } 94 | } 95 | val clusterIndices = IntArray(pointCount) 96 | for (i in 0 until pointCount) { 97 | clusterIndices[i] = random.nextInt(clusterCount) 98 | } 99 | val indexMatrix = arrayOfNulls(clusterCount) 100 | for (i in 0 until clusterCount) { 101 | indexMatrix[i] = IntArray(clusterCount) 102 | } 103 | val distanceToIndexMatrix: Array?> = arrayOfNulls(clusterCount) 104 | for (i in 0 until clusterCount) { 105 | distanceToIndexMatrix[i] = arrayOfNulls(clusterCount) 106 | for (j in 0 until clusterCount) { 107 | distanceToIndexMatrix[i]!![j] = Distance() 108 | } 109 | } 110 | val pixelCountSums = IntArray(clusterCount) 111 | for (iteration in 0 until MAX_ITERATIONS) { 112 | for (i in 0 until clusterCount) { 113 | for (j in i + 1 until clusterCount) { 114 | val distance = pointProvider.distance(clusters[i], clusters[j]) 115 | distanceToIndexMatrix[j]!![i]!!.distance = distance 116 | distanceToIndexMatrix[j]!![i]!!.index = i 117 | distanceToIndexMatrix[i]!![j]!!.distance = distance 118 | distanceToIndexMatrix[i]!![j]!!.index = j 119 | } 120 | Arrays.sort(distanceToIndexMatrix[i]!!) 121 | for (j in 0 until clusterCount) { 122 | indexMatrix[i]!![j] = distanceToIndexMatrix[i]!![j]!!.index 123 | } 124 | } 125 | var pointsMoved = 0 126 | for (i in 0 until pointCount) { 127 | val point = points[i] 128 | val previousClusterIndex = clusterIndices[i] 129 | val previousCluster = clusters[previousClusterIndex] 130 | val previousDistance = pointProvider.distance(point, previousCluster) 131 | var minimumDistance = previousDistance 132 | var newClusterIndex = -1 133 | for (j in 0 until clusterCount) { 134 | if (distanceToIndexMatrix[previousClusterIndex]!![j]!!.distance >= 4 * previousDistance) { 135 | continue 136 | } 137 | val distance = pointProvider.distance(point, clusters[j]) 138 | if (distance < minimumDistance) { 139 | minimumDistance = distance 140 | newClusterIndex = j 141 | } 142 | } 143 | if (newClusterIndex != -1) { 144 | val distanceChange = 145 | abs(sqrt(minimumDistance) - sqrt(previousDistance)) 146 | if (distanceChange > MIN_MOVEMENT_DISTANCE) { 147 | pointsMoved++ 148 | clusterIndices[i] = newClusterIndex 149 | } 150 | } 151 | } 152 | if (pointsMoved == 0 && iteration != 0) { 153 | break 154 | } 155 | val componentASums = DoubleArray(clusterCount) 156 | val componentBSums = DoubleArray(clusterCount) 157 | val componentCSums = DoubleArray(clusterCount) 158 | Arrays.fill(pixelCountSums, 0) 159 | for (i in 0 until pointCount) { 160 | val clusterIndex = clusterIndices[i] 161 | val point = points[i] 162 | val count = counts[i] 163 | pixelCountSums[clusterIndex] += count 164 | componentASums[clusterIndex] += point!![0] * count 165 | componentBSums[clusterIndex] += point!![1] * count 166 | componentCSums[clusterIndex] += point!![2] * count 167 | } 168 | for (i in 0 until clusterCount) { 169 | val count = pixelCountSums[i] 170 | if (count == 0) { 171 | clusters[i] = doubleArrayOf(0.0, 0.0, 0.0) 172 | continue 173 | } 174 | val a = componentASums[i] / count 175 | val b = componentBSums[i] / count 176 | val c = componentCSums[i] / count 177 | clusters[i]!![0] = a 178 | clusters[i]!![1] = b 179 | clusters[i]!![2] = c 180 | } 181 | } 182 | val argbToPopulation: MutableMap = LinkedHashMap() 183 | for (i in 0 until clusterCount) { 184 | val count = pixelCountSums[i] 185 | if (count == 0) { 186 | continue 187 | } 188 | val possibleNewCluster = pointProvider.toInt(clusters[i]) 189 | if (argbToPopulation.containsKey(possibleNewCluster)) { 190 | continue 191 | } 192 | argbToPopulation[possibleNewCluster] = count 193 | } 194 | return argbToPopulation 195 | } 196 | 197 | private class Distance internal constructor() : Comparable { 198 | var index: Int 199 | var distance: Double 200 | 201 | init { 202 | index = -1 203 | distance = -1.0 204 | } 205 | 206 | override fun compareTo(other: Distance): Int { 207 | return distance.compareTo(other.distance) 208 | } 209 | } 210 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/contrast/Contrast.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.contrast 17 | 18 | import com.t8rin.dynamic.theme.utils.ColorUtils.lstarFromY 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.yFromLstar 20 | 21 | /** 22 | * Color science for contrast utilities. 23 | * 24 | * 25 | * Utility methods for calculating contrast given two colors, or calculating a color given one 26 | * color and a contrast ratio. 27 | * 28 | * 29 | * Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y 30 | * becomes HCT's tone and L*a*b*'s' L*. 31 | */ 32 | object Contrast { 33 | // The minimum contrast ratio of two colors. 34 | // Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1. 35 | const val RATIO_MIN = 1.0 36 | 37 | // The maximum contrast ratio of two colors. 38 | // Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100. 39 | // If lighter == 100, darker = 0, ratio == 21. 40 | const val RATIO_MAX = 21.0 41 | const val RATIO_30 = 3.0 42 | const val RATIO_45 = 4.5 43 | const val RATIO_70 = 7.0 44 | 45 | // Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio 46 | // with the color can be calculated. However, that luminance may not contrast as desired, i.e. the 47 | // contrast ratio of the input color and the returned luminance may not reach the contrast ratio 48 | // asked for. 49 | // 50 | // When the desired contrast ratio and the result contrast ratio differ by more than this amount, 51 | // an error value should be returned, or the method should be documented as 'unsafe', meaning, 52 | // it will return a valid luminance but that luminance may not meet the requested contrast ratio. 53 | // 54 | // 0.04 selected because it ensures the resulting ratio rounds to the same tenth. 55 | private const val CONTRAST_RATIO_EPSILON = 0.04 56 | 57 | // Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as 58 | // perceptual accurate color spaces. 59 | // 60 | // To be displayed, they must gamut map to a "display space", one that has a defined limit on the 61 | // number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB. 62 | // Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must 63 | // choose how to sacrifice accuracy in hue, saturation, and/or lightness. 64 | // 65 | // A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue, 66 | // thus maintaining aesthetic intent, and reduce chroma until the color is in gamut. 67 | // 68 | // HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness, 69 | // if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB 70 | // color with, for example, 47.892 lightness, but not 47.891. 71 | // 72 | // To allow for this inherent incompatibility between perceptually accurate color spaces and 73 | // display color spaces, methods that take a contrast ratio and luminance, and return a luminance 74 | // that reaches that contrast ratio for the input luminance, purposefully darken/lighten their 75 | // result such that the desired contrast ratio will be reached even if inaccuracy is introduced. 76 | // 77 | // 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough 78 | // guarantee that as long as a percetual color space gamut maps lightness such that the resulting 79 | // lightness rounds to the same as the requested, the desired contrast ratio will be reached. 80 | private const val LUMINANCE_GAMUT_MAP_TOLERANCE = 0.4 81 | 82 | /** 83 | * Contrast ratio is a measure of legibility, its used to compare the lightness of two colors. 84 | * This method is used commonly in industry due to its use by WCAG. 85 | * 86 | * 87 | * To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness, 88 | * also known as relative luminance. 89 | * 90 | * 91 | * The equation is ratio = lighter Y + 5 / darker Y + 5. 92 | */ 93 | fun ratioOfYs(y1: Double, y2: Double): Double { 94 | val lighter = Math.max(y1, y2) 95 | val darker = if (lighter == y2) y1 else y2 96 | return (lighter + 5.0) / (darker + 5.0) 97 | } 98 | 99 | /** 100 | * Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual 101 | * luminance. 102 | * 103 | * 104 | * Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is 105 | * linear to number of photons, not to perception of lightness. Perceptual luminance, L* in 106 | * L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're 107 | * accurate to the eye. 108 | * 109 | * 110 | * Y and L* are pure functions of each other, so it possible to use perceptually accurate color 111 | * spaces, and measure contrast, and measure contrast in a much more understandable way: instead 112 | * of a ratio, a linear difference. This allows a designer to determine what they need to adjust a 113 | * color's lightness to in order to reach their desired contrast, instead of guessing & checking 114 | * with hex codes. 115 | */ 116 | @JvmStatic 117 | fun ratioOfTones(t1: Double, t2: Double): Double { 118 | return ratioOfYs(yFromLstar(t1), yFromLstar(t2)) 119 | } 120 | 121 | /** 122 | * Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns -1 123 | * if ratio cannot be achieved. 124 | * 125 | * @param tone Tone return value must contrast with. 126 | * @param ratio Desired contrast ratio of return value and tone parameter. 127 | */ 128 | fun lighter(tone: Double, ratio: Double): Double { 129 | if (tone < 0.0 || tone > 100.0) { 130 | return -1.0 131 | } 132 | // Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y. 133 | val darkY = yFromLstar(tone) 134 | val lightY = ratio * (darkY + 5.0) - 5.0 135 | if (lightY < 0.0 || lightY > 100.0) { 136 | return -1.0 137 | } 138 | val realContrast = ratioOfYs(lightY, darkY) 139 | val delta = Math.abs(realContrast - ratio) 140 | if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { 141 | return -1.0 142 | } 143 | val returnValue = lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE 144 | // NOMUTANTS--important validation step; functions it is calling may change implementation. 145 | return if (returnValue < 0 || returnValue > 100) { 146 | -1.0 147 | } else returnValue 148 | } 149 | 150 | /** 151 | * Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved. 152 | * 153 | * 154 | * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in 155 | * bounds return value may not reach the desired ratio. 156 | * 157 | * @param tone Tone return value must contrast with. 158 | * @param ratio Desired contrast ratio of return value and tone parameter. 159 | */ 160 | @JvmStatic 161 | fun lighterUnsafe(tone: Double, ratio: Double): Double { 162 | val lighterSafe = lighter(tone, ratio) 163 | return if (lighterSafe < 0.0) 100.0 else lighterSafe 164 | } 165 | 166 | /** 167 | * Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns -1 168 | * if ratio cannot be achieved. 169 | * 170 | * @param tone Tone return value must contrast with. 171 | * @param ratio Desired contrast ratio of return value and tone parameter. 172 | */ 173 | fun darker(tone: Double, ratio: Double): Double { 174 | if (tone < 0.0 || tone > 100.0) { 175 | return -1.0 176 | } 177 | // Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y. 178 | val lightY = yFromLstar(tone) 179 | val darkY = (lightY + 5.0) / ratio - 5.0 180 | if (darkY < 0.0 || darkY > 100.0) { 181 | return -1.0 182 | } 183 | val realContrast = ratioOfYs(lightY, darkY) 184 | val delta = Math.abs(realContrast - ratio) 185 | if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { 186 | return -1.0 187 | } 188 | 189 | // For information on 0.4 constant, see comment in lighter(tone, ratio). 190 | val returnValue = lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE 191 | // NOMUTANTS--important validation step; functions it is calling may change implementation. 192 | return if (returnValue < 0 || returnValue > 100) { 193 | -1.0 194 | } else returnValue 195 | } 196 | 197 | /** 198 | * Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved. 199 | * 200 | * 201 | * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in 202 | * bounds return value may not reach the desired ratio. 203 | * 204 | * @param tone Tone return value must contrast with. 205 | * @param ratio Desired contrast ratio of return value and tone parameter. 206 | */ 207 | @JvmStatic 208 | fun darkerUnsafe(tone: Double, ratio: Double): Double { 209 | val darkerSafe = darker(tone, ratio) 210 | return Math.max(0.0, darkerSafe) 211 | } 212 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/utils/ColorUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // This file is automatically generated. Do not modify it. 17 | package com.t8rin.dynamic.theme.utils 18 | 19 | /** 20 | * Color science utilities. 21 | * 22 | * 23 | * Utility methods for color science constants and color space conversions that aren't HCT or 24 | * CAM16. 25 | */ 26 | object ColorUtils { 27 | val SRGB_TO_XYZ = arrayOf( 28 | doubleArrayOf(0.41233895, 0.35762064, 0.18051042), 29 | doubleArrayOf(0.2126, 0.7152, 0.0722), 30 | doubleArrayOf(0.01932141, 0.11916382, 0.95034478) 31 | ) 32 | val XYZ_TO_SRGB = arrayOf( 33 | doubleArrayOf( 34 | 3.2413774792388685, -1.5376652402851851, -0.49885366846268053 35 | ), doubleArrayOf( 36 | -0.9691452513005321, 1.8758853451067872, 0.04156585616912061 37 | ), doubleArrayOf( 38 | 0.05562093689691305, -0.20395524564742123, 1.0571799111220335 39 | ) 40 | ) 41 | val WHITE_POINT_D65 = doubleArrayOf(95.047, 100.0, 108.883) 42 | 43 | /** 44 | * Converts a color from RGB components to ARGB format. 45 | */ 46 | fun argbFromRgb(red: Int, green: Int, blue: Int): Int { 47 | return 255 shl 24 or (red and 255 shl 16) or (green and 255 shl 8) or (blue and 255) 48 | } 49 | 50 | /** 51 | * Converts a color from linear RGB components to ARGB format. 52 | */ 53 | @JvmStatic 54 | fun argbFromLinrgb(linrgb: DoubleArray): Int { 55 | val r = delinearized(linrgb[0]) 56 | val g = delinearized(linrgb[1]) 57 | val b = delinearized(linrgb[2]) 58 | return argbFromRgb(r, g, b) 59 | } 60 | 61 | /** 62 | * Returns the alpha component of a color in ARGB format. 63 | */ 64 | fun alphaFromArgb(argb: Int): Int { 65 | return argb shr 24 and 255 66 | } 67 | 68 | /** 69 | * Returns the red component of a color in ARGB format. 70 | */ 71 | @JvmStatic 72 | fun redFromArgb(argb: Int): Int { 73 | return argb shr 16 and 255 74 | } 75 | 76 | /** 77 | * Returns the green component of a color in ARGB format. 78 | */ 79 | @JvmStatic 80 | fun greenFromArgb(argb: Int): Int { 81 | return argb shr 8 and 255 82 | } 83 | 84 | /** 85 | * Returns the blue component of a color in ARGB format. 86 | */ 87 | @JvmStatic 88 | fun blueFromArgb(argb: Int): Int { 89 | return argb and 255 90 | } 91 | 92 | /** 93 | * Returns whether a color in ARGB format is opaque. 94 | */ 95 | fun isOpaque(argb: Int): Boolean { 96 | return alphaFromArgb(argb) >= 255 97 | } 98 | 99 | /** 100 | * Converts a color from ARGB to XYZ. 101 | */ 102 | @JvmStatic 103 | fun argbFromXyz(x: Double, y: Double, z: Double): Int { 104 | val matrix = XYZ_TO_SRGB 105 | val linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z 106 | val linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z 107 | val linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z 108 | val r = delinearized(linearR) 109 | val g = delinearized(linearG) 110 | val b = delinearized(linearB) 111 | return argbFromRgb(r, g, b) 112 | } 113 | 114 | /** 115 | * Converts a color from XYZ to ARGB. 116 | */ 117 | fun xyzFromArgb(argb: Int): DoubleArray? { 118 | val r = linearized(redFromArgb(argb)) 119 | val g = linearized(greenFromArgb(argb)) 120 | val b = linearized(blueFromArgb(argb)) 121 | return MathUtils.matrixMultiply(doubleArrayOf(r, g, b), SRGB_TO_XYZ) 122 | } 123 | 124 | /** 125 | * Converts a color represented in Lab color space into an ARGB integer. 126 | */ 127 | @JvmStatic 128 | fun argbFromLab(l: Double, a: Double, b: Double): Int { 129 | val whitePoint = WHITE_POINT_D65 130 | val fy = (l + 16.0) / 116.0 131 | val fx = a / 500.0 + fy 132 | val fz = fy - b / 200.0 133 | val xNormalized = labInvf(fx) 134 | val yNormalized = labInvf(fy) 135 | val zNormalized = labInvf(fz) 136 | val x = xNormalized * whitePoint[0] 137 | val y = yNormalized * whitePoint[1] 138 | val z = zNormalized * whitePoint[2] 139 | return argbFromXyz(x, y, z) 140 | } 141 | 142 | /** 143 | * Converts a color from ARGB representation to L*a*b* representation. 144 | * 145 | * @param argb the ARGB representation of a color 146 | * @return a Lab object representing the color 147 | */ 148 | @JvmStatic 149 | fun labFromArgb(argb: Int): DoubleArray { 150 | val linearR = linearized(redFromArgb(argb)) 151 | val linearG = linearized(greenFromArgb(argb)) 152 | val linearB = linearized(blueFromArgb(argb)) 153 | val matrix = SRGB_TO_XYZ 154 | val x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB 155 | val y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB 156 | val z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB 157 | val whitePoint = WHITE_POINT_D65 158 | val xNormalized = x / whitePoint[0] 159 | val yNormalized = y / whitePoint[1] 160 | val zNormalized = z / whitePoint[2] 161 | val fx = labF(xNormalized) 162 | val fy = labF(yNormalized) 163 | val fz = labF(zNormalized) 164 | val l = 116.0 * fy - 16 165 | val a = 500.0 * (fx - fy) 166 | val b = 200.0 * (fy - fz) 167 | return doubleArrayOf(l, a, b) 168 | } 169 | 170 | /** 171 | * Converts an L* value to an ARGB representation. 172 | * 173 | * @param lstar L* in L*a*b* 174 | * @return ARGB representation of grayscale color with lightness matching L* 175 | */ 176 | @JvmStatic 177 | fun argbFromLstar(lstar: Double): Int { 178 | val y = yFromLstar(lstar) 179 | val component = delinearized(y) 180 | return argbFromRgb(component, component, component) 181 | } 182 | 183 | /** 184 | * Computes the L* value of a color in ARGB representation. 185 | * 186 | * @param argb ARGB representation of a color 187 | * @return L*, from L*a*b*, coordinate of the color 188 | */ 189 | @JvmStatic 190 | fun lstarFromArgb(argb: Int): Double { 191 | val y = xyzFromArgb(argb)!![1] 192 | return 116.0 * labF(y / 100.0) - 16.0 193 | } 194 | 195 | /** 196 | * Converts an L* value to a Y value. 197 | * 198 | * 199 | * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. 200 | * 201 | * 202 | * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a 203 | * logarithmic scale. 204 | * 205 | * @param lstar L* in L*a*b* 206 | * @return Y in XYZ 207 | */ 208 | @JvmStatic 209 | fun yFromLstar(lstar: Double): Double { 210 | return 100.0 * labInvf((lstar + 16.0) / 116.0) 211 | } 212 | 213 | /** 214 | * Converts a Y value to an L* value. 215 | * 216 | * 217 | * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. 218 | * 219 | * 220 | * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a 221 | * logarithmic scale. 222 | * 223 | * @param y Y in XYZ 224 | * @return L* in L*a*b* 225 | */ 226 | @JvmStatic 227 | fun lstarFromY(y: Double): Double { 228 | return labF(y / 100.0) * 116.0 - 16.0 229 | } 230 | 231 | /** 232 | * Linearizes an RGB component. 233 | * 234 | * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel 235 | * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space 236 | */ 237 | @JvmStatic 238 | fun linearized(rgbComponent: Int): Double { 239 | val normalized = rgbComponent / 255.0 240 | return if (normalized <= 0.040449936) { 241 | normalized / 12.92 * 100.0 242 | } else { 243 | Math.pow((normalized + 0.055) / 1.055, 2.4) * 100.0 244 | } 245 | } 246 | 247 | /** 248 | * Delinearizes an RGB component. 249 | * 250 | * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel 251 | * @return 0 <= output <= 255, color channel converted to regular RGB space 252 | */ 253 | fun delinearized(rgbComponent: Double): Int { 254 | val normalized = rgbComponent / 100.0 255 | var delinearized = 0.0 256 | delinearized = if (normalized <= 0.0031308) { 257 | normalized * 12.92 258 | } else { 259 | 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055 260 | } 261 | return MathUtils.clampInt(0, 255, Math.round(delinearized * 255.0).toInt()) 262 | } 263 | 264 | /** 265 | * Returns the standard white point; white on a sunny day. 266 | * 267 | * @return The white point 268 | */ 269 | @JvmStatic 270 | fun whitePointD65(): DoubleArray { 271 | return WHITE_POINT_D65 272 | } 273 | 274 | fun labF(t: Double): Double { 275 | val e = 216.0 / 24389.0 276 | val kappa = 24389.0 / 27.0 277 | return if (t > e) { 278 | Math.pow(t, 1.0 / 3.0) 279 | } else { 280 | (kappa * t + 16) / 116 281 | } 282 | } 283 | 284 | fun labInvf(ft: Double): Double { 285 | val e = 216.0 / 24389.0 286 | val kappa = 24389.0 / 27.0 287 | val ft3 = ft * ft * ft 288 | return if (ft3 > e) { 289 | ft3 290 | } else { 291 | (116 * ft - 16) / kappa 292 | } 293 | } 294 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/temperature/TemperatureCache.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.temperature 17 | 18 | import com.t8rin.dynamic.theme.hct.Hct 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.labFromArgb 20 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesDouble 21 | import com.t8rin.dynamic.theme.utils.MathUtils.sanitizeDegreesInt 22 | import java.util.* 23 | import kotlin.math.* 24 | 25 | /** 26 | * Design utilities using color temperature theory. 27 | * 28 | * 29 | * Analogous colors, complementary color, and cache to efficiently, lazily, generate data for 30 | * calculations when needed. 31 | */ 32 | class TemperatureCache { 33 | private lateinit var input: Hct 34 | private var precomputedComplement: Hct? = null 35 | private var precomputedHctsByTemp: List? = null 36 | private var precomputedHctsByHue: List? = null 37 | private var precomputedTempsByHct: Map? = null 38 | 39 | private constructor() { 40 | throw UnsupportedOperationException() 41 | } 42 | 43 | /** 44 | * Create a cache that allows calculation of ex. complementary and analogous colors. 45 | * 46 | * @param input Color to find complement/analogous colors of. Any colors will have the same tone, 47 | * and chroma as the input color, modulo any restrictions due to the other hues having lower 48 | * limits on chroma. 49 | */ 50 | constructor(input: Hct) { 51 | this.input = input 52 | }// Find the color in the other section, closest to the inverse percentile 53 | // of the input color. This is the complement. 54 | /** 55 | * A color that complements the input color aesthetically. 56 | * 57 | * 58 | * In art, this is usually described as being across the color wheel. History of this shows 59 | * intent as a color that is just as cool-warm as the input color is warm-cool. 60 | */ 61 | val complement: Hct? 62 | get() { 63 | if (precomputedComplement != null) { 64 | return precomputedComplement 65 | } 66 | val coldestHue = coldest.hue 67 | val coldestTemp = tempsByHct!![coldest]!! 68 | val warmestHue = warmest.hue 69 | val warmestTemp = tempsByHct!![warmest]!! 70 | val range = warmestTemp - coldestTemp 71 | val startHueIsColdestToWarmest = isBetween(input.hue, coldestHue, warmestHue) 72 | val startHue = if (startHueIsColdestToWarmest) warmestHue else coldestHue 73 | val endHue = if (startHueIsColdestToWarmest) coldestHue else warmestHue 74 | val directionOfRotation = 1.0 75 | var smallestError = 1000.0 76 | var answer: Hct? = hctsByHue!![Math.round(input.hue).toInt()] 77 | val complementRelativeTemp = 1.0 - getRelativeTemperature(input) 78 | // Find the color in the other section, closest to the inverse percentile 79 | // of the input color. This is the complement. 80 | var hueAddend = 0.0 81 | while (hueAddend <= 360.0) { 82 | val hue = sanitizeDegreesDouble( 83 | startHue + directionOfRotation * hueAddend 84 | ) 85 | if (!isBetween(hue, startHue, endHue)) { 86 | hueAddend += 1.0 87 | continue 88 | } 89 | val possibleAnswer = hctsByHue!![hue.roundToInt()] 90 | val relativeTemp = (tempsByHct!![possibleAnswer]!! - coldestTemp) / range 91 | val error = abs(complementRelativeTemp - relativeTemp) 92 | if (error < smallestError) { 93 | smallestError = error 94 | answer = possibleAnswer 95 | } 96 | hueAddend += 1.0 97 | } 98 | precomputedComplement = answer 99 | return precomputedComplement 100 | } 101 | 102 | /** 103 | * 5 colors that pair well with the input color. 104 | * 105 | * 106 | * The colors are equidistant in temperature and adjacent in hue. 107 | */ 108 | val analogousColors: List 109 | get() = getAnalogousColors(5, 12) 110 | 111 | /** 112 | * A set of colors with differing hues, equidistant in temperature. 113 | * 114 | * 115 | * In art, this is usually described as a set of 5 colors on a color wheel divided into 12 116 | * sections. This method allows provision of either of those values. 117 | * 118 | * 119 | * Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat. 120 | * 121 | * @param count The number of colors to return, includes the input color. 122 | * @param divisions The number of divisions on the color wheel. 123 | */ 124 | fun getAnalogousColors(count: Int, divisions: Int): List { 125 | // The starting hue is the hue of the input color. 126 | val startHue = Math.round(input.hue).toInt() 127 | val startHct = hctsByHue!![startHue] 128 | var lastTemp = getRelativeTemperature(startHct) 129 | val allColors: MutableList = ArrayList() 130 | allColors.add(startHct) 131 | var absoluteTotalTempDelta = 0.0 132 | for (i in 0..359) { 133 | val hue = sanitizeDegreesInt(startHue + i) 134 | val hct = hctsByHue!![hue] 135 | val temp = getRelativeTemperature(hct) 136 | val tempDelta = Math.abs(temp - lastTemp) 137 | lastTemp = temp 138 | absoluteTotalTempDelta += tempDelta 139 | } 140 | var hueAddend = 1 141 | val tempStep = absoluteTotalTempDelta / divisions.toDouble() 142 | var totalTempDelta = 0.0 143 | lastTemp = getRelativeTemperature(startHct) 144 | while (allColors.size < divisions) { 145 | val hue = sanitizeDegreesInt(startHue + hueAddend) 146 | val hct = hctsByHue!![hue] 147 | val temp = getRelativeTemperature(hct) 148 | val tempDelta = Math.abs(temp - lastTemp) 149 | totalTempDelta += tempDelta 150 | var desiredTotalTempDeltaForIndex = allColors.size * tempStep 151 | var indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex 152 | var indexAddend = 1 153 | // Keep adding this hue to the answers until its temperature is 154 | // insufficient. This ensures consistent behavior when there aren't 155 | // `divisions` discrete steps between 0 and 360 in hue with `tempStep` 156 | // delta in temperature between them. 157 | // 158 | // For example, white and black have no analogues: there are no other 159 | // colors at T100/T0. Therefore, they should just be added to the array 160 | // as answers. 161 | while (indexSatisfied && allColors.size < divisions) { 162 | allColors.add(hct) 163 | desiredTotalTempDeltaForIndex = (allColors.size + indexAddend) * tempStep 164 | indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex 165 | indexAddend++ 166 | } 167 | lastTemp = temp 168 | hueAddend++ 169 | if (hueAddend > 360) { 170 | while (allColors.size < divisions) { 171 | allColors.add(hct) 172 | } 173 | break 174 | } 175 | } 176 | val answers: MutableList = ArrayList() 177 | answers.add(input) 178 | val ccwCount = Math.floor((count.toDouble() - 1.0) / 2.0).toInt() 179 | for (i in 1 until ccwCount + 1) { 180 | var index = 0 - i 181 | while (index < 0) { 182 | index += allColors.size 183 | } 184 | if (index >= allColors.size) { 185 | index %= allColors.size 186 | } 187 | answers.add(0, allColors[index]) 188 | } 189 | val cwCount = count - ccwCount - 1 190 | for (i in 1 until cwCount + 1) { 191 | var index = i 192 | while (index < 0) { 193 | index += allColors.size 194 | } 195 | if (index >= allColors.size) { 196 | index %= allColors.size 197 | } 198 | answers.add(allColors[index]) 199 | } 200 | return answers 201 | } 202 | 203 | /** 204 | * Temperature relative to all colors with the same chroma and tone. 205 | * 206 | * @param hct HCT to find the relative temperature of. 207 | * @return Value on a scale from 0 to 1. 208 | */ 209 | fun getRelativeTemperature(hct: Hct): Double { 210 | val range = tempsByHct!![warmest]!! - tempsByHct!![coldest]!! 211 | val differenceFromColdest = tempsByHct!![hct]!! - tempsByHct!![coldest]!! 212 | // Handle when there's no difference in temperature between warmest and 213 | // coldest: for example, at T100, only one color is available, white. 214 | return if (range == 0.0) { 215 | 0.5 216 | } else differenceFromColdest / range 217 | } 218 | 219 | /** 220 | * Coldest color with same chroma and tone as input. 221 | */ 222 | private val coldest: Hct 223 | get() = hctsByTemp!![0] 224 | 225 | /** 226 | * HCTs for all colors with the same chroma/tone as the input. 227 | * 228 | * 229 | * Sorted by hue, ex. index 0 is hue 0. 230 | */ 231 | private val hctsByHue: List? 232 | get() { 233 | if (precomputedHctsByHue != null) { 234 | return precomputedHctsByHue 235 | } 236 | val hcts: MutableList = ArrayList() 237 | var hue = 0.0 238 | while (hue <= 360.0) { 239 | val colorAtHue = Hct.from(hue, input.chroma, input.tone) 240 | hcts.add(colorAtHue) 241 | hue += 1.0 242 | } 243 | precomputedHctsByHue = Collections.unmodifiableList(hcts) 244 | return precomputedHctsByHue 245 | } 246 | 247 | /** 248 | * HCTs for all colors with the same chroma/tone as the input. 249 | * 250 | * 251 | * Sorted from coldest first to warmest last. 252 | */ 253 | // Prevent lint for Comparator not being available on Android before API level 24, 7.0, 2016. 254 | // "AndroidJdkLibsChecker" for one linter, "NewApi" for another. 255 | // A java_library Bazel rule with an Android constraint cannot skip these warnings without this 256 | // annotation; another solution would be to create an android_library rule and supply 257 | // AndroidManifest with an SDK set higher than 23. 258 | private val hctsByTemp: List? 259 | get() { 260 | if (precomputedHctsByTemp != null) { 261 | return precomputedHctsByTemp 262 | } 263 | val hcts: MutableList = ArrayList(hctsByHue) 264 | hcts.add(input) 265 | val temperaturesComparator = Comparator.comparing( 266 | { arg: Hct -> tempsByHct!![arg] }) { obj: Double?, anotherDouble: Double? -> 267 | obj!!.compareTo( 268 | anotherDouble!! 269 | ) 270 | } 271 | Collections.sort(hcts, temperaturesComparator) 272 | precomputedHctsByTemp = hcts 273 | return precomputedHctsByTemp 274 | } 275 | 276 | /** 277 | * Keys of HCTs in getHctsByTemp, values of raw temperature. 278 | */ 279 | private val tempsByHct: Map? 280 | get() { 281 | if (precomputedTempsByHct != null) { 282 | return precomputedTempsByHct 283 | } 284 | val allHcts: MutableList = ArrayList(hctsByHue) 285 | allHcts.add(input) 286 | val temperaturesByHct: MutableMap = HashMap() 287 | for (hct in allHcts) { 288 | temperaturesByHct[hct] = rawTemperature(hct) 289 | } 290 | precomputedTempsByHct = temperaturesByHct 291 | return precomputedTempsByHct 292 | } 293 | 294 | /** 295 | * Warmest color with same chroma and tone as input. 296 | */ 297 | private val warmest: Hct 298 | get() = hctsByTemp!![hctsByTemp!!.size - 1] 299 | 300 | companion object { 301 | /** 302 | * Value representing cool-warm factor of a color. Values below 0 are considered cool, above, 303 | * warm. 304 | * 305 | * 306 | * Color science has researched emotion and harmony, which art uses to select colors. Warm-cool 307 | * is the foundation of analogous and complementary colors. See: - Li-Chen Ou's Chapter 19 in 308 | * Handbook of Color Psychology (2015). - Josef Albers' Interaction of Color chapters 19 and 21. 309 | * 310 | * 311 | * Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space. 312 | * Return value has these properties:

313 | * - Values below 0 are cool, above 0 are warm.

314 | * - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.

315 | * - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130. 316 | */ 317 | fun rawTemperature(color: Hct): Double { 318 | val lab = labFromArgb(color.toInt()) 319 | val hue = sanitizeDegreesDouble( 320 | Math.toDegrees( 321 | Math.atan2( 322 | lab[2], lab[1] 323 | ) 324 | ) 325 | ) 326 | val chroma = hypot(lab[1], lab[2]) 327 | return (-0.5 328 | + (0.02 329 | * chroma.pow(1.07) 330 | * cos(Math.toRadians(sanitizeDegreesDouble(hue - 50.0))))) 331 | } 332 | 333 | /** 334 | * Determines if an angle is between two other angles, rotating clockwise. 335 | */ 336 | private fun isBetween(angle: Double, a: Double, b: Double): Boolean { 337 | return if (a < b) { 338 | angle in a..b 339 | } else a <= angle || angle <= b 340 | } 341 | } 342 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/quantize/QuantizerWu.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.t8rin.dynamic.theme.quantize 17 | 18 | import com.t8rin.dynamic.theme.utils.ColorUtils.blueFromArgb 19 | import com.t8rin.dynamic.theme.utils.ColorUtils.greenFromArgb 20 | import com.t8rin.dynamic.theme.utils.ColorUtils.redFromArgb 21 | 22 | /** 23 | * An image quantizer that divides the image's pixels into clusters by recursively cutting an RGB 24 | * cube, based on the weight of pixels in each area of the cube. 25 | * 26 | * 27 | * The algorithm was described by Xiaolin Wu in Graphic Gems II, published in 1991. 28 | */ 29 | class QuantizerWu : Quantizer { 30 | lateinit var weights: IntArray 31 | lateinit var momentsR: IntArray 32 | lateinit var momentsG: IntArray 33 | lateinit var momentsB: IntArray 34 | lateinit var moments: DoubleArray 35 | lateinit var cubes: Array 36 | override fun quantize(pixels: IntArray, colorCount: Int): QuantizerResult { 37 | val mapResult = QuantizerMap().quantize(pixels, colorCount) 38 | constructHistogram(mapResult!!.colorToCount) 39 | createMoments() 40 | val createBoxesResult = createBoxes(colorCount) 41 | val colors = createResult(createBoxesResult.resultCount) 42 | val resultMap: MutableMap = LinkedHashMap() 43 | for (color in colors) { 44 | resultMap[color] = 0 45 | } 46 | return QuantizerResult(resultMap) 47 | } 48 | 49 | fun constructHistogram(pixels: Map?) { 50 | weights = IntArray(TOTAL_SIZE) 51 | momentsR = IntArray(TOTAL_SIZE) 52 | momentsG = IntArray(TOTAL_SIZE) 53 | momentsB = IntArray(TOTAL_SIZE) 54 | moments = DoubleArray(TOTAL_SIZE) 55 | for ((pixel, count) in pixels!!) { 56 | val red = redFromArgb(pixel) 57 | val green = greenFromArgb(pixel) 58 | val blue = blueFromArgb(pixel) 59 | val bitsToRemove = 8 - INDEX_BITS 60 | val iR = (red shr bitsToRemove) + 1 61 | val iG = (green shr bitsToRemove) + 1 62 | val iB = (blue shr bitsToRemove) + 1 63 | val index = getIndex(iR, iG, iB) 64 | weights[index] += count 65 | momentsR[index] += red * count 66 | momentsG[index] += green * count 67 | momentsB[index] += blue * count 68 | moments[index] += (count * (red * red + green * green + blue * blue)).toDouble() 69 | } 70 | } 71 | 72 | fun createMoments() { 73 | for (r in 1 until INDEX_COUNT) { 74 | val area = IntArray(INDEX_COUNT) 75 | val areaR = IntArray(INDEX_COUNT) 76 | val areaG = IntArray(INDEX_COUNT) 77 | val areaB = IntArray(INDEX_COUNT) 78 | val area2 = DoubleArray(INDEX_COUNT) 79 | for (g in 1 until INDEX_COUNT) { 80 | var line = 0 81 | var lineR = 0 82 | var lineG = 0 83 | var lineB = 0 84 | var line2 = 0.0 85 | for (b in 1 until INDEX_COUNT) { 86 | val index = getIndex(r, g, b) 87 | line += weights[index] 88 | lineR += momentsR[index] 89 | lineG += momentsG[index] 90 | lineB += momentsB[index] 91 | line2 += moments[index] 92 | area[b] += line 93 | areaR[b] += lineR 94 | areaG[b] += lineG 95 | areaB[b] += lineB 96 | area2[b] += line2 97 | val previousIndex = getIndex(r - 1, g, b) 98 | weights[index] = weights[previousIndex] + area[b] 99 | momentsR[index] = momentsR[previousIndex] + areaR[b] 100 | momentsG[index] = momentsG[previousIndex] + areaG[b] 101 | momentsB[index] = momentsB[previousIndex] + areaB[b] 102 | moments[index] = moments[previousIndex] + area2[b] 103 | } 104 | } 105 | } 106 | } 107 | 108 | fun createBoxes(maxColorCount: Int): CreateBoxesResult { 109 | cubes = arrayOfNulls(maxColorCount) 110 | for (i in 0 until maxColorCount) { 111 | cubes[i] = Box() 112 | } 113 | val volumeVariance = DoubleArray(maxColorCount) 114 | val firstBox = cubes[0] 115 | firstBox!!.r1 = INDEX_COUNT - 1 116 | firstBox.g1 = INDEX_COUNT - 1 117 | firstBox.b1 = INDEX_COUNT - 1 118 | var generatedColorCount = maxColorCount 119 | var next = 0 120 | var i = 1 121 | while (i < maxColorCount) { 122 | if (cut(cubes[next], cubes[i])) { 123 | volumeVariance[next] = if (cubes[next]!!.vol > 1) variance(cubes[next]) else 0.0 124 | volumeVariance[i] = if (cubes[i]!!.vol > 1) variance(cubes[i]) else 0.0 125 | } else { 126 | volumeVariance[next] = 0.0 127 | i-- 128 | } 129 | next = 0 130 | var temp = volumeVariance[0] 131 | for (j in 1..i) { 132 | if (volumeVariance[j] > temp) { 133 | temp = volumeVariance[j] 134 | next = j 135 | } 136 | } 137 | if (temp <= 0.0) { 138 | generatedColorCount = i + 1 139 | break 140 | } 141 | i++ 142 | } 143 | return CreateBoxesResult(maxColorCount, generatedColorCount) 144 | } 145 | 146 | fun createResult(colorCount: Int): List { 147 | val colors: MutableList = ArrayList() 148 | for (i in 0 until colorCount) { 149 | val cube = cubes[i] 150 | val weight = volume(cube, weights) 151 | if (weight > 0) { 152 | val r = volume(cube, momentsR) / weight 153 | val g = volume(cube, momentsG) / weight 154 | val b = volume(cube, momentsB) / weight 155 | val color = 156 | 255 shl 24 or (r and 0x0ff shl 16) or (g and 0x0ff shl 8) or (b and 0x0ff) 157 | colors.add(color) 158 | } 159 | } 160 | return colors 161 | } 162 | 163 | fun variance(cube: Box?): Double { 164 | val dr = volume(cube, momentsR) 165 | val dg = volume(cube, momentsG) 166 | val db = volume(cube, momentsB) 167 | val xx = ((((moments[getIndex(cube!!.r1, cube.g1, cube.b1)] 168 | - moments[getIndex(cube.r1, cube.g1, cube.b0)] 169 | - moments[getIndex(cube.r1, cube.g0, cube.b1)]) 170 | + moments[getIndex(cube.r1, cube.g0, cube.b0)] 171 | - moments[getIndex(cube.r0, cube.g1, cube.b1)] 172 | ) + moments[getIndex(cube.r0, cube.g1, cube.b0)] 173 | + moments[getIndex(cube.r0, cube.g0, cube.b1)]) 174 | - moments[getIndex(cube.r0, cube.g0, cube.b0)]) 175 | val hypotenuse = dr * dr + dg * dg + db * db 176 | val volume = volume(cube, weights) 177 | return xx - hypotenuse / volume.toDouble() 178 | } 179 | 180 | fun cut(one: Box?, two: Box?): Boolean { 181 | val wholeR = volume(one, momentsR) 182 | val wholeG = volume(one, momentsG) 183 | val wholeB = volume(one, momentsB) 184 | val wholeW = volume(one, weights) 185 | val maxRResult = 186 | maximize(one, Direction.RED, one!!.r0 + 1, one.r1, wholeR, wholeG, wholeB, wholeW) 187 | val maxGResult = 188 | maximize(one, Direction.GREEN, one.g0 + 1, one.g1, wholeR, wholeG, wholeB, wholeW) 189 | val maxBResult = 190 | maximize(one, Direction.BLUE, one.b0 + 1, one.b1, wholeR, wholeG, wholeB, wholeW) 191 | val cutDirection: Direction 192 | val maxR = maxRResult.maximum 193 | val maxG = maxGResult.maximum 194 | val maxB = maxBResult.maximum 195 | cutDirection = if (maxR >= maxG && maxR >= maxB) { 196 | if (maxRResult.cutLocation < 0) { 197 | return false 198 | } 199 | Direction.RED 200 | } else if (maxG >= maxR && maxG >= maxB) { 201 | Direction.GREEN 202 | } else { 203 | Direction.BLUE 204 | } 205 | two!!.r1 = one.r1 206 | two.g1 = one.g1 207 | two.b1 = one.b1 208 | when (cutDirection) { 209 | Direction.RED -> { 210 | one.r1 = maxRResult.cutLocation 211 | two.r0 = one.r1 212 | two.g0 = one.g0 213 | two.b0 = one.b0 214 | } 215 | Direction.GREEN -> { 216 | one.g1 = maxGResult.cutLocation 217 | two.r0 = one.r0 218 | two.g0 = one.g1 219 | two.b0 = one.b0 220 | } 221 | Direction.BLUE -> { 222 | one.b1 = maxBResult.cutLocation 223 | two.r0 = one.r0 224 | two.g0 = one.g0 225 | two.b0 = one.b1 226 | } 227 | } 228 | one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0) 229 | two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0) 230 | return true 231 | } 232 | 233 | fun maximize( 234 | cube: Box?, 235 | direction: Direction, 236 | first: Int, 237 | last: Int, 238 | wholeR: Int, 239 | wholeG: Int, 240 | wholeB: Int, 241 | wholeW: Int 242 | ): MaximizeResult { 243 | val bottomR = bottom(cube, direction, momentsR) 244 | val bottomG = bottom(cube, direction, momentsG) 245 | val bottomB = bottom(cube, direction, momentsB) 246 | val bottomW = bottom(cube, direction, weights) 247 | var max = 0.0 248 | var cut = -1 249 | var halfR = 0 250 | var halfG = 0 251 | var halfB = 0 252 | var halfW = 0 253 | for (i in first until last) { 254 | halfR = bottomR + top(cube, direction, i, momentsR) 255 | halfG = bottomG + top(cube, direction, i, momentsG) 256 | halfB = bottomB + top(cube, direction, i, momentsB) 257 | halfW = bottomW + top(cube, direction, i, weights) 258 | if (halfW == 0) { 259 | continue 260 | } 261 | var tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB).toDouble() 262 | var tempDenominator = halfW.toDouble() 263 | var temp = tempNumerator / tempDenominator 264 | halfR = wholeR - halfR 265 | halfG = wholeG - halfG 266 | halfB = wholeB - halfB 267 | halfW = wholeW - halfW 268 | if (halfW == 0) { 269 | continue 270 | } 271 | tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB).toDouble() 272 | tempDenominator = halfW.toDouble() 273 | temp += tempNumerator / tempDenominator 274 | if (temp > max) { 275 | max = temp 276 | cut = i 277 | } 278 | } 279 | return MaximizeResult(cut, max) 280 | } 281 | 282 | enum class Direction { 283 | RED, GREEN, BLUE 284 | } 285 | 286 | class MaximizeResult internal constructor(// < 0 if cut impossible 287 | var cutLocation: Int, var maximum: Double 288 | ) 289 | 290 | class CreateBoxesResult internal constructor(var requestedCount: Int, var resultCount: Int) 291 | class Box { 292 | var r0 = 0 293 | var r1 = 0 294 | var g0 = 0 295 | var g1 = 0 296 | var b0 = 0 297 | var b1 = 0 298 | var vol = 0 299 | } 300 | 301 | companion object { 302 | // A histogram of all the input colors is constructed. It has the shape of a 303 | // cube. The cube would be too large if it contained all 16 million colors: 304 | // historical best practice is to use 5 bits of the 8 in each channel, 305 | // reducing the histogram to a volume of ~32,000. 306 | private const val INDEX_BITS = 5 307 | private const val INDEX_COUNT = 33 // ((1 << INDEX_BITS) + 1) 308 | private const val TOTAL_SIZE = 35937 // INDEX_COUNT * INDEX_COUNT * INDEX_COUNT 309 | fun getIndex(r: Int, g: Int, b: Int): Int { 310 | return r shl INDEX_BITS * 2 + (r shl INDEX_BITS + 1) + r + (g shl INDEX_BITS) + g + b 311 | } 312 | 313 | fun volume(cube: Box?, moment: IntArray): Int { 314 | return ((((moment[getIndex(cube!!.r1, cube.g1, cube.b1)] 315 | - moment[getIndex(cube.r1, cube.g1, cube.b0)] 316 | - moment[getIndex(cube.r1, cube.g0, cube.b1)]) 317 | + moment[getIndex(cube.r1, cube.g0, cube.b0)] 318 | - moment[getIndex(cube.r0, cube.g1, cube.b1)] 319 | ) + moment[getIndex(cube.r0, cube.g1, cube.b0)] 320 | + moment[getIndex(cube.r0, cube.g0, cube.b1)]) 321 | - moment[getIndex(cube.r0, cube.g0, cube.b0)]) 322 | } 323 | 324 | fun bottom(cube: Box?, direction: Direction, moment: IntArray): Int { 325 | return when (direction) { 326 | Direction.RED -> ((-moment[getIndex( 327 | cube!!.r0, cube.g1, cube.b1 328 | )] 329 | + moment[getIndex(cube.r0, cube.g1, cube.b0)] 330 | + moment[getIndex(cube.r0, cube.g0, cube.b1)]) 331 | - moment[getIndex(cube.r0, cube.g0, cube.b0)]) 332 | Direction.GREEN -> ((-moment[getIndex( 333 | cube!!.r1, cube.g0, cube.b1 334 | )] 335 | + moment[getIndex(cube.r1, cube.g0, cube.b0)] 336 | + moment[getIndex(cube.r0, cube.g0, cube.b1)]) 337 | - moment[getIndex(cube.r0, cube.g0, cube.b0)]) 338 | Direction.BLUE -> ((-moment[getIndex( 339 | cube!!.r1, cube.g1, cube.b0 340 | )] 341 | + moment[getIndex(cube.r1, cube.g0, cube.b0)] 342 | + moment[getIndex(cube.r0, cube.g1, cube.b0)]) 343 | - moment[getIndex(cube.r0, cube.g0, cube.b0)]) 344 | } 345 | throw IllegalArgumentException("unexpected direction $direction") 346 | } 347 | 348 | fun top(cube: Box?, direction: Direction, position: Int, moment: IntArray): Int { 349 | return when (direction) { 350 | Direction.RED -> ((moment[getIndex(position, cube!!.g1, cube.b1)] 351 | - moment[getIndex(position, cube.g1, cube.b0)] 352 | - moment[getIndex(position, cube.g0, cube.b1)]) 353 | + moment[getIndex(position, cube.g0, cube.b0)]) 354 | Direction.GREEN -> ((moment[getIndex( 355 | cube!!.r1, position, cube.b1 356 | )] 357 | - moment[getIndex(cube.r1, position, cube.b0)] 358 | - moment[getIndex(cube.r0, position, cube.b1)]) 359 | + moment[getIndex(cube.r0, position, cube.b0)]) 360 | Direction.BLUE -> ((moment[getIndex( 361 | cube!!.r1, cube.g1, position 362 | )] 363 | - moment[getIndex(cube.r1, cube.g0, position)] 364 | - moment[getIndex(cube.r0, cube.g1, position)]) 365 | + moment[getIndex(cube.r0, cube.g0, position)]) 366 | } 367 | throw IllegalArgumentException("unexpected direction $direction") 368 | } 369 | } 370 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/hct/Cam16.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.t8rin.dynamic.theme.hct; 18 | 19 | import static java.lang.Math.max; 20 | 21 | import com.t8rin.dynamic.theme.utils.ColorUtils; 22 | 23 | /** 24 | * CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex 25 | * code and viewing conditions. 26 | * 27 | *

CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, 28 | * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when 29 | * measuring distances between colors. 30 | * 31 | *

In traditional color spaces, a color can be identified solely by the observer's measurement of 32 | * the color. Color appearance models such as CAM16 also use information about the environment where 33 | * the color was observed, known as the viewing conditions. 34 | * 35 | *

For example, white under the traditional assumption of a midday sun white point is accurately 36 | * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) 37 | */ 38 | public final class Cam16 { 39 | // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. 40 | static final double[][] XYZ_TO_CAM16RGB = { 41 | {0.401288, 0.650173, -0.051461}, 42 | {-0.250268, 1.204414, 0.045854}, 43 | {-0.002079, 0.048952, 0.953127} 44 | }; 45 | 46 | // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. 47 | static final double[][] CAM16RGB_TO_XYZ = { 48 | {1.8620678, -1.0112547, 0.14918678}, 49 | {0.38752654, 0.62144744, -0.00897398}, 50 | {-0.01584150, -0.03412294, 1.0499644} 51 | }; 52 | 53 | // CAM16 color dimensions, see getters for documentation. 54 | private final double hue; 55 | private final double chroma; 56 | private final double j; 57 | private final double q; 58 | private final double m; 59 | private final double s; 60 | 61 | // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. 62 | private final double jstar; 63 | private final double astar; 64 | private final double bstar; 65 | 66 | // Avoid allocations during conversion by pre-allocating an array. 67 | private final double[] tempArray = new double[]{0.0, 0.0, 0.0}; 68 | 69 | /** 70 | * All of the CAM16 dimensions can be calculated from 3 of the dimensions, in the following 71 | * combinations: - {j or q} and {c, m, or s} and hue - jstar, astar, bstar Prefer using a static 72 | * method that constructs from 3 of those dimensions. This constructor is intended for those 73 | * methods to use to return all possible dimensions. 74 | * 75 | * @param hue for example, red, orange, yellow, green, etc. 76 | * @param chroma informally, colorfulness / color intensity. like saturation in HSL, except 77 | * perceptually accurate. 78 | * @param j lightness 79 | * @param q brightness; ratio of lightness to white point's lightness 80 | * @param m colorfulness 81 | * @param s saturation; ratio of chroma to white point's chroma 82 | * @param jstar CAM16-UCS J coordinate 83 | * @param astar CAM16-UCS a coordinate 84 | * @param bstar CAM16-UCS b coordinate 85 | */ 86 | private Cam16( 87 | double hue, 88 | double chroma, 89 | double j, 90 | double q, 91 | double m, 92 | double s, 93 | double jstar, 94 | double astar, 95 | double bstar) { 96 | this.hue = hue; 97 | this.chroma = chroma; 98 | this.j = j; 99 | this.q = q; 100 | this.m = m; 101 | this.s = s; 102 | this.jstar = jstar; 103 | this.astar = astar; 104 | this.bstar = bstar; 105 | } 106 | 107 | /** 108 | * Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions. 109 | * 110 | * @param argb ARGB representation of a color. 111 | */ 112 | public static Cam16 fromInt(int argb) { 113 | return fromIntInViewingConditions(argb, ViewingConditions.DEFAULT); 114 | } 115 | 116 | /** 117 | * Create a CAM16 color from a color in defined viewing conditions. 118 | * 119 | * @param argb ARGB representation of a color. 120 | * @param viewingConditions Information about the environment where the color was observed. 121 | */ 122 | // The RGB => XYZ conversion matrix elements are derived scientific constants. While the values 123 | // may differ at runtime due to floating point imprecision, keeping the values the same, and 124 | // accurate, across implementations takes precedence. 125 | @SuppressWarnings("FloatingPointLiteralPrecision") 126 | static Cam16 fromIntInViewingConditions(int argb, ViewingConditions viewingConditions) { 127 | // Transform ARGB int to XYZ 128 | int red = (argb & 0x00ff0000) >> 16; 129 | int green = (argb & 0x0000ff00) >> 8; 130 | int blue = (argb & 0x000000ff); 131 | double redL = ColorUtils.linearized(red); 132 | double greenL = ColorUtils.linearized(green); 133 | double blueL = ColorUtils.linearized(blue); 134 | double x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL; 135 | double y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL; 136 | double z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL; 137 | 138 | return fromXyzInViewingConditions(x, y, z, viewingConditions); 139 | } 140 | 141 | static Cam16 fromXyzInViewingConditions( 142 | double x, double y, double z, ViewingConditions viewingConditions) { 143 | // Transform XYZ to 'cone'/'rgb' responses 144 | double[][] matrix = XYZ_TO_CAM16RGB; 145 | double rT = (x * matrix[0][0]) + (y * matrix[0][1]) + (z * matrix[0][2]); 146 | double gT = (x * matrix[1][0]) + (y * matrix[1][1]) + (z * matrix[1][2]); 147 | double bT = (x * matrix[2][0]) + (y * matrix[2][1]) + (z * matrix[2][2]); 148 | 149 | // Discount illuminant 150 | double rD = viewingConditions.getRgbD()[0] * rT; 151 | double gD = viewingConditions.getRgbD()[1] * gT; 152 | double bD = viewingConditions.getRgbD()[2] * bT; 153 | 154 | // Chromatic adaptation 155 | double rAF = Math.pow(viewingConditions.getFl() * Math.abs(rD) / 100.0, 0.42); 156 | double gAF = Math.pow(viewingConditions.getFl() * Math.abs(gD) / 100.0, 0.42); 157 | double bAF = Math.pow(viewingConditions.getFl() * Math.abs(bD) / 100.0, 0.42); 158 | double rA = Math.signum(rD) * 400.0 * rAF / (rAF + 27.13); 159 | double gA = Math.signum(gD) * 400.0 * gAF / (gAF + 27.13); 160 | double bA = Math.signum(bD) * 400.0 * bAF / (bAF + 27.13); 161 | 162 | // redness-greenness 163 | double a = (11.0 * rA + -12.0 * gA + bA) / 11.0; 164 | // yellowness-blueness 165 | double b = (rA + gA - 2.0 * bA) / 9.0; 166 | 167 | // auxiliary components 168 | double u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0; 169 | double p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0; 170 | 171 | // hue 172 | double atan2 = Math.atan2(b, a); 173 | double atanDegrees = Math.toDegrees(atan2); 174 | double hue = 175 | atanDegrees < 0 176 | ? atanDegrees + 360.0 177 | : atanDegrees >= 360 ? atanDegrees - 360.0 : atanDegrees; 178 | double hueRadians = Math.toRadians(hue); 179 | 180 | // achromatic response to color 181 | double ac = p2 * viewingConditions.getNbb(); 182 | 183 | // CAM16 lightness and brightness 184 | double j = 185 | 100.0 186 | * Math.pow( 187 | ac / viewingConditions.getAw(), 188 | viewingConditions.getC() * viewingConditions.getZ()); 189 | double q = 190 | 4.0 191 | / viewingConditions.getC() 192 | * Math.sqrt(j / 100.0) 193 | * (viewingConditions.getAw() + 4.0) 194 | * viewingConditions.getFlRoot(); 195 | 196 | // CAM16 chroma, colorfulness, and saturation. 197 | double huePrime = (hue < 20.14) ? hue + 360 : hue; 198 | double eHue = 0.25 * (Math.cos(Math.toRadians(huePrime) + 2.0) + 3.8); 199 | double p1 = 50000.0 / 13.0 * eHue * viewingConditions.getNc() * viewingConditions.getNcb(); 200 | double t = p1 * Math.hypot(a, b) / (u + 0.305); 201 | double alpha = 202 | Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73) * Math.pow(t, 0.9); 203 | // CAM16 chroma, colorfulness, saturation 204 | double c = alpha * Math.sqrt(j / 100.0); 205 | double m = c * viewingConditions.getFlRoot(); 206 | double s = 207 | 50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0)); 208 | 209 | // CAM16-UCS components 210 | double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j); 211 | double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m); 212 | double astar = mstar * Math.cos(hueRadians); 213 | double bstar = mstar * Math.sin(hueRadians); 214 | 215 | return new Cam16(hue, c, j, q, m, s, jstar, astar, bstar); 216 | } 217 | 218 | /** 219 | * @param j CAM16 lightness 220 | * @param c CAM16 chroma 221 | * @param h CAM16 hue 222 | */ 223 | static Cam16 fromJch(double j, double c, double h) { 224 | return fromJchInViewingConditions(j, c, h, ViewingConditions.DEFAULT); 225 | } 226 | 227 | /** 228 | * @param j CAM16 lightness 229 | * @param c CAM16 chroma 230 | * @param h CAM16 hue 231 | * @param viewingConditions Information about the environment where the color was observed. 232 | */ 233 | private static Cam16 fromJchInViewingConditions( 234 | double j, double c, double h, ViewingConditions viewingConditions) { 235 | double q = 236 | 4.0 237 | / viewingConditions.getC() 238 | * Math.sqrt(j / 100.0) 239 | * (viewingConditions.getAw() + 4.0) 240 | * viewingConditions.getFlRoot(); 241 | double m = c * viewingConditions.getFlRoot(); 242 | double alpha = c / Math.sqrt(j / 100.0); 243 | double s = 244 | 50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0)); 245 | 246 | double hueRadians = Math.toRadians(h); 247 | double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j); 248 | double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m); 249 | double astar = mstar * Math.cos(hueRadians); 250 | double bstar = mstar * Math.sin(hueRadians); 251 | return new Cam16(h, c, j, q, m, s, jstar, astar, bstar); 252 | } 253 | 254 | /** 255 | * Create a CAM16 color from CAM16-UCS coordinates. 256 | * 257 | * @param jstar CAM16-UCS lightness. 258 | * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y 259 | * axis. 260 | * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X 261 | * axis. 262 | */ 263 | public static Cam16 fromUcs(double jstar, double astar, double bstar) { 264 | 265 | return fromUcsInViewingConditions(jstar, astar, bstar, ViewingConditions.DEFAULT); 266 | } 267 | 268 | /** 269 | * Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions. 270 | * 271 | * @param jstar CAM16-UCS lightness. 272 | * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y 273 | * axis. 274 | * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X 275 | * axis. 276 | * @param viewingConditions Information about the environment where the color was observed. 277 | */ 278 | public static Cam16 fromUcsInViewingConditions( 279 | double jstar, double astar, double bstar, ViewingConditions viewingConditions) { 280 | 281 | double m = Math.hypot(astar, bstar); 282 | double m2 = Math.expm1(m * 0.0228) / 0.0228; 283 | double c = m2 / viewingConditions.getFlRoot(); 284 | double h = Math.atan2(bstar, astar) * (180.0 / Math.PI); 285 | if (h < 0.0) { 286 | h += 360.0; 287 | } 288 | double j = jstar / (1. - (jstar - 100.) * 0.007); 289 | return fromJchInViewingConditions(j, c, h, viewingConditions); 290 | } 291 | 292 | /** 293 | * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, 294 | * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure 295 | * distances between colors. 296 | */ 297 | double distance(Cam16 other) { 298 | double dJ = getJstar() - other.getJstar(); 299 | double dA = getAstar() - other.getAstar(); 300 | double dB = getBstar() - other.getBstar(); 301 | double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB); 302 | double dE = 1.41 * Math.pow(dEPrime, 0.63); 303 | return dE; 304 | } 305 | 306 | /** 307 | * Hue in CAM16 308 | */ 309 | public double getHue() { 310 | return hue; 311 | } 312 | 313 | /** 314 | * Chroma in CAM16 315 | */ 316 | public double getChroma() { 317 | return chroma; 318 | } 319 | 320 | /** 321 | * Lightness in CAM16 322 | */ 323 | public double getJ() { 324 | return j; 325 | } 326 | 327 | /** 328 | * Brightness in CAM16. 329 | * 330 | *

Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is 331 | * much brighter viewed in sunlight than in indoor light, but it is the lightest object under any 332 | * lighting. 333 | */ 334 | public double getQ() { 335 | return q; 336 | } 337 | 338 | /** 339 | * Colorfulness in CAM16. 340 | * 341 | *

Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much 342 | * more colorful outside than inside, but it has the same chroma in both environments. 343 | */ 344 | public double getM() { 345 | return m; 346 | } 347 | 348 | /** 349 | * Saturation in CAM16. 350 | * 351 | *

Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness 352 | * relative to the color's own brightness, where chroma is colorfulness relative to white. 353 | */ 354 | public double getS() { 355 | return s; 356 | } 357 | 358 | /** 359 | * Lightness coordinate in CAM16-UCS 360 | */ 361 | public double getJstar() { 362 | return jstar; 363 | } 364 | 365 | /** 366 | * a* coordinate in CAM16-UCS 367 | */ 368 | public double getAstar() { 369 | return astar; 370 | } 371 | 372 | /** 373 | * b* coordinate in CAM16-UCS 374 | */ 375 | public double getBstar() { 376 | return bstar; 377 | } 378 | 379 | /** 380 | * ARGB representation of the color. Assumes the color was viewed in default viewing conditions, 381 | * which are near-identical to the default viewing conditions for sRGB. 382 | */ 383 | public int toInt() { 384 | return viewed(ViewingConditions.DEFAULT); 385 | } 386 | 387 | /** 388 | * ARGB representation of the color, in defined viewing conditions. 389 | * 390 | * @param viewingConditions Information about the environment where the color will be viewed. 391 | * @return ARGB representation of color 392 | */ 393 | int viewed(ViewingConditions viewingConditions) { 394 | double[] xyz = xyzInViewingConditions(viewingConditions, tempArray); 395 | return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2]); 396 | } 397 | 398 | double[] xyzInViewingConditions(ViewingConditions viewingConditions, double[] returnArray) { 399 | double alpha = 400 | (getChroma() == 0.0 || getJ() == 0.0) ? 0.0 : getChroma() / Math.sqrt(getJ() / 100.0); 401 | 402 | double t = 403 | Math.pow( 404 | alpha / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), 1.0 / 0.9); 405 | double hRad = Math.toRadians(getHue()); 406 | 407 | double eHue = 0.25 * (Math.cos(hRad + 2.0) + 3.8); 408 | double ac = 409 | viewingConditions.getAw() 410 | * Math.pow(getJ() / 100.0, 1.0 / viewingConditions.getC() / viewingConditions.getZ()); 411 | double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb(); 412 | double p2 = (ac / viewingConditions.getNbb()); 413 | 414 | double hSin = Math.sin(hRad); 415 | double hCos = Math.cos(hRad); 416 | 417 | double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin); 418 | double a = gamma * hCos; 419 | double b = gamma * hSin; 420 | double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0; 421 | double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0; 422 | double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0; 423 | 424 | double rCBase = max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA))); 425 | double rC = 426 | Math.signum(rA) * (100.0 / viewingConditions.getFl()) * Math.pow(rCBase, 1.0 / 0.42); 427 | double gCBase = max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA))); 428 | double gC = 429 | Math.signum(gA) * (100.0 / viewingConditions.getFl()) * Math.pow(gCBase, 1.0 / 0.42); 430 | double bCBase = max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA))); 431 | double bC = 432 | Math.signum(bA) * (100.0 / viewingConditions.getFl()) * Math.pow(bCBase, 1.0 / 0.42); 433 | double rF = rC / viewingConditions.getRgbD()[0]; 434 | double gF = gC / viewingConditions.getRgbD()[1]; 435 | double bF = bC / viewingConditions.getRgbD()[2]; 436 | 437 | double[][] matrix = CAM16RGB_TO_XYZ; 438 | double x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]); 439 | double y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]); 440 | double z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]); 441 | 442 | if (returnArray != null) { 443 | returnArray[0] = x; 444 | returnArray[1] = y; 445 | returnArray[2] = z; 446 | return returnArray; 447 | } else { 448 | return new double[]{x, y, z}; 449 | } 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/scheme/Scheme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | // This file is automatically generated. Do not modify it. 17 | package com.t8rin.dynamic.theme.scheme 18 | 19 | import com.t8rin.dynamic.theme.palettes.CorePalette 20 | import com.t8rin.dynamic.theme.palettes.CorePalette.Companion.contentOf 21 | import com.t8rin.dynamic.theme.palettes.CorePalette.Companion.of 22 | 23 | /** 24 | * Represents a Material color scheme, a mapping of color roles to colors. 25 | */ 26 | class Scheme { 27 | var primary = 0 28 | var onPrimary = 0 29 | var primaryContainer = 0 30 | var onPrimaryContainer = 0 31 | var secondary = 0 32 | var onSecondary = 0 33 | var secondaryContainer = 0 34 | var onSecondaryContainer = 0 35 | var tertiary = 0 36 | var onTertiary = 0 37 | var tertiaryContainer = 0 38 | var onTertiaryContainer = 0 39 | var error = 0 40 | var onError = 0 41 | var errorContainer = 0 42 | var onErrorContainer = 0 43 | var background = 0 44 | var onBackground = 0 45 | var surface = 0 46 | var onSurface = 0 47 | var surfaceVariant = 0 48 | var onSurfaceVariant = 0 49 | var outline = 0 50 | var outlineVariant = 0 51 | var shadow = 0 52 | var scrim = 0 53 | var inverseSurface = 0 54 | var inverseOnSurface = 0 55 | var inversePrimary = 0 56 | 57 | constructor() {} 58 | constructor( 59 | primary: Int, 60 | onPrimary: Int, 61 | primaryContainer: Int, 62 | onPrimaryContainer: Int, 63 | secondary: Int, 64 | onSecondary: Int, 65 | secondaryContainer: Int, 66 | onSecondaryContainer: Int, 67 | tertiary: Int, 68 | onTertiary: Int, 69 | tertiaryContainer: Int, 70 | onTertiaryContainer: Int, 71 | error: Int, 72 | onError: Int, 73 | errorContainer: Int, 74 | onErrorContainer: Int, 75 | background: Int, 76 | onBackground: Int, 77 | surface: Int, 78 | onSurface: Int, 79 | surfaceVariant: Int, 80 | onSurfaceVariant: Int, 81 | outline: Int, 82 | outlineVariant: Int, 83 | shadow: Int, 84 | scrim: Int, 85 | inverseSurface: Int, 86 | inverseOnSurface: Int, 87 | inversePrimary: Int 88 | ) : super() { 89 | this.primary = primary 90 | this.onPrimary = onPrimary 91 | this.primaryContainer = primaryContainer 92 | this.onPrimaryContainer = onPrimaryContainer 93 | this.secondary = secondary 94 | this.onSecondary = onSecondary 95 | this.secondaryContainer = secondaryContainer 96 | this.onSecondaryContainer = onSecondaryContainer 97 | this.tertiary = tertiary 98 | this.onTertiary = onTertiary 99 | this.tertiaryContainer = tertiaryContainer 100 | this.onTertiaryContainer = onTertiaryContainer 101 | this.error = error 102 | this.onError = onError 103 | this.errorContainer = errorContainer 104 | this.onErrorContainer = onErrorContainer 105 | this.background = background 106 | this.onBackground = onBackground 107 | this.surface = surface 108 | this.onSurface = onSurface 109 | this.surfaceVariant = surfaceVariant 110 | this.onSurfaceVariant = onSurfaceVariant 111 | this.outline = outline 112 | this.outlineVariant = outlineVariant 113 | this.shadow = shadow 114 | this.scrim = scrim 115 | this.inverseSurface = inverseSurface 116 | this.inverseOnSurface = inverseOnSurface 117 | this.inversePrimary = inversePrimary 118 | } 119 | 120 | fun withPrimary(primary: Int): Scheme { 121 | this.primary = primary 122 | return this 123 | } 124 | 125 | fun withOnPrimary(onPrimary: Int): Scheme { 126 | this.onPrimary = onPrimary 127 | return this 128 | } 129 | 130 | fun withPrimaryContainer(primaryContainer: Int): Scheme { 131 | this.primaryContainer = primaryContainer 132 | return this 133 | } 134 | 135 | fun withOnPrimaryContainer(onPrimaryContainer: Int): Scheme { 136 | this.onPrimaryContainer = onPrimaryContainer 137 | return this 138 | } 139 | 140 | fun withSecondary(secondary: Int): Scheme { 141 | this.secondary = secondary 142 | return this 143 | } 144 | 145 | fun withOnSecondary(onSecondary: Int): Scheme { 146 | this.onSecondary = onSecondary 147 | return this 148 | } 149 | 150 | fun withSecondaryContainer(secondaryContainer: Int): Scheme { 151 | this.secondaryContainer = secondaryContainer 152 | return this 153 | } 154 | 155 | fun withOnSecondaryContainer(onSecondaryContainer: Int): Scheme { 156 | this.onSecondaryContainer = onSecondaryContainer 157 | return this 158 | } 159 | 160 | fun withTertiary(tertiary: Int): Scheme { 161 | this.tertiary = tertiary 162 | return this 163 | } 164 | 165 | fun withOnTertiary(onTertiary: Int): Scheme { 166 | this.onTertiary = onTertiary 167 | return this 168 | } 169 | 170 | fun withTertiaryContainer(tertiaryContainer: Int): Scheme { 171 | this.tertiaryContainer = tertiaryContainer 172 | return this 173 | } 174 | 175 | fun withOnTertiaryContainer(onTertiaryContainer: Int): Scheme { 176 | this.onTertiaryContainer = onTertiaryContainer 177 | return this 178 | } 179 | 180 | fun withError(error: Int): Scheme { 181 | this.error = error 182 | return this 183 | } 184 | 185 | fun withOnError(onError: Int): Scheme { 186 | this.onError = onError 187 | return this 188 | } 189 | 190 | fun withErrorContainer(errorContainer: Int): Scheme { 191 | this.errorContainer = errorContainer 192 | return this 193 | } 194 | 195 | fun withOnErrorContainer(onErrorContainer: Int): Scheme { 196 | this.onErrorContainer = onErrorContainer 197 | return this 198 | } 199 | 200 | fun withBackground(background: Int): Scheme { 201 | this.background = background 202 | return this 203 | } 204 | 205 | fun withOnBackground(onBackground: Int): Scheme { 206 | this.onBackground = onBackground 207 | return this 208 | } 209 | 210 | fun withSurface(surface: Int): Scheme { 211 | this.surface = surface 212 | return this 213 | } 214 | 215 | fun withOnSurface(onSurface: Int): Scheme { 216 | this.onSurface = onSurface 217 | return this 218 | } 219 | 220 | fun withSurfaceVariant(surfaceVariant: Int): Scheme { 221 | this.surfaceVariant = surfaceVariant 222 | return this 223 | } 224 | 225 | fun withOnSurfaceVariant(onSurfaceVariant: Int): Scheme { 226 | this.onSurfaceVariant = onSurfaceVariant 227 | return this 228 | } 229 | 230 | fun withOutline(outline: Int): Scheme { 231 | this.outline = outline 232 | return this 233 | } 234 | 235 | fun withOutlineVariant(outlineVariant: Int): Scheme { 236 | this.outlineVariant = outlineVariant 237 | return this 238 | } 239 | 240 | fun withShadow(shadow: Int): Scheme { 241 | this.shadow = shadow 242 | return this 243 | } 244 | 245 | fun withScrim(scrim: Int): Scheme { 246 | this.scrim = scrim 247 | return this 248 | } 249 | 250 | fun withInverseSurface(inverseSurface: Int): Scheme { 251 | this.inverseSurface = inverseSurface 252 | return this 253 | } 254 | 255 | fun withInverseOnSurface(inverseOnSurface: Int): Scheme { 256 | this.inverseOnSurface = inverseOnSurface 257 | return this 258 | } 259 | 260 | fun withInversePrimary(inversePrimary: Int): Scheme { 261 | this.inversePrimary = inversePrimary 262 | return this 263 | } 264 | 265 | override fun toString(): String { 266 | return ("Scheme{" 267 | + "primary=" 268 | + primary 269 | + ", onPrimary=" 270 | + onPrimary 271 | + ", primaryContainer=" 272 | + primaryContainer 273 | + ", onPrimaryContainer=" 274 | + onPrimaryContainer 275 | + ", secondary=" 276 | + secondary 277 | + ", onSecondary=" 278 | + onSecondary 279 | + ", secondaryContainer=" 280 | + secondaryContainer 281 | + ", onSecondaryContainer=" 282 | + onSecondaryContainer 283 | + ", tertiary=" 284 | + tertiary 285 | + ", onTertiary=" 286 | + onTertiary 287 | + ", tertiaryContainer=" 288 | + tertiaryContainer 289 | + ", onTertiaryContainer=" 290 | + onTertiaryContainer 291 | + ", error=" 292 | + error 293 | + ", onError=" 294 | + onError 295 | + ", errorContainer=" 296 | + errorContainer 297 | + ", onErrorContainer=" 298 | + onErrorContainer 299 | + ", background=" 300 | + background 301 | + ", onBackground=" 302 | + onBackground 303 | + ", surface=" 304 | + surface 305 | + ", onSurface=" 306 | + onSurface 307 | + ", surfaceVariant=" 308 | + surfaceVariant 309 | + ", onSurfaceVariant=" 310 | + onSurfaceVariant 311 | + ", outline=" 312 | + outline 313 | + ", outlineVariant=" 314 | + outlineVariant 315 | + ", shadow=" 316 | + shadow 317 | + ", scrim=" 318 | + scrim 319 | + ", inverseSurface=" 320 | + inverseSurface 321 | + ", inverseOnSurface=" 322 | + inverseOnSurface 323 | + ", inversePrimary=" 324 | + inversePrimary 325 | + '}') 326 | } 327 | 328 | override fun equals(`object`: Any?): Boolean { 329 | if (this === `object`) { 330 | return true 331 | } 332 | if (`object` !is Scheme) { 333 | return false 334 | } 335 | if (!super.equals(`object`)) { 336 | return false 337 | } 338 | val scheme = `object` 339 | if (primary != scheme.primary) { 340 | return false 341 | } 342 | if (onPrimary != scheme.onPrimary) { 343 | return false 344 | } 345 | if (primaryContainer != scheme.primaryContainer) { 346 | return false 347 | } 348 | if (onPrimaryContainer != scheme.onPrimaryContainer) { 349 | return false 350 | } 351 | if (secondary != scheme.secondary) { 352 | return false 353 | } 354 | if (onSecondary != scheme.onSecondary) { 355 | return false 356 | } 357 | if (secondaryContainer != scheme.secondaryContainer) { 358 | return false 359 | } 360 | if (onSecondaryContainer != scheme.onSecondaryContainer) { 361 | return false 362 | } 363 | if (tertiary != scheme.tertiary) { 364 | return false 365 | } 366 | if (onTertiary != scheme.onTertiary) { 367 | return false 368 | } 369 | if (tertiaryContainer != scheme.tertiaryContainer) { 370 | return false 371 | } 372 | if (onTertiaryContainer != scheme.onTertiaryContainer) { 373 | return false 374 | } 375 | if (error != scheme.error) { 376 | return false 377 | } 378 | if (onError != scheme.onError) { 379 | return false 380 | } 381 | if (errorContainer != scheme.errorContainer) { 382 | return false 383 | } 384 | if (onErrorContainer != scheme.onErrorContainer) { 385 | return false 386 | } 387 | if (background != scheme.background) { 388 | return false 389 | } 390 | if (onBackground != scheme.onBackground) { 391 | return false 392 | } 393 | if (surface != scheme.surface) { 394 | return false 395 | } 396 | if (onSurface != scheme.onSurface) { 397 | return false 398 | } 399 | if (surfaceVariant != scheme.surfaceVariant) { 400 | return false 401 | } 402 | if (onSurfaceVariant != scheme.onSurfaceVariant) { 403 | return false 404 | } 405 | if (outline != scheme.outline) { 406 | return false 407 | } 408 | if (outlineVariant != scheme.outlineVariant) { 409 | return false 410 | } 411 | if (shadow != scheme.shadow) { 412 | return false 413 | } 414 | if (scrim != scheme.scrim) { 415 | return false 416 | } 417 | if (inverseSurface != scheme.inverseSurface) { 418 | return false 419 | } 420 | if (inverseOnSurface != scheme.inverseOnSurface) { 421 | return false 422 | } 423 | return if (inversePrimary != scheme.inversePrimary) { 424 | false 425 | } else true 426 | } 427 | 428 | override fun hashCode(): Int { 429 | var result = super.hashCode() 430 | result = 31 * result + primary 431 | result = 31 * result + onPrimary 432 | result = 31 * result + primaryContainer 433 | result = 31 * result + onPrimaryContainer 434 | result = 31 * result + secondary 435 | result = 31 * result + onSecondary 436 | result = 31 * result + secondaryContainer 437 | result = 31 * result + onSecondaryContainer 438 | result = 31 * result + tertiary 439 | result = 31 * result + onTertiary 440 | result = 31 * result + tertiaryContainer 441 | result = 31 * result + onTertiaryContainer 442 | result = 31 * result + error 443 | result = 31 * result + onError 444 | result = 31 * result + errorContainer 445 | result = 31 * result + onErrorContainer 446 | result = 31 * result + background 447 | result = 31 * result + onBackground 448 | result = 31 * result + surface 449 | result = 31 * result + onSurface 450 | result = 31 * result + surfaceVariant 451 | result = 31 * result + onSurfaceVariant 452 | result = 31 * result + outline 453 | result = 31 * result + outlineVariant 454 | result = 31 * result + shadow 455 | result = 31 * result + scrim 456 | result = 31 * result + inverseSurface 457 | result = 31 * result + inverseOnSurface 458 | result = 31 * result + inversePrimary 459 | return result 460 | } 461 | 462 | companion object { 463 | fun light(argb: Int): Scheme { 464 | return lightFromCorePalette(of(argb)) 465 | } 466 | 467 | fun dark(argb: Int): Scheme { 468 | return darkFromCorePalette(of(argb)) 469 | } 470 | 471 | fun lightContent(argb: Int): Scheme { 472 | return lightFromCorePalette(contentOf(argb)) 473 | } 474 | 475 | fun darkContent(argb: Int): Scheme { 476 | return darkFromCorePalette(contentOf(argb)) 477 | } 478 | 479 | private fun lightFromCorePalette(core: CorePalette): Scheme { 480 | return Scheme() 481 | .withPrimary(core.a1!!.tone(40)) 482 | .withOnPrimary(core.a1!!.tone(100)) 483 | .withPrimaryContainer(core.a1!!.tone(90)) 484 | .withOnPrimaryContainer(core.a1!!.tone(10)) 485 | .withSecondary(core.a2!!.tone(40)) 486 | .withOnSecondary(core.a2!!.tone(100)) 487 | .withSecondaryContainer(core.a2!!.tone(90)) 488 | .withOnSecondaryContainer(core.a2!!.tone(10)) 489 | .withTertiary(core.a3!!.tone(40)) 490 | .withOnTertiary(core.a3!!.tone(100)) 491 | .withTertiaryContainer(core.a3!!.tone(90)) 492 | .withOnTertiaryContainer(core.a3!!.tone(10)) 493 | .withError(core.error.tone(40)) 494 | .withOnError(core.error.tone(100)) 495 | .withErrorContainer(core.error.tone(90)) 496 | .withOnErrorContainer(core.error.tone(10)) 497 | .withBackground(core.n1!!.tone(99)) 498 | .withOnBackground(core.n1!!.tone(10)) 499 | .withSurface(core.n1!!.tone(99)) 500 | .withOnSurface(core.n1!!.tone(10)) 501 | .withSurfaceVariant(core.n2!!.tone(90)) 502 | .withOnSurfaceVariant(core.n2!!.tone(30)) 503 | .withOutline(core.n2!!.tone(50)) 504 | .withOutlineVariant(core.n2!!.tone(80)) 505 | .withShadow(core.n1!!.tone(0)) 506 | .withScrim(core.n1!!.tone(0)) 507 | .withInverseSurface(core.n1!!.tone(20)) 508 | .withInverseOnSurface(core.n1!!.tone(95)) 509 | .withInversePrimary(core.a1!!.tone(80)) 510 | } 511 | 512 | private fun darkFromCorePalette(core: CorePalette): Scheme { 513 | return Scheme() 514 | .withPrimary(core.a1!!.tone(80)) 515 | .withOnPrimary(core.a1!!.tone(20)) 516 | .withPrimaryContainer(core.a1!!.tone(30)) 517 | .withOnPrimaryContainer(core.a1!!.tone(90)) 518 | .withSecondary(core.a2!!.tone(80)) 519 | .withOnSecondary(core.a2!!.tone(20)) 520 | .withSecondaryContainer(core.a2!!.tone(30)) 521 | .withOnSecondaryContainer(core.a2!!.tone(90)) 522 | .withTertiary(core.a3!!.tone(80)) 523 | .withOnTertiary(core.a3!!.tone(20)) 524 | .withTertiaryContainer(core.a3!!.tone(30)) 525 | .withOnTertiaryContainer(core.a3!!.tone(90)) 526 | .withError(core.error.tone(80)) 527 | .withOnError(core.error.tone(20)) 528 | .withErrorContainer(core.error.tone(30)) 529 | .withOnErrorContainer(core.error.tone(80)) 530 | .withBackground(core.n1!!.tone(10)) 531 | .withOnBackground(core.n1!!.tone(90)) 532 | .withSurface(core.n1!!.tone(10)) 533 | .withOnSurface(core.n1!!.tone(90)) 534 | .withSurfaceVariant(core.n2!!.tone(30)) 535 | .withOnSurfaceVariant(core.n2!!.tone(80)) 536 | .withOutline(core.n2!!.tone(60)) 537 | .withOutlineVariant(core.n2!!.tone(30)) 538 | .withShadow(core.n1!!.tone(0)) 539 | .withScrim(core.n1!!.tone(0)) 540 | .withInverseSurface(core.n1!!.tone(90)) 541 | .withInverseOnSurface(core.n1!!.tone(20)) 542 | .withInversePrimary(core.a1!!.tone(40)) 543 | } 544 | } 545 | } -------------------------------------------------------------------------------- /dynamic_theme/src/main/java/com/t8rin/dynamic/theme/DynamicTheme.kt: -------------------------------------------------------------------------------- 1 | package com.t8rin.dynamic.theme 2 | 3 | import android.Manifest 4 | import android.app.WallpaperManager 5 | import android.content.pm.PackageManager 6 | import android.graphics.Bitmap 7 | import android.graphics.drawable.BitmapDrawable 8 | import android.os.Build 9 | import androidx.annotation.FloatRange 10 | import androidx.compose.animation.animateColorAsState 11 | import androidx.compose.animation.core.AnimationSpec 12 | import androidx.compose.animation.core.tween 13 | import androidx.compose.foundation.background 14 | import androidx.compose.foundation.isSystemInDarkTheme 15 | import androidx.compose.foundation.layout.* 16 | import androidx.compose.foundation.shape.CircleShape 17 | import androidx.compose.material3.* 18 | import androidx.compose.runtime.* 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.compositeOver 24 | import androidx.compose.ui.graphics.toArgb 25 | import androidx.compose.ui.platform.LocalContext 26 | import androidx.compose.ui.platform.LocalDensity 27 | import androidx.compose.ui.platform.LocalLifecycleOwner 28 | import androidx.compose.ui.unit.Density 29 | import androidx.compose.ui.unit.dp 30 | import androidx.core.app.ActivityCompat 31 | import androidx.core.graphics.ColorUtils 32 | import androidx.lifecycle.Lifecycle 33 | import androidx.lifecycle.LifecycleEventObserver 34 | import androidx.palette.graphics.Palette 35 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 36 | import com.t8rin.dynamic.theme.hct.Hct 37 | import com.t8rin.dynamic.theme.palettes.TonalPalette 38 | import com.t8rin.dynamic.theme.scheme.Scheme 39 | 40 | /** 41 | * DynamicTheme allows you to dynamically change the color scheme of the content hierarchy. 42 | * To do this you just need to update [DynamicThemeState]. 43 | * @param state - current instance of [DynamicThemeState] 44 | * */ 45 | @Composable 46 | fun DynamicTheme( 47 | state: DynamicThemeState, 48 | typography: Typography = Typography(), 49 | density: Density = LocalDensity.current, 50 | defaultColorTuple: ColorTuple, 51 | dynamicColor: Boolean = true, 52 | amoledMode: Boolean = false, 53 | isDarkTheme: Boolean = isSystemInDarkTheme(), 54 | content: @Composable () -> Unit, 55 | ) { 56 | val colorTuple = getAppColorTuple( 57 | defaultColorTuple = defaultColorTuple, 58 | dynamicColor = dynamicColor, 59 | darkTheme = isDarkTheme 60 | ) 61 | 62 | LaunchedEffect(colorTuple) { 63 | state.updateColorTuple(colorTuple) 64 | } 65 | 66 | val systemUiController = rememberSystemUiController() 67 | val useDarkIcons = !isDarkTheme 68 | 69 | SideEffect { 70 | systemUiController.setSystemBarsColor( 71 | color = Color.Transparent, 72 | darkIcons = useDarkIcons, 73 | isNavigationBarContrastEnforced = false 74 | ) 75 | } 76 | 77 | val scheme = rememberColorScheme( 78 | amoledMode = amoledMode, 79 | isDarkTheme = isDarkTheme, 80 | colorTuple = state.colorTuple.value 81 | ).animateAllColors(tween(150)) 82 | 83 | MaterialTheme( 84 | typography = typography, 85 | colorScheme = scheme, 86 | content = { 87 | CompositionLocalProvider( 88 | values = arrayOf( 89 | LocalDynamicThemeState provides state, 90 | LocalDensity provides density 91 | ), 92 | content = content 93 | ) 94 | } 95 | ) 96 | } 97 | 98 | /**Composable representing ColorTuple object **/ 99 | @Composable 100 | fun ColorTupleItem( 101 | modifier: Modifier = Modifier, 102 | backgroundColor: Color = MaterialTheme.colorScheme.surface, 103 | colorTuple: ColorTuple, 104 | content: (@Composable BoxScope.() -> Unit)? = null 105 | ) { 106 | val (primary, secondary, tertiary) = remember(colorTuple) { 107 | derivedStateOf { 108 | colorTuple.run { 109 | val hct = Hct.fromInt(colorTuple.primary.toArgb()) 110 | val hue = hct.hue 111 | val chroma = hct.chroma 112 | 113 | val secondary = colorTuple.secondary?.toArgb().let { 114 | if (it != null) { 115 | TonalPalette.fromInt(it) 116 | } else { 117 | TonalPalette.fromHueAndChroma(hue, chroma / 3.0) 118 | } 119 | } 120 | val tertiary = colorTuple.tertiary?.toArgb().let { 121 | if (it != null) { 122 | TonalPalette.fromInt(it) 123 | } else { 124 | TonalPalette.fromHueAndChroma(hue + 60.0, chroma / 2.0) 125 | } 126 | } 127 | 128 | Triple( 129 | primary, 130 | colorTuple.secondary ?: Color(secondary.tone(70)), 131 | colorTuple.tertiary ?: Color(tertiary.tone(70)) 132 | ) 133 | } 134 | } 135 | }.value.run { 136 | Triple( 137 | animateColorAsState(targetValue = first).value, 138 | animateColorAsState(targetValue = second).value, 139 | animateColorAsState(targetValue = third).value 140 | ) 141 | } 142 | 143 | Surface( 144 | modifier = modifier, 145 | color = backgroundColor, 146 | shape = MaterialTheme.shapes.medium, 147 | ) { 148 | Box( 149 | modifier = Modifier 150 | .fillMaxSize() 151 | .padding(8.dp) 152 | .clip(CircleShape), 153 | contentAlignment = Alignment.Center 154 | ) { 155 | Column( 156 | Modifier.fillMaxSize() 157 | ) { 158 | Box( 159 | modifier = Modifier 160 | .fillMaxWidth() 161 | .weight(1f) 162 | .background(primary) 163 | ) 164 | Row( 165 | modifier = Modifier 166 | .weight(1f) 167 | .fillMaxWidth() 168 | ) { 169 | Box( 170 | modifier = Modifier 171 | .weight(1f) 172 | .fillMaxHeight() 173 | .background(tertiary) 174 | ) 175 | Box( 176 | modifier = Modifier 177 | .weight(1f) 178 | .fillMaxHeight() 179 | .background(secondary) 180 | ) 181 | } 182 | } 183 | content?.invoke(this) 184 | } 185 | } 186 | } 187 | 188 | fun Color.calculateSecondaryColor(): Int { 189 | val hct = Hct.fromInt(this.toArgb()) 190 | val hue = hct.hue 191 | val chroma = hct.chroma 192 | 193 | return TonalPalette.fromHueAndChroma(hue, chroma / 3.0).tone(80) 194 | } 195 | 196 | fun Color.calculateTertiaryColor(): Int { 197 | val hct = Hct.fromInt(this.toArgb()) 198 | val hue = hct.hue 199 | val chroma = hct.chroma 200 | 201 | return TonalPalette.fromHueAndChroma(hue + 60.0, chroma / 2.0).tone(80) 202 | } 203 | 204 | fun Color.calculateSurfaceColor(): Int { 205 | val hct = Hct.fromInt(this.toArgb()) 206 | val hue = hct.hue 207 | val chroma = hct.chroma 208 | 209 | return TonalPalette.fromHueAndChroma(hue, (chroma / 12.0).coerceAtMost(4.0)).tone(90) 210 | } 211 | 212 | 213 | @Composable 214 | fun getAppColorTuple( 215 | defaultColorTuple: ColorTuple, 216 | dynamicColor: Boolean, 217 | darkTheme: Boolean 218 | ): ColorTuple { 219 | val context = LocalContext.current 220 | return remember( 221 | LocalLifecycleOwner.current.lifecycle.observeAsState().value, 222 | dynamicColor, 223 | darkTheme, 224 | defaultColorTuple 225 | ) { 226 | derivedStateOf { 227 | var colorTuple: ColorTuple 228 | val wallpaperManager = WallpaperManager.getInstance(context) 229 | val wallColors = 230 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { 231 | wallpaperManager 232 | .getWallpaperColors(WallpaperManager.FLAG_SYSTEM) 233 | } else null 234 | 235 | when { 236 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 237 | if (darkTheme) { 238 | dynamicDarkColorScheme(context) 239 | } else { 240 | dynamicLightColorScheme(context) 241 | }.run { 242 | colorTuple = ColorTuple( 243 | primary = primary, 244 | secondary = secondary, 245 | tertiary = tertiary, 246 | surface = surface 247 | ) 248 | } 249 | } 250 | dynamicColor && wallColors != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> { 251 | colorTuple = ColorTuple( 252 | primary = Color(wallColors.primaryColor.toArgb()), 253 | secondary = wallColors.secondaryColor?.toArgb()?.let { Color(it) }, 254 | tertiary = wallColors.tertiaryColor?.toArgb()?.let { Color(it) } 255 | ) 256 | } 257 | dynamicColor && ActivityCompat.checkSelfPermission( 258 | context, 259 | Manifest.permission.READ_EXTERNAL_STORAGE 260 | ) == PackageManager.PERMISSION_GRANTED -> { 261 | colorTuple = ColorTuple( 262 | primary = (wallpaperManager.drawable as BitmapDrawable).bitmap.extractPrimaryColor() 263 | ) 264 | } 265 | else -> { 266 | colorTuple = defaultColorTuple 267 | } 268 | } 269 | colorTuple 270 | } 271 | }.value 272 | } 273 | 274 | @Composable 275 | fun Lifecycle.observeAsState(): State { 276 | val state = remember { mutableStateOf(Lifecycle.Event.ON_ANY) } 277 | DisposableEffect(this) { 278 | val observer = LifecycleEventObserver { _, event -> 279 | state.value = event 280 | } 281 | this@observeAsState.addObserver(observer) 282 | onDispose { 283 | this@observeAsState.removeObserver(observer) 284 | } 285 | } 286 | return state 287 | } 288 | 289 | /** 290 | * This function animates colors when current color scheme changes. 291 | * 292 | * @param animationSpec Animation that will be applied when theming option changes. 293 | * @return [ColorScheme] with animated colors. 294 | */ 295 | @Composable 296 | private fun ColorScheme.animateAllColors(animationSpec: AnimationSpec): ColorScheme { 297 | 298 | /** 299 | * Wraps color into [animateColorAsState]. 300 | * 301 | * @return Animated [Color]. 302 | */ 303 | @Composable 304 | fun Color.animateColor() = animateColorAsState(this, animationSpec).value 305 | 306 | return this.copy( 307 | primary = primary.animateColor(), 308 | onPrimary = onPrimary.animateColor(), 309 | primaryContainer = primaryContainer.animateColor(), 310 | onPrimaryContainer = onPrimaryContainer.animateColor(), 311 | inversePrimary = inversePrimary.animateColor(), 312 | secondary = secondary.animateColor(), 313 | onSecondary = onSecondary.animateColor(), 314 | secondaryContainer = secondaryContainer.animateColor(), 315 | onSecondaryContainer = onSecondaryContainer.animateColor(), 316 | tertiary = tertiary.animateColor(), 317 | onTertiary = onTertiary.animateColor(), 318 | tertiaryContainer = tertiaryContainer.animateColor(), 319 | onTertiaryContainer = onTertiaryContainer.animateColor(), 320 | background = background.animateColor(), 321 | onBackground = onBackground.animateColor(), 322 | surface = surface.animateColor(), 323 | onSurface = onSurface.animateColor(), 324 | surfaceVariant = surfaceVariant.animateColor(), 325 | onSurfaceVariant = onSurfaceVariant.animateColor(), 326 | surfaceTint = surfaceTint.animateColor(), 327 | inverseSurface = inverseSurface.animateColor(), 328 | inverseOnSurface = inverseOnSurface.animateColor(), 329 | error = error.animateColor(), 330 | onError = onError.animateColor(), 331 | errorContainer = errorContainer.animateColor(), 332 | onErrorContainer = onErrorContainer.animateColor(), 333 | outline = outline.animateColor(), 334 | ) 335 | } 336 | 337 | 338 | fun Bitmap.extractPrimaryColor(default: Int = 0, blendWithVibrant: Boolean = true): Color { 339 | fun Int.blend( 340 | color: Int, 341 | @FloatRange(from = 0.0, to = 1.0) fraction: Float = 0.5f 342 | ): Int = ColorUtils.blendARGB(this, color, fraction) 343 | 344 | val palette = Palette 345 | .from(this) 346 | .generate() 347 | 348 | return Color( 349 | palette.getDominantColor(default).run { 350 | if (blendWithVibrant) blend(palette.getVibrantColor(default)) 351 | else this 352 | } 353 | ) 354 | } 355 | 356 | /** Class that represents App color scheme based on three main colors 357 | * @param primary primary color 358 | * @param secondary secondary color 359 | * @param tertiary tertiary color 360 | */ 361 | data class ColorTuple( 362 | val primary: Color, 363 | val secondary: Color? = null, 364 | val tertiary: Color? = null, 365 | val surface: Color? = null 366 | ) 367 | 368 | /** 369 | * Creates and remember [DynamicThemeState] instance 370 | * */ 371 | @Composable 372 | fun rememberDynamicThemeState( 373 | initialColorTuple: ColorTuple = ColorTuple( 374 | primary = MaterialTheme.colorScheme.primary, 375 | secondary = MaterialTheme.colorScheme.secondary, 376 | tertiary = MaterialTheme.colorScheme.tertiary, 377 | surface = MaterialTheme.colorScheme.surface 378 | ) 379 | ): DynamicThemeState { 380 | return remember { 381 | DynamicThemeState(initialColorTuple) 382 | } 383 | } 384 | 385 | @Stable 386 | class DynamicThemeState( 387 | initialColorTuple: ColorTuple 388 | ) { 389 | val colorTuple: MutableState = mutableStateOf(initialColorTuple) 390 | 391 | fun updateColor(color: Color) { 392 | colorTuple.value = ColorTuple(primary = color, secondary = null, tertiary = null) 393 | } 394 | 395 | fun updateColorTuple(newColorTuple: ColorTuple) { 396 | colorTuple.value = newColorTuple 397 | } 398 | 399 | fun updateColorByImage(bitmap: Bitmap) { 400 | updateColor(bitmap.extractPrimaryColor()) 401 | } 402 | } 403 | 404 | @Composable 405 | fun rememberColorScheme( 406 | isDarkTheme: Boolean, 407 | amoledMode: Boolean, 408 | colorTuple: ColorTuple 409 | ): ColorScheme { 410 | return remember(colorTuple, isDarkTheme, amoledMode) { 411 | if (isDarkTheme) { 412 | Scheme.darkContent(colorTuple.primary.toArgb()).toDarkThemeColorScheme(colorTuple) 413 | } else { 414 | Scheme.lightContent(colorTuple.primary.toArgb()).toLightThemeColorScheme(colorTuple) 415 | }.let { 416 | if (amoledMode && isDarkTheme) { 417 | it.copy(background = Color.Black, surface = Color.Black) 418 | } else it 419 | }.run { 420 | copy( 421 | outlineVariant = onSecondaryContainer 422 | .copy(alpha = 0.2f) 423 | .compositeOver(surfaceColorAtElevation(6.dp)) 424 | ) 425 | } 426 | } 427 | } 428 | 429 | private fun Scheme.toDarkThemeColorScheme( 430 | colorTuple: ColorTuple 431 | ): ColorScheme { 432 | val hct = Hct.fromInt(colorTuple.primary.toArgb()) 433 | val hue = hct.hue 434 | val chroma = hct.chroma 435 | 436 | val a2 = colorTuple.secondary?.toArgb().let { 437 | if (it != null) { 438 | TonalPalette.fromInt(it) 439 | } else { 440 | TonalPalette.fromHueAndChroma(hue, chroma / 3.0) 441 | } 442 | } 443 | 444 | val a3 = colorTuple.tertiary?.toArgb().let { 445 | if (it != null) { 446 | TonalPalette.fromInt(it) 447 | } else { 448 | TonalPalette.fromHueAndChroma(hue + 60.0, chroma / 2.0) 449 | } 450 | } 451 | 452 | val n1 = colorTuple.surface?.toArgb().let { 453 | if (it != null) { 454 | TonalPalette.fromInt(it) 455 | } else { 456 | TonalPalette.fromHueAndChroma(hue, (chroma / 12.0).coerceAtMost(4.0)) 457 | } 458 | } 459 | 460 | val n2 = TonalPalette.fromInt(n1.tone(90)) 461 | 462 | return darkColorScheme( 463 | primary = Color(primary), 464 | onPrimary = Color(onPrimary), 465 | primaryContainer = Color(primaryContainer), 466 | onPrimaryContainer = Color(onPrimaryContainer), 467 | inversePrimary = Color(inversePrimary), 468 | secondary = Color(a2.tone(80)), 469 | onSecondary = Color(a2.tone(20)), 470 | secondaryContainer = Color(a2.tone(30)), 471 | onSecondaryContainer = Color(a2.tone(90)), 472 | tertiary = Color(a3.tone(80)), 473 | onTertiary = Color(a3.tone(20)), 474 | tertiaryContainer = Color(a3.tone(30)), 475 | onTertiaryContainer = Color(a3.tone(90)), 476 | background = Color(n1.tone(10)), 477 | onBackground = Color(n1.tone(90)), 478 | surface = Color(n1.tone(10)), 479 | onSurface = Color(n1.tone(90)), 480 | surfaceVariant = Color(n2.tone(30)), 481 | onSurfaceVariant = Color(n2.tone(80)), 482 | outline = Color(n2.tone(60)), 483 | outlineVariant = Color(n2.tone(30)), 484 | inverseSurface = Color(n1.tone(90)), 485 | inverseOnSurface = Color(n1.tone(20)), 486 | error = Color(error), 487 | onError = Color(onError), 488 | errorContainer = Color(errorContainer), 489 | onErrorContainer = Color(onErrorContainer), 490 | scrim = Color(scrim), 491 | ) 492 | } 493 | 494 | private fun Scheme.toLightThemeColorScheme( 495 | colorTuple: ColorTuple 496 | ): ColorScheme { 497 | val hct = Hct.fromInt(colorTuple.primary.toArgb()) 498 | val hue = hct.hue 499 | val chroma = hct.chroma 500 | 501 | val a2 = colorTuple.secondary?.toArgb().let { 502 | if (it != null) { 503 | TonalPalette.fromInt(it) 504 | } else { 505 | TonalPalette.fromHueAndChroma(hue, chroma / 3.0) 506 | } 507 | } 508 | val a3 = colorTuple.tertiary?.toArgb().let { 509 | if (it != null) { 510 | TonalPalette.fromInt(it) 511 | } else { 512 | TonalPalette.fromHueAndChroma(hue + 60.0, chroma / 2.0) 513 | } 514 | } 515 | 516 | val n1 = colorTuple.surface?.toArgb().let { 517 | if (it != null) { 518 | TonalPalette.fromInt(it) 519 | } else { 520 | TonalPalette.fromHueAndChroma(hue, (chroma / 12.0).coerceAtMost(4.0)) 521 | } 522 | } 523 | 524 | val n2 = TonalPalette.fromInt(n1.tone(90)) 525 | 526 | return lightColorScheme( 527 | primary = Color(primary), 528 | onPrimary = Color(onPrimary), 529 | primaryContainer = Color(primaryContainer), 530 | onPrimaryContainer = Color(onPrimaryContainer), 531 | inversePrimary = Color(inversePrimary), 532 | secondary = Color(a2.tone(40)), 533 | onSecondary = Color(a2.tone(100)), 534 | secondaryContainer = Color(a2.tone(90)), 535 | onSecondaryContainer = Color(a2.tone(10)), 536 | tertiary = Color(a3.tone(40)), 537 | onTertiary = Color(a3.tone(100)), 538 | tertiaryContainer = Color(a3.tone(90)), 539 | onTertiaryContainer = Color(a3.tone(10)), 540 | background = Color(n1.tone(99)), 541 | onBackground = Color(n1.tone(10)), 542 | surface = Color(n1.tone(99)), 543 | onSurface = Color(n1.tone(10)), 544 | surfaceVariant = Color(n2.tone(90)), 545 | onSurfaceVariant = Color(n2.tone(30)), 546 | outline = Color(n2.tone(50)), 547 | outlineVariant = Color(n2.tone(80)), 548 | inverseSurface = Color(n1.tone(20)), 549 | inverseOnSurface = Color(n1.tone(95)), 550 | surfaceTint = Color(primary), 551 | error = Color(error), 552 | onError = Color(onError), 553 | errorContainer = Color(errorContainer), 554 | onErrorContainer = Color(onErrorContainer), 555 | scrim = Color(scrim), 556 | ) 557 | } 558 | 559 | val LocalDynamicThemeState: ProvidableCompositionLocal = 560 | compositionLocalOf { error("DynamicThemeState not present") } --------------------------------------------------------------------------------