diff --git a/.github/ISSUE_TEMPLATE/theme-adapter-appcompat-bug-report.md b/.github/ISSUE_TEMPLATE/theme-adapter-appcompat-bug-report.md new file mode 100644 index 000000000..ec0f51bd3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/theme-adapter-appcompat-bug-report.md @@ -0,0 +1,16 @@ +--- +name: AppCompat Theme Adapter bug report +about: Create a report to help us improve +title: "[AppCompat Theme Adapter]" +labels: '' +assignees: ricknout + +--- + +**Description** + +**Steps to reproduce** + +**Expected behavior** + +**Additional context** diff --git a/.github/ISSUE_TEMPLATE/theme-adapter-core-bug-report .md b/.github/ISSUE_TEMPLATE/theme-adapter-core-bug-report .md new file mode 100644 index 000000000..94c8a00a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/theme-adapter-core-bug-report .md @@ -0,0 +1,16 @@ +--- +name: Core Theme Adapter bug report +about: Create a report to help us improve +title: "[Core Theme Adapter]" +labels: '' +assignees: ricknout + +--- + +**Description** + +**Steps to reproduce** + +**Expected behavior** + +**Additional context** diff --git a/.github/ISSUE_TEMPLATE/theme-adapter-material-bug-report.md b/.github/ISSUE_TEMPLATE/theme-adapter-material-bug-report.md new file mode 100644 index 000000000..52de208c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/theme-adapter-material-bug-report.md @@ -0,0 +1,16 @@ +--- +name: Material Theme Adapter bug report +about: Create a report to help us improve +title: "[Material Theme Adapter]" +labels: '' +assignees: ricknout + +--- + +**Description** + +**Steps to reproduce** + +**Expected behavior** + +**Additional context** diff --git a/.github/ISSUE_TEMPLATE/theme-adapter-material3-bug-report.md b/.github/ISSUE_TEMPLATE/theme-adapter-material3-bug-report.md new file mode 100644 index 000000000..3d13aaa00 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/theme-adapter-material3-bug-report.md @@ -0,0 +1,16 @@ +--- +name: Material 3 Theme Adapter bug report +about: Create a report to help us improve +title: "[Material 3 Theme Adapter]" +labels: '' +assignees: ricknout + +--- + +**Description** + +**Steps to reproduce** + +**Expected behavior** + +**Additional context** diff --git a/ASSETS_LICENSE.txt b/ASSETS_LICENSE.txt new file mode 100644 index 000000000..e7fc95866 --- /dev/null +++ b/ASSETS_LICENSE.txt @@ -0,0 +1,88 @@ +All font files are licensed under the SIL OPEN FONT LICENSE license. All other files are licensed under the Apache 2 license. + + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 786be89da..cfbe6b124 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,14 @@ For stable versions of Compose, we use the latest *stable* version of the Compos ### 🍫 [System UI Controller](./systemuicontroller/) A library that provides easy-to-use utilities for recoloring the Android system bars from Jetpack Compose. -### 🎨 [AppCompat Theme Adapter](./appcompat-theme/) -A library that enables the reuse of [AppCompat][appcompat] XML themes for theming in Jetpack Compose. +### 🎨 [AppCompat Theme Adapter](./themeadapter-appcompat/) +A library that enables the reuse of [AppCompat][appcompat] XML themes, for theming in Jetpack Compose. + +### 🎨 [Material Theme Adapter](./themeadapter-material/) +A library that enables the reuse of [MDC-Android][mdc] Material 2 XML themes, for theming in Jetpack Compose. + +### 🎨 [Material 3 Theme Adapter](./themeadapter-material3/) +A library that enables the reuse of [MDC-Android][mdc] Material 3 XML themes, for theming in Jetpack Compose. ### 📖 [Pager](./pager/) A library that provides utilities for building paginated layouts in Jetpack Compose, similar to Android's [ViewPager][viewpager]. @@ -73,6 +79,9 @@ See our [Migration Guide](https://google.github.io/accompanist/insets/) for migr ### ⬇️ [Swipe to Refresh](./swiperefresh/) (Deprecated) See our [Migration Guide](https://google.github.io/accompanist/swiperefresh/) for migrating to PullRefresh in Compose Material. +### 🎨 [AppCompat Theme Adapter](./appcompat-theme/) (Deprecated) +See our [Migration Guide](https://google.github.io/accompanist/appcompat-theme/) for migrating to the new artifact in Accompanist. + --- ## Future? @@ -118,7 +127,7 @@ limitations under the License. [appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat [compose]: https://developer.android.com/jetpack/compose [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/ -[mdc]: https://material.io/develop/android/ +[mdc]: https://github.com/material-components/material-components-android [windowinsets]: https://developer.android.com/reference/kotlin/android/view/WindowInsets [viewpager]: https://developer.android.com/reference/kotlin/androidx/viewpager/widget/ViewPager [runtimepermissions]: https://developer.android.com/guide/topics/permissions/overview diff --git a/appcompat-theme/README.md b/appcompat-theme/README.md index b3aab95c6..b629013ef 100644 --- a/appcompat-theme/README.md +++ b/appcompat-theme/README.md @@ -2,20 +2,7 @@ [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-appcompat-theme)](https://search.maven.org/search?q=g:com.google.accompanist) -AppCompat Compose Theme Adapter enables reuse of [AppCompat][appcompat] XML themes, for theming in [Jetpack Compose][compose]. - -## Usage -This library attempts to bridge the gap between [AppCompat][appcompat] XML themes, and themes in [Jetpack Compose][compose], - allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: - -``` kotlin -AppCompatTheme { - // MaterialTheme.colors, MaterialTheme.shapes, MaterialTheme.typography - // will now contain copies of the context's theme -} -``` - -For more information, visit the documentation: https://google.github.io/accompanist/appcompat-theme +> :warning: This library has been deprecated in favor of the new `themeadapter-appcompat` artifact. Please see our [Migration Guide](https://google.github.io/accompanist/appcompat-theme/) for how to migrate. ## Download @@ -31,7 +18,4 @@ dependencies { Snapshots of the development version are available in Sonatype's `snapshots` [repository][snap]. These are updated on every commit. - [appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat - [compose]: https://developer.android.com/jetpack/compose - [appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-appcompat-theme/ \ No newline at end of file diff --git a/appcompat-theme/api/current.api b/appcompat-theme/api/current.api index 25b5aed03..190a52338 100644 --- a/appcompat-theme/api/current.api +++ b/appcompat-theme/api/current.api @@ -2,20 +2,20 @@ package com.google.accompanist.appcompattheme { public final class AppCompatTheme { - method @androidx.compose.runtime.Composable public static void AppCompatTheme(optional android.content.Context context, optional boolean readColors, optional boolean readTypography, optional androidx.compose.material.Shapes shapes, kotlin.jvm.functions.Function0 content); - method public static com.google.accompanist.appcompattheme.ThemeParameters createAppCompatTheme(android.content.Context, optional boolean readColors, optional boolean readTypography); + method @Deprecated @androidx.compose.runtime.Composable public static void AppCompatTheme(optional android.content.Context context, optional boolean readColors, optional boolean readTypography, optional androidx.compose.material.Shapes shapes, kotlin.jvm.functions.Function0 content); + method @Deprecated public static com.google.accompanist.appcompattheme.ThemeParameters createAppCompatTheme(android.content.Context, optional boolean readColors, optional boolean readTypography); } public final class ColorKt { } - public final class ThemeParameters { - ctor public ThemeParameters(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography); - method public androidx.compose.material.Colors? component1(); - method public androidx.compose.material.Typography? component2(); - method public com.google.accompanist.appcompattheme.ThemeParameters copy(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography); - method public androidx.compose.material.Colors? getColors(); - method public androidx.compose.material.Typography? getTypography(); + @Deprecated public final class ThemeParameters { + ctor @Deprecated public ThemeParameters(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography); + method @Deprecated public androidx.compose.material.Colors? component1(); + method @Deprecated public androidx.compose.material.Typography? component2(); + method @Deprecated public com.google.accompanist.appcompattheme.ThemeParameters copy(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography); + method @Deprecated public androidx.compose.material.Colors? getColors(); + method @Deprecated public androidx.compose.material.Typography? getTypography(); property public final androidx.compose.material.Colors? colors; property public final androidx.compose.material.Typography? typography; } diff --git a/appcompat-theme/src/androidTest/kotlin/com/google/accompanist/appcompattheme/InstrumentedAppCompatThemeTest.kt b/appcompat-theme/src/androidTest/kotlin/com/google/accompanist/appcompattheme/InstrumentedAppCompatThemeTest.kt index 6e03b737d..ce7677ab7 100644 --- a/appcompat-theme/src/androidTest/kotlin/com/google/accompanist/appcompattheme/InstrumentedAppCompatThemeTest.kt +++ b/appcompat-theme/src/androidTest/kotlin/com/google/accompanist/appcompattheme/InstrumentedAppCompatThemeTest.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DEPRECATION") + package com.google.accompanist.appcompattheme import androidx.appcompat.app.AppCompatActivity diff --git a/appcompat-theme/src/main/java/com/google/accompanist/appcompattheme/AppCompatTheme.kt b/appcompat-theme/src/main/java/com/google/accompanist/appcompattheme/AppCompatTheme.kt index a853efecd..8cbe1da87 100644 --- a/appcompat-theme/src/main/java/com/google/accompanist/appcompattheme/AppCompatTheme.kt +++ b/appcompat-theme/src/main/java/com/google/accompanist/appcompattheme/AppCompatTheme.kt @@ -15,6 +15,7 @@ */ @file:JvmName("AppCompatTheme") +@file:Suppress("DEPRECATION") package com.google.accompanist.appcompattheme @@ -96,6 +97,13 @@ import androidx.core.content.res.use * @param readTypography whether to read the font family from [context]'s theme. * @param shapes A set of shapes to be used by the components in this hierarchy. */ +@Deprecated( + """ + accompanist/appcompat-theme is deprecated. + The API has moved to accompanist/themeadapter/appcompat. + For more migration information, please visit https://google.github.io/accompanist/appcompat-theme/#migration + """ +) @Composable fun AppCompatTheme( context: Context = LocalContext.current, @@ -129,6 +137,13 @@ fun AppCompatTheme( * This class contains some of the individual components of a [MaterialTheme]: * [Colors] & [Typography]. */ +@Deprecated( + """ + accompanist/appcompat-theme is deprecated. + The API has moved to accompanist/themeadapter/appcompat. + For more migration information, please visit https://google.github.io/accompanist/appcompat-theme/#migration + """ +) data class ThemeParameters( val colors: Colors?, val typography: Typography? @@ -148,6 +163,13 @@ data class ThemeParameters( * * @return [ThemeParameters] instance containing the resulting [Colors] and [Typography] */ +@Deprecated( + """ + accompanist/appcompat-theme is deprecated. + The API has moved to accompanist/themeadapter/appcompat. + For more migration information, please visit https://google.github.io/accompanist/appcompat-theme/#migration + """ +) fun Context.createAppCompatTheme( readColors: Boolean = true, readTypography: Boolean = true diff --git a/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/BaseAppCompatThemeTest.kt b/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/BaseAppCompatThemeTest.kt index deef0b7a4..ba403d567 100644 --- a/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/BaseAppCompatThemeTest.kt +++ b/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/BaseAppCompatThemeTest.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DEPRECATION") + package com.google.accompanist.appcompattheme import android.view.ContextThemeWrapper diff --git a/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/NotAppCompatThemeTest.kt b/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/NotAppCompatThemeTest.kt index b5aa3e9e5..09dfe90ad 100644 --- a/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/NotAppCompatThemeTest.kt +++ b/appcompat-theme/src/sharedTest/kotlin/com/google/accompanist/appcompattheme/NotAppCompatThemeTest.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DEPRECATION") + package com.google.accompanist.appcompattheme import androidx.compose.ui.test.junit4.createAndroidComposeRule diff --git a/docs/appcompat-theme.md b/docs/appcompat-theme.md index c689f55e3..f0dacb8ae 100644 --- a/docs/appcompat-theme.md +++ b/docs/appcompat-theme.md @@ -2,6 +2,21 @@ [![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-appcompat-theme)](https://search.maven.org/search?q=g:com.google.accompanist) +!!! warning +**This library is deprecated in favor of the new [`themeadapter-appcompat`][themeadapterappcompatlib] artifact. The migration guide and original documentation is below. + +## Migration + +Accompanist AppCompat Theme Adapter has moved from the [`appcompat-theme`][appcompatthemelib] artifact to the [`themeadapter-appcompat`][themeadapterappcompatlib] artifact. +The implementation is identical but the dependency and import package have changed. + +### Migration steps + +1. Change the dependency from `com.google.accompanist:accompanist-appcompat-theme:` to `com.google.accompanist:accompanist-themeadapter-appcompat:` +2. Change any `com.google.accompanist.appcompattheme.*` imports to `com.google.accompanist.themeadapter.appcompat.*` + +## Original Docs + A library that enables reuse of [AppCompat][appcompat] XML themes for theming in [Jetpack Compose][compose]. The basis of theming in [Jetpack Compose][compose] is the [`MaterialTheme`][materialtheme] composable, where you provide [`Colors`][colors], [`Shapes`][shapes] and [`Typography`][typography] instances containing your styling parameters: @@ -167,6 +182,8 @@ See the License for the specific language governing permissions and limitations under the License. ``` + [appcompatthemelib]: ../appcompat-theme + [themeadapterappcompatlib]: ../themeadapter-appcompat [compose]: https://developer.android.com/jetpack/compose [appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat [appcompattheme]: ../api/appcompat-theme/appcompat-theme/com.google.accompanist.appcompattheme/-app-compat-theme.html diff --git a/docs/themeadapter-appcompat.md b/docs/themeadapter-appcompat.md new file mode 100644 index 000000000..6b1ef3df2 --- /dev/null +++ b/docs/themeadapter-appcompat.md @@ -0,0 +1,179 @@ +# AppCompat Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-appcompat)](https://search.maven.org/search?q=g:com.google.accompanist) + +A library that enables the reuse of [AppCompat][appcompat] XML themes, for theming in [Jetpack Compose][compose]. + +The basis of theming in [Jetpack Compose][compose] is the [`MaterialTheme`][materialtheme] composable, where you provide [`Colors`][colors], [`Shapes`][shapes] and [`Typography`][typography] instances containing your styling parameters: + +``` kotlin +MaterialTheme( + typography = type, + colors = colors, + shapes = shapes +) { + // Surface, Scaffold, etc +} +``` + +[AppCompat][appcompat] XML themes allow for similar but coarser theming via XML theme attributes, like so: + +``` xml + +``` + +This library attempts to bridge the gap between [AppCompat][appcompat] XML themes, and themes in [Jetpack Compose][compose], allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: + +``` kotlin +AppCompatTheme { + // MaterialTheme.colors, MaterialTheme.shapes, MaterialTheme.typography + // will now contain copies of the context's theme +} +``` + +This is especially handy when you're migrating an existing app, a fragment (or other UI container) at a time. + +!!! caution + If you are using [Material Design Components][mdc] in your app, you should use the + [Material Theme Adapter](https://github.com/google/accompanist/tree/main/themeadapter-material) or + [Material 3 Theme Adapter](https://github.com/google/accompanist/tree/main/themeadapter-material3) + instead, as they allow much finer-grained reading of your theme. + + +### Customizing the theme + +The [`AppCompatTheme()`][appcompattheme] function will automatically read the host context's AppCompat theme and pass them to [`MaterialTheme`][materialtheme] on your behalf, but if you want to customize the generated values, you can do so via the [`createAppCompatTheme()`][createappcompattheme] function: + +``` kotlin +val context = LocalContext.current +var (colors, type) = context.createAppCompatTheme() + +// Modify colors or type as required. Then pass them +// through to MaterialTheme... + +MaterialTheme( + colors = colors, + typography = type +) { + // rest of layout +} +``` + + + +## Generated theme + +Synthesizing a material theme from a `Theme.AppCompat` theme is not perfect, since `Theme.AppCompat` +does not expose the same level of customization as is available in material theming. +Going through the pillars of material theming: + +### Colors + +AppCompat has a limited set of top-level color attributes, which means that [`AppCompatTheme()`][appcompattheme] +has to generate/select alternative colors in certain situations. The mapping is currently: + +| MaterialTheme color | AppCompat attribute | +|---------------------|-------------------------------------------------------| +| primary | `colorPrimary` | +| primaryVariant | `colorPrimaryDark` | +| onPrimary | Calculated black/white | +| secondary | `colorAccent` | +| secondaryVariant | `colorAccent` | +| onSecondary | Calculated black/white | +| surface | Default | +| onSurface | `android:textColorPrimary`, else calculated black/white | +| background | `android:colorBackground` | +| onBackground | `android:textColorPrimary`, else calculated black/white | +| error | `colorError` | +| onError | Calculated black/white | + +Where the table says "calculated black/white", this means either black/white, depending on +which provides the greatest contrast against the corresponding background color. + +### Typography + +AppCompat does not provide any semantic text appearances (such as headline6, body1, etc), and +instead relies on text appearances for specific widgets or use cases. As such, the only thing +we read from an AppCompat theme is the default `app:fontFamily` or `android:fontFamily`. +For example: + +``` xml + +``` + +Compose does not currently support downloadable fonts, so any font referenced from the theme +should from your resources. See [here](https://developer.android.com/guide/topics/resources/font-resource) +for more information. + +### Shape + +AppCompat has no concept of shape theming, therefore we use the default value from +[`MaterialTheme.shapes`][shapes]. If you wish to provide custom values, use the `shapes` parameter on `AppCompatTheme`. + +## Limitations + +There are some known limitations with the implementation at the moment: + +* This relies on your `Activity`/`Context` theme extending one of the `Theme.AppCompat` themes. +* Variable fonts are not supported in Compose yet, meaning that the value of `android:fontVariationSettings` are currently ignored. +* You can modify the resulting `MaterialTheme` in Compose as required, but this _only_ works in Compose. Any changes you make will not be reflected in the Activity theme. + +--- + +## Usage + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-appcompat)](https://search.maven.org/search?q=g:com.google.accompanist) + +``` groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:" +} +``` + +### Library Snapshots + +Snapshots of the current development version of this library are available, which track the latest commit. See [here](../using-snapshot-version) for more information on how to use them. + +--- + +## Contributions + +Please contribute! We will gladly review any pull requests. +Make sure to read the [Contributing](../contributing) page first though. + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose +[mdc]: https://github.com/material-components/material-components-android +[appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat +[appcompattheme]: ../api/themeadapter-appcompat/com.google.accompanist.themeadapter.appcompat/-app-compat-theme.html +[createappcompattheme]: ../api/themeadapter-appcompat/com.google.accompanist.themeadapter.appcompat/create-app-compat-theme.html +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material/MaterialTheme +[colors]: https://developer.android.com/reference/kotlin/androidx/compose/material/Colors +[typography]: https://developer.android.com/reference/kotlin/androidx/compose/material/Typography +[shapes]: https://developer.android.com/reference/kotlin/androidx/compose/material/Shapes \ No newline at end of file diff --git a/docs/themeadapter-core.md b/docs/themeadapter-core.md new file mode 100644 index 000000000..6bccc1ae1 --- /dev/null +++ b/docs/themeadapter-core.md @@ -0,0 +1,55 @@ +# Core Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist--themeadapter-core)](https://search.maven.org/search?q=g:com.google.accompanist) + +A library that includes common utilities that enable the reuse of XML themes, for theming in [Jetpack Compose][compose]. + +See the [API][api] for more details. + +--- + +## Usage + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-core)](https://search.maven.org/search?q=g:com.google.accompanist) + +``` groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-core:" +} +``` + +### Library Snapshots + +Snapshots of the current development version of this library are available, which track the latest commit. See [here](../using-snapshot-version) for more information on how to use them. + +--- + +## Contributions + +Please contribute! We will gladly review any pull requests. +Make sure to read the [Contributing](../contributing) page first though. + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose +[api]: ../api/themeadapter-core \ No newline at end of file diff --git a/docs/themeadapter-material.md b/docs/themeadapter-material.md new file mode 100644 index 000000000..87305bd79 --- /dev/null +++ b/docs/themeadapter-material.md @@ -0,0 +1,141 @@ +# Material Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-material)](https://search.maven.org/search?q=g:com.google.accompanist) + +A library that enables the reuse of [MDC-Android][mdc] Material 2 XML themes, for theming in [Jetpack Compose][compose]. + +![Material Theme Adapter header](material-header.png) + +The basis of Material Design 2 theming in [Jetpack Compose][compose] is the [`MaterialTheme`][materialtheme] composable, where you provide [`Colors`][colors], [`Typography`][typography] and [`Shapes`][shapes] instances containing your styling parameters: + +``` kotlin +MaterialTheme( + colors = colors, + typography = type, + shapes = shapes +) { + // M2 Surface, Scaffold, etc. +} +``` + +[Material Components for Android][mdc] themes allow for similar theming for views via XML theme attributes, like so: + +``` xml + +``` + +This library attempts to bridge the gap between [Material Components for Android][mdc] M2 XML themes, and themes in [Jetpack Compose][compose], allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: + + +``` kotlin +MdcTheme { + // MaterialTheme.colors, MaterialTheme.typography, MaterialTheme.shapes + // will now contain copies of the Context's theme +} +``` + +This is especially handy when you're migrating an existing app, a `Fragment` (or other UI container) at a time. + +!!! caution +If you are using an AppCompat (i.e. non-MDC) theme in your app, you should use +[AppCompat Theme Adapter](https://github.com/google/accompanist/tree/main/themeadapter-appcompat) +instead, as it attempts to bridge the gap between [AppCompat][appcompat] XML themes, and M2 themes in [Jetpack Compose][compose]. + +### Customizing the M2 theme + +The [`MdcTheme()`][mdctheme] function will automatically read the host `Context`'s MDC theme and pass them to [`MaterialTheme`][materialtheme] on your behalf, but if you want to customize the generated values, you can do so via the [`createMdcTheme()`][createmdctheme] function: + +``` kotlin +val context = LocalContext.current +val layoutDirection = LocalLayoutDirection.current +var (colors, typography, shapes) = createMdcTheme( + context = context, + layoutDirection = layoutDirection +) + +// Modify colors, typography or shapes as required. +// Then pass them through to MaterialTheme... + +MaterialTheme( + colors = colors, + typography = type, + shapes = shapes +) { + // Rest of M2 layout +} +``` + +### Limitations + +There are some known limitations with the implementation at the moment: + +* This relies on your `Activity`/`Context` theme extending one of the `Theme.MaterialComponents` themes. +* Text colors are not read from the text appearances by default. You can enable it via the `setTextColors` function parameter. +* Variable fonts are not supported in Compose yet, meaning that the value of `android:fontVariationSettings` are currently ignored. +* MDC `ShapeAppearances` allow setting of different corner families (cut, rounded) on each corner, whereas Compose's [Shapes][shapes] allows only a single corner family for the entire shape. Therefore only the `app:cornerFamily` attribute is read, others (`app:cornerFamilyTopLeft`, etc) are ignored. +* You can modify the resulting `MaterialTheme` in Compose as required, but this _only_ works in Compose. Any changes you make will not be reflected in the `Activity` theme. + +--- + +## Usage + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-material)](https://search.maven.org/search?q=g:com.google.accompanist) + +``` groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-material:" +} +``` + +### Library Snapshots + +Snapshots of the current development version of this library are available, which track the latest commit. See [here](../using-snapshot-version) for more information on how to use them. + +--- + +## Contributions + +Please contribute! We will gladly review any pull requests. +Make sure to read the [Contributing](../contributing) page first though. + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose +[mdc]: https://github.com/material-components/material-components-android +[mdctheme]: ../api/themeadapter-material/com.google.accompanist.themeadapter.material/-mdc-theme.html +[createmdctheme]: ../api/themeadapter-material/com.google.accompanist.themeadapter.material/create-mdc-theme.html +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material/MaterialTheme +[colors]: https://developer.android.com/reference/kotlin/androidx/compose/material/Colors +[typography]: https://developer.android.com/reference/kotlin/androidx/compose/material/Typography +[shapes]: https://developer.android.com/reference/kotlin/androidx/compose/material/Shapes \ No newline at end of file diff --git a/docs/themeadapter-material3.md b/docs/themeadapter-material3.md new file mode 100644 index 000000000..6d4aeb2aa --- /dev/null +++ b/docs/themeadapter-material3.md @@ -0,0 +1,135 @@ +# Material 3 Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-material3)](https://search.maven.org/search?q=g:com.google.accompanist) + +A library that enables the reuse of [MDC-Android][mdc] Material 3 XML themes, for theming in [Jetpack Compose][compose]. + +![Material 3 Theme Adapter header](material3-header.png) + +The basis of Material Design 3 theming in [Jetpack Compose][compose] is the [`MaterialTheme`][materialtheme] composable, where you provide [`ColorScheme`][colorscheme], [`Typography`][typography] and [`Shapes`][shapes] instances containing your styling parameters: + +``` kotlin +MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = shapes +) { + // M3 Surface, Scaffold, etc. +} +``` + +[Material Components for Android][mdc] themes allow for similar theming for views via XML theme attributes, like so: + +``` xml + +``` + +This library attempts to bridge the gap between [Material Components for Android][mdc] M3 XML themes, and themes in [Jetpack Compose][compose], allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: + + +``` kotlin +Mdc3Theme { + // MaterialTheme.colorScheme, MaterialTheme.typography, MaterialTheme.shapes + // will now contain copies of the Context's theme +} +``` + +This is especially handy when you're migrating an existing app, a `Fragment` (or other UI container) at a time. + +### Customizing the M3 theme + +The [`Mdc3Theme()`][mdc3theme] function will automatically read the host `Context`'s MDC theme and pass them to [`MaterialTheme`][materialtheme] on your behalf, but if you want to customize the generated values, you can do so via the [`createMdc3Theme()`][createmdc3theme] function: + +``` kotlin +val context = LocalContext.current +var (colorScheme, typography, shapes) = createMdc3Theme( + context = context +) + +// Modify colorScheme, typography or shapes as required. +// Then pass them through to MaterialTheme... + +MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = shapes +) { + // Rest of M3 layout +} +``` + +### Limitations + +There are some known limitations with the implementation at the moment: + +* This relies on your `Activity`/`Context` theme extending one of the `Theme.Material3` themes. +* Text colors are not read from the text appearances by default. You can enable it via the `setTextColors` function parameter. +* Variable fonts are not supported in Compose yet, meaning that the value of `android:fontVariationSettings` are currently ignored. +* MDC `ShapeAppearances` allow setting of different corner families (cut, rounded) on each corner, whereas Compose's [Shapes][shapes] allows only a single corner family for the entire shape. Therefore only the `app:cornerFamily` attribute is read, others (`app:cornerFamilyTopLeft`, etc) are ignored. +* You can modify the resulting `MaterialTheme` in Compose as required, but this _only_ works in Compose. Any changes you make will not be reflected in the `Activity` theme. + +--- + +## Usage + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-material3)](https://search.maven.org/search?q=g:com.google.accompanist) + +``` groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-material3:" +} +``` + +### Library Snapshots + +Snapshots of the current development version of this library are available, which track the latest commit. See [here](../using-snapshot-version) for more information on how to use them. + +--- + +## Contributions + +Please contribute! We will gladly review any pull requests. +Make sure to read the [Contributing](../contributing) page first though. + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose +[mdc]: https://github.com/material-components/material-components-android +[mdc3theme]: ../api/themeadapter-material3/com.google.accompanist.themeadapter.material3/-mdc-3-theme.html +[createmdc3theme]: ../api/themeadapter-material3/com.google.accompanist.themeadapter.material3/create-mdc-3-theme.html +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material3/MaterialTheme +[colorscheme]: https://developer.android.com/reference/kotlin/androidx/compose/material3/ColorScheme +[typography]: https://developer.android.com/reference/kotlin/androidx/compose/material3/Typography +[shapes]: https://developer.android.com/reference/kotlin/androidx/compose/material3/Shapes \ No newline at end of file diff --git a/docs/themeadapter/material-header.png b/docs/themeadapter/material-header.png new file mode 100644 index 000000000..c0e8d8f2d Binary files /dev/null and b/docs/themeadapter/material-header.png differ diff --git a/docs/themeadapter/material3-header.png b/docs/themeadapter/material3-header.png new file mode 100644 index 000000000..1c74aa9cc Binary files /dev/null and b/docs/themeadapter/material3-header.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc7831f89..40367d846 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ compose = "1.3.1" composeCompiler = "1.3.2" +composeMaterial3 = "1.0.1" composesnapshot = "-" # a single character = no snapshot # gradlePlugin and lint need to be updated together @@ -29,6 +30,7 @@ compose-foundation-foundation = { module = "androidx.compose.foundation:foundati compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "compose" } compose-material-material = { module = "androidx.compose.material:material", version.ref = "compose" } compose-material-iconsext = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } +compose-material3-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" } compose-animation-animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } snapper = "dev.chrisbanes.snapper:snapper:0.2.2" @@ -70,6 +72,8 @@ androidx-window-testing = { module = "androidx.window:window-testing", version.r androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxnavigation" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidxnavigation" } +mdc = "com.google.android.material:material:1.7.0" + napier = "io.github.aakira:napier:1.4.1" androidx-test-core = "androidx.test:core-ktx:1.5.0-alpha02" diff --git a/mkdocs.yml b/mkdocs.yml index db4496f58..215be2b12 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,18 @@ nav: - 'AppCompat Theme': - 'Guide': appcompat-theme.md - 'API': api/appcompat-theme/ + - 'AppCompat Theme Adapter': + - 'Guide': themeadapter-appcompat.md + - 'API': api/themeadapter-appcompat/ + - 'Material Theme Adapter': + - 'Guide': themeadapter-material.md + - 'API': api/themeadapter-material/ + - 'Material 3 Theme Adapter': + - 'Guide': themeadapter-material3.md + - 'API': api/themeadapter-material3/ + - 'Core Theme Adapter': + - 'Guide': themeadapter-core.md + - 'API': api/themeadapter-core/ - 'Pager layouts': - 'Guide': pager.md - 'API': api/pager/ diff --git a/sample/build.gradle b/sample/build.gradle index ec1c357e9..dad8a1ea9 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -64,15 +64,19 @@ dependencies { implementation project(':testharness') // Don't use in production! Use the configurations below. testImplementation project(':testharness') androidTestImplementation project(':testharness') + implementation project(':themeadapter-material') + implementation project(':themeadapter-material3') implementation project(':web') implementation libs.androidx.appcompat + implementation libs.mdc implementation libs.coil.compose implementation libs.coil.gif implementation libs.compose.material.material implementation libs.compose.material.iconsext + implementation libs.compose.material3.material3 implementation libs.compose.foundation.layout debugImplementation libs.compose.ui.tooling implementation libs.compose.ui.tooling.preview diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 2209a21fa..bd067bef4 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -455,7 +455,7 @@ - + + + + + + + + + + + + + + + diff --git a/sample/src/main/java/com/google/accompanist/sample/themeadapter/Material3Sample.kt b/sample/src/main/java/com/google/accompanist/sample/themeadapter/Material3Sample.kt new file mode 100644 index 000000000..57b98d711 --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/themeadapter/Material3Sample.kt @@ -0,0 +1,341 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.sample.themeadapter + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFloatingActionButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.sample.R +import com.google.accompanist.themeadapter.material3.Mdc3Theme +import com.google.android.material.color.DynamicColors + +class Mdc3ThemeSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DynamicColors.applyToActivityIfAvailable(this) + val contentView = ComposeView(this) + setContentView(contentView) + contentView.setContent { + Mdc3Theme { + Material3Sample() + } + } + } +} + +@Preview +@Composable +fun Material3SamplePreview() { + Mdc3Theme { + Material3Sample() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Material3Sample() { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + CenterAlignedTopAppBar( + title = { Text(text = stringResource(R.string.themeadapter_title_material3)) }, + scrollBehavior = scrollBehavior + ) + } + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(16.dp) + ) { + Button(onClick = {}) { + Text(text = "Filled button") + } + VerticalSpacer() + + ElevatedButton(onClick = {}) { + Text(text = "Elevated button") + } + VerticalSpacer() + + FilledTonalButton(onClick = {}) { + Text(text = "Filled tonal button") + } + VerticalSpacer() + + OutlinedButton(onClick = {}) { + Text(text = "Outlined button") + } + VerticalSpacer() + + TextButton(onClick = {}) { + Text(text = "Text button") + } + VerticalSpacer() + + SmallFloatingActionButton( + onClick = {}, + content = { Icon(Icons.Default.Favorite, null) } + ) + VerticalSpacer() + + FloatingActionButton( + onClick = {}, + content = { Icon(Icons.Default.Favorite, null) } + ) + VerticalSpacer() + + LargeFloatingActionButton( + onClick = {}, + content = { Icon(Icons.Default.Favorite, null) } + ) + VerticalSpacer() + + ExtendedFloatingActionButton( + onClick = {}, + text = { Text(text = "Extended FAB") }, + icon = { Icon(Icons.Default.Favorite, null) } + ) + VerticalSpacer() + + Card(modifier = Modifier.size(width = 180.dp, height = 100.dp)) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "Card") + } + } + VerticalSpacer() + + var checkboxChecked by remember { mutableStateOf(true) } + Checkbox( + checked = checkboxChecked, + onCheckedChange = { checkboxChecked = it } + ) + VerticalSpacer() + + var radioButtonChecked by remember { mutableStateOf(true) } + Row(Modifier.selectableGroup()) { + RadioButton( + selected = radioButtonChecked, + onClick = { radioButtonChecked = true } + ) + RadioButton( + selected = !radioButtonChecked, + onClick = { radioButtonChecked = false } + ) + } + VerticalSpacer() + + var switchChecked by remember { mutableStateOf(true) } + Switch( + checked = switchChecked, + onCheckedChange = { switchChecked = it } + ) + VerticalSpacer() + + var linearProgress by remember { mutableStateOf(0.1f) } + val animatedLinearProgress by animateFloatAsState( + targetValue = linearProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec + ) + Row(verticalAlignment = Alignment.CenterVertically) { + LinearProgressIndicator(progress = animatedLinearProgress) + HorizontalSpacer() + TextButton( + onClick = { + if (linearProgress < 1f) linearProgress += 0.1f + } + ) { + Text("Increase") + } + } + VerticalSpacer() + + var circularProgress by remember { mutableStateOf(0.1f) } + val animatedCircularProgress by animateFloatAsState( + targetValue = circularProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec + ) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(progress = animatedCircularProgress) + HorizontalSpacer() + TextButton( + onClick = { + if (circularProgress < 1f) circularProgress += 0.1f + } + ) { + Text("Increase") + } + } + VerticalSpacer() + + var sliderValue by remember { mutableStateOf(0f) } + Column { + Text(text = sliderValue.toString()) + Slider(value = sliderValue, onValueChange = { sliderValue = it }) + } + VerticalSpacer() + + var text by rememberSaveable { mutableStateOf("") } + TextField( + value = text, + onValueChange = { text = it }, + label = { Text("Text field") }, + singleLine = true + ) + VerticalSpacer() + + var outlinedText by rememberSaveable { mutableStateOf("") } + OutlinedTextField( + value = outlinedText, + onValueChange = { outlinedText = it }, + label = { Text("Outlined text field") }, + singleLine = true + ) + VerticalSpacer() + + Text( + text = "Display Large", + style = MaterialTheme.typography.displayLarge + ) + Text( + text = "Display Medium", + style = MaterialTheme.typography.displayMedium + ) + Text( + text = "Display Small", + style = MaterialTheme.typography.displaySmall + ) + Text( + text = "Headline Large", + style = MaterialTheme.typography.headlineLarge + ) + Text( + text = "Headline Medium", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "Headline Small", + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = "Title Large", + style = MaterialTheme.typography.titleLarge + ) + Text( + text = "Title Medium", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Title Small", + style = MaterialTheme.typography.titleSmall + ) + Text( + text = "Body Large", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Body Medium", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Body Small", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "Label Large", + style = MaterialTheme.typography.labelLarge + ) + Text( + text = "Label Medium", + style = MaterialTheme.typography.labelMedium + ) + Text( + text = "Label Small", + style = MaterialTheme.typography.labelSmall + ) + } + } +} + +@Composable +private fun VerticalSpacer() { + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun HorizontalSpacer() { + Spacer(Modifier.width(8.dp)) +} diff --git a/sample/src/main/java/com/google/accompanist/sample/themeadapter/MaterialSample.kt b/sample/src/main/java/com/google/accompanist/sample/themeadapter/MaterialSample.kt new file mode 100644 index 000000000..cf66c8ceb --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/themeadapter/MaterialSample.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.sample.themeadapter + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.sample.R +import com.google.accompanist.themeadapter.material.MdcTheme + +class MdcThemeSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val contentView = ComposeView(this) + setContentView(contentView) + contentView.setContent { + MdcTheme { + MaterialSample() + } + } + } +} + +@Preview +@Composable +fun MaterialSamplePreview() { + MdcTheme { + MaterialSample() + } +} + +@Composable +fun MaterialSample() { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.themeadapter_title_material)) } + ) + } + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(16.dp) + ) { + CircularProgressIndicator() + VerticalSpacer() + + Button(onClick = {}) { + Text(text = "Button") + } + VerticalSpacer() + + OutlinedButton(onClick = {}) { + Text(text = "Outlined Button") + } + VerticalSpacer() + + TextButton(onClick = {}) { + Text(text = "Text Button") + } + VerticalSpacer() + + FloatingActionButton( + onClick = {}, + content = { Icon(Icons.Default.Favorite, null) } + ) + VerticalSpacer() + + ExtendedFloatingActionButton( + onClick = {}, + text = { Text(text = "Extended FAB") }, + icon = { Icon(Icons.Default.Favorite, null) } + ) + VerticalSpacer() + + TextField( + value = "", + onValueChange = {}, + label = { Text(text = "Text field") } + ) + VerticalSpacer() + + Text( + text = "H1", + style = MaterialTheme.typography.h1 + ) + Text( + text = "Headline 2", + style = MaterialTheme.typography.h2 + ) + Text( + text = "Headline 3", + style = MaterialTheme.typography.h3 + ) + Text( + text = "Headline 4", + style = MaterialTheme.typography.h4 + ) + Text( + text = "Headline 5", + style = MaterialTheme.typography.h5 + ) + Text( + text = "Headline 6", + style = MaterialTheme.typography.h6 + ) + Text( + text = "Subtitle 1", + style = MaterialTheme.typography.subtitle1 + ) + Text( + text = "Subtitle 2", + style = MaterialTheme.typography.subtitle2 + ) + Text( + text = "Body 1", + style = MaterialTheme.typography.body1 + ) + Text( + text = "Body 2", + style = MaterialTheme.typography.body2 + ) + Text( + text = "Caption", + style = MaterialTheme.typography.caption + ) + Text( + text = "Overline", + style = MaterialTheme.typography.overline + ) + } + } +} + +@Composable +private fun VerticalSpacer() { + Spacer(Modifier.height(8.dp)) +} diff --git a/sample/src/main/res/font/dancingscript.xml b/sample/src/main/res/font/dancingscript.xml new file mode 100644 index 000000000..5d9983a4e --- /dev/null +++ b/sample/src/main/res/font/dancingscript.xml @@ -0,0 +1,31 @@ + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/font/dancingscript_bold.ttf b/sample/src/main/res/font/dancingscript_bold.ttf new file mode 100644 index 000000000..6eb9606ed Binary files /dev/null and b/sample/src/main/res/font/dancingscript_bold.ttf differ diff --git a/sample/src/main/res/font/dancingscript_medium.ttf b/sample/src/main/res/font/dancingscript_medium.ttf new file mode 100644 index 000000000..1d96569e9 Binary files /dev/null and b/sample/src/main/res/font/dancingscript_medium.ttf differ diff --git a/sample/src/main/res/font/dancingscript_regular.ttf b/sample/src/main/res/font/dancingscript_regular.ttf new file mode 100644 index 000000000..f74378341 Binary files /dev/null and b/sample/src/main/res/font/dancingscript_regular.ttf differ diff --git a/sample/src/main/res/font/dancingscript_semibold.ttf b/sample/src/main/res/font/dancingscript_semibold.ttf new file mode 100644 index 000000000..ead1c3d95 Binary files /dev/null and b/sample/src/main/res/font/dancingscript_semibold.ttf differ diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml index 1f3230a0c..d8aa86de3 100644 --- a/sample/src/main/res/values/colors.xml +++ b/sample/src/main/res/values/colors.xml @@ -18,4 +18,5 @@ #ff8a65 #f4511e #ff4081 + #c60055 diff --git a/sample/src/main/res/values/shape.xml b/sample/src/main/res/values/shape.xml new file mode 100644 index 000000000..2a600bee5 --- /dev/null +++ b/sample/src/main/res/values/shape.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 6499ded84..5a504ee97 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -68,8 +68,11 @@ Adaptive: TwoPane Basic Adaptive: TwoPane Horizontal Adaptive: TwoPane Vertical - + Test Harness This is content\n%s + Theme Adapter: Material + Theme Adapter: Material 3 + diff --git a/sample/src/main/res/values/themes.xml b/sample/src/main/res/values/themes.xml index 07fbd8d38..0c2f452d5 100644 --- a/sample/src/main/res/values/themes.xml +++ b/sample/src/main/res/values/themes.xml @@ -32,4 +32,24 @@ false @android:color/transparent + + + \ No newline at end of file diff --git a/sample/src/main/res/values/type.xml b/sample/src/main/res/values/type.xml new file mode 100644 index 000000000..5878fc2fe --- /dev/null +++ b/sample/src/main/res/values/type.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index b278e6967..28f5d9cec 100644 --- a/settings.gradle +++ b/settings.gradle @@ -44,4 +44,8 @@ include ':systemuicontroller' include ':swiperefresh' include ':sample' include ':testharness' +include ':themeadapter-core' +include ':themeadapter-appcompat' +include ':themeadapter-material' +include ':themeadapter-material3' include ':web' diff --git a/themeadapter-appcompat/README.md b/themeadapter-appcompat/README.md new file mode 100644 index 000000000..c98e83d11 --- /dev/null +++ b/themeadapter-appcompat/README.md @@ -0,0 +1,38 @@ +# AppCompat Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-appcompat)](https://search.maven.org/search?q=g:com.google.accompanist) + +AppCompat Theme Adapter enables the reuse of [AppCompat][appcompat] XML themes, for theming in [Jetpack Compose][compose]. + +## Usage + +This library attempts to bridge the gap between [AppCompat][appcompat] XML themes, and themes in [Jetpack Compose][compose], +allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: + +``` kotlin +AppCompatTheme { + // MaterialTheme.colors and MaterialTheme.typography + // will now contain copies of the context's theme +} +``` + +For more information, visit the documentation: https://google.github.io/accompanist/themeadapter-appcompat + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:" +} +``` + +Snapshots of the development version are available in Sonatype's `snapshots` [repository][snap]. These are updated on every commit. + +[appcompat]: https://developer.android.com/jetpack/androidx/releases/appcompat +[compose]: https://developer.android.com/jetpack/compose +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#materialtheme +[snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-themeadapter-appcompat/ diff --git a/themeadapter-appcompat/api/current.api b/themeadapter-appcompat/api/current.api new file mode 100644 index 000000000..26e84d32d --- /dev/null +++ b/themeadapter-appcompat/api/current.api @@ -0,0 +1,24 @@ +// Signature format: 4.0 +package com.google.accompanist.themeadapter.appcompat { + + public final class AppCompatTheme { + method @androidx.compose.runtime.Composable public static void AppCompatTheme(optional android.content.Context context, optional boolean readColors, optional boolean readTypography, optional androidx.compose.material.Shapes shapes, kotlin.jvm.functions.Function0 content); + method public static com.google.accompanist.themeadapter.appcompat.ThemeParameters createAppCompatTheme(android.content.Context, optional boolean readColors, optional boolean readTypography); + } + + public final class ColorKt { + } + + public final class ThemeParameters { + ctor public ThemeParameters(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography); + method public androidx.compose.material.Colors? component1(); + method public androidx.compose.material.Typography? component2(); + method public com.google.accompanist.themeadapter.appcompat.ThemeParameters copy(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography); + method public androidx.compose.material.Colors? getColors(); + method public androidx.compose.material.Typography? getTypography(); + property public final androidx.compose.material.Colors? colors; + property public final androidx.compose.material.Typography? typography; + } + +} + diff --git a/themeadapter-appcompat/build.gradle b/themeadapter-appcompat/build.gradle new file mode 100644 index 000000000..183d38441 --- /dev/null +++ b/themeadapter-appcompat/build.gradle @@ -0,0 +1,111 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +kotlin { + explicitApi() +} + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + // targetSdkVersion has no effect for libraries. This is only used for the test APK + targetSdkVersion 33 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose true + buildConfig false + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Multiple dependencies bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + animationsDisabled true + } + + sourceSets { + test { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + androidTest { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + } +} + +dependencies { + implementation(project(':themeadapter-core')) + + implementation libs.compose.material.material + + // ====================== + // Test dependencies + // ====================== + + androidTestImplementation project(':internal-testutils') + testImplementation project(':internal-testutils') + + androidTestImplementation libs.junit + testImplementation libs.junit + + androidTestImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.junit4 + + androidTestImplementation libs.androidx.test.runner + testImplementation libs.androidx.test.runner + + androidTestImplementation libs.androidx.test.espressoCore + testImplementation libs.androidx.test.espressoCore + + testImplementation libs.robolectric +} + +apply plugin: 'com.vanniktech.maven.publish' diff --git a/themeadapter-appcompat/gradle.properties b/themeadapter-appcompat/gradle.properties new file mode 100644 index 000000000..9d492e9f2 --- /dev/null +++ b/themeadapter-appcompat/gradle.properties @@ -0,0 +1,19 @@ +# +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +POM_ARTIFACT_ID=accompanist-themeadapter-appcompat +POM_NAME=AppCompat Theme Adapter for Compose +POM_PACKAGING=aar diff --git a/themeadapter-appcompat/src/androidTest/AndroidManifest.xml b/themeadapter-appcompat/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..13a5a7368 --- /dev/null +++ b/themeadapter-appcompat/src/androidTest/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/themeadapter-appcompat/src/androidTest/kotlin/com/google/accompanist/themeadapter/appcompat/InstrumentedAppCompatThemeTest.kt b/themeadapter-appcompat/src/androidTest/kotlin/com/google/accompanist/themeadapter/appcompat/InstrumentedAppCompatThemeTest.kt new file mode 100644 index 000000000..6c19b5d02 --- /dev/null +++ b/themeadapter-appcompat/src/androidTest/kotlin/com/google/accompanist/themeadapter/appcompat/InstrumentedAppCompatThemeTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.toFontFamily +import androidx.test.filters.SdkSuppress +import com.google.accompanist.themeadapter.appcompat.test.R +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Version of [BaseAppCompatThemeTest] which is designed to be run on device/emulators. + */ +@RunWith(Parameterized::class) +class InstrumentedAppCompatThemeTest( + activityClass: Class +) : BaseAppCompatThemeTest(activityClass) { + companion object { + @JvmStatic + @Parameterized.Parameters + fun activities() = listOf( + DarkAppCompatActivity::class.java, + LightAppCompatActivity::class.java + ) + } + + /** + * On API 21-22, the family is loaded with only the 400 font. + * + * This only works on device as Robolectric seems to always use the behavior from API 23+, + * which is not what we want to test. + */ + @Test + @SdkSuppress(maxSdkVersion = 22) + fun type_rubik_family_api21() = composeTestRule.setContent { + val rubik = Font(R.font.rubik, FontWeight.W400).toFontFamily() + + WithThemeOverlay(R.style.ThemeOverlay_AppCompatThemeTest_RubikFontFamily) { + AppCompatTheme { + MaterialTheme.typography.assertFontFamily(expected = rubik) + } + } + } +} diff --git a/themeadapter-appcompat/src/main/AndroidManifest.xml b/themeadapter-appcompat/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b35dcbec9 --- /dev/null +++ b/themeadapter-appcompat/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/themeadapter-appcompat/src/main/java/com/google/accompanist/themeadapter/appcompat/AppCompatTheme.kt b/themeadapter-appcompat/src/main/java/com/google/accompanist/themeadapter/appcompat/AppCompatTheme.kt new file mode 100644 index 000000000..3d5249356 --- /dev/null +++ b/themeadapter-appcompat/src/main/java/com/google/accompanist/themeadapter/appcompat/AppCompatTheme.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("AppCompatTheme") + +package com.google.accompanist.themeadapter.appcompat + +import android.content.Context +import androidx.compose.material.Colors +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.Typography +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.res.use +import com.google.accompanist.themeadapter.core.parseColor +import com.google.accompanist.themeadapter.core.parseFontFamily + +/** + * This function creates the components of a [MaterialTheme], synthesizing a material theme + * from values in the [context]'s `Theme.AppCompat` theme. + * + * If you are using [Material Design Components](https://material.io/develop/android/) + * in your app, you should use the + * [MDC Compose Theme Adapter](https://github.com/material-components/material-components-android-compose-theme-adapter) + * instead, as it allows much finer-grained reading of your theme. + * + * Synthesizing a material theme from an `AppCompat` theme is not perfect, since `Theme.AppCompat` + * does not expose the same level of customization as `Theme.MaterialComponents`. + * Going through the pillars of material theming: + * + * ### Colors + * + * AppCompat has a limited set of top-level color attributes, which means that [AppCompatTheme] + * has to generate/select alternative colors in certain situations. The mapping is currently: + * + * | MaterialTheme color | AppCompat | + * |---------------------|-------------------------------------------------------| + * | primary | colorPrimary | + * | primaryVariant | colorPrimaryDark | + * | onPrimary | Calculated black/white | + * | secondary | colorAccent | + * | secondaryVariant | colorAccent | + * | onSecondary | Calculated black/white | + * | surface | Default | + * | onSurface | android:textColorPrimary, else calculated black/white | + * | background | android:colorBackground | + * | onBackground | android:textColorPrimary, else calculated black/white | + * | error | colorError | + * | onError | Calculated black/white | + * + * Where the table says "calculated black/white", this means either black/white, depending on + * which provides the greatest contrast against the corresponding background color. + * + * ### Typography + * + * AppCompat does not provide any semantic text appearances (such as headline6, body1, etc), and + * instead relies on text appearances for specific widgets or use cases. As such, the only thing + * we read from an AppCompat theme is the default `app:fontFamily` or `android:fontFamily`. + * For example: + * + * ``` + * + * ``` + * + * Compose does not currently support downloadable fonts, so any font referenced from the theme + * should from your resources. See [here](https://developer.android.com/guide/topics/resources/font-resource) + * for more information. + * + * ### Shape + * + * AppCompat has no concept of shape theming, therefore we use the default value from + * [MaterialTheme.shapes]. If you wish to provide custom values, use the [shapes] parameter. + * + * @param context The context to read the theme from. + * @param readColors whether the read the color palette from the [context]'s theme. + * @param readTypography whether to read the font family from [context]'s theme. + * @param shapes A set of shapes to be used by the components in this hierarchy. + */ +@Composable +fun AppCompatTheme( + context: Context = LocalContext.current, + readColors: Boolean = true, + readTypography: Boolean = true, + shapes: Shapes = MaterialTheme.shapes, + content: @Composable () -> Unit +) { + val themeParams = remember(context.theme) { + context.createAppCompatTheme( + readColors = readColors, + readTypography = readTypography + ) + } + + MaterialTheme( + colors = themeParams.colors ?: MaterialTheme.colors, + typography = themeParams.typography ?: MaterialTheme.typography, + shapes = shapes, + ) { + // We update the LocalContentColor to match our onBackground. This allows the default + // content color to be more appropriate to the theme background + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colors.onBackground, + content = content + ) + } +} + +/** + * This class contains some of the individual components of a [MaterialTheme]: + * [Colors] & [Typography]. + */ +data class ThemeParameters( + val colors: Colors?, + val typography: Typography? +) + +/** + * This function creates the components of a [androidx.compose.material.MaterialTheme], reading the + * values from the `Theme.AppCompat` Android theme. Please see the documentation + * of [AppCompatTheme] for more information on how the theme is read. + * + * The individual components of the returned [ThemeParameters] may be `null`, depending on the + * matching 'read' parameter. For example, if you set [readColors] to `false`, + * [ThemeParameters.colors] will be null. + * + * @param readColors whether the read the color palette from this context's theme. + * @param readTypography whether to read the font family from this context's theme. + * + * @return [ThemeParameters] instance containing the resulting [Colors] and [Typography] + */ +fun Context.createAppCompatTheme( + readColors: Boolean = true, + readTypography: Boolean = true +): ThemeParameters = obtainStyledAttributes(R.styleable.ThemeAdapterAppCompatTheme).use { ta -> + require(ta.hasValue(R.styleable.ThemeAdapterAppCompatTheme_windowActionBar)) { + "createAppCompatTheme requires the host context's theme to extend Theme.AppCompat" + } + + val colors = if (readColors) { + val isLightTheme = ta.getBoolean(R.styleable.ThemeAdapterAppCompatTheme_isLightTheme, true) + + val defaultColors = if (isLightTheme) lightColors() else darkColors() + + /* First we'll read the Material color palette */ + val primary = ta.parseColor(R.styleable.ThemeAdapterAppCompatTheme_colorPrimary) + // colorPrimaryDark is roughly equivalent to primaryVariant + val primaryVariant = + ta.parseColor(R.styleable.ThemeAdapterAppCompatTheme_colorPrimaryDark) + val onPrimary = primary.calculateOnColor() + + // colorAccent is roughly equivalent to secondary + val secondary = ta.parseColor(R.styleable.ThemeAdapterAppCompatTheme_colorAccent) + // We don't have a secondaryVariant, so just use the secondary + val secondaryVariant = secondary + val onSecondary = secondary.calculateOnColor() + + // We try and use the android:textColorPrimary value (with forced 100% alpha) for the + // onSurface and onBackground colors + val textColorPrimary = ta.parseColor( + R.styleable.ThemeAdapterAppCompatTheme_android_textColorPrimary + ).let { color -> + // We only force the alpha value if it's not Unspecified + if (color != Color.Unspecified) color.copy(alpha = 1f) else color + } + + val surface = defaultColors.surface + val onSurface = surface.calculateOnColorWithTextColorPrimary(textColorPrimary) + + val background = + ta.parseColor(R.styleable.ThemeAdapterAppCompatTheme_android_colorBackground) + val onBackground = background.calculateOnColorWithTextColorPrimary(textColorPrimary) + + val error = ta.parseColor(R.styleable.ThemeAdapterAppCompatTheme_colorError) + val onError = error.calculateOnColor() + + defaultColors.copy( + primary = primary, + primaryVariant = primaryVariant, + onPrimary = onPrimary, + secondary = secondary, + secondaryVariant = secondaryVariant, + onSecondary = onSecondary, + surface = surface, + onSurface = onSurface, + background = background, + onBackground = onBackground, + error = error, + onError = onError + ) + } else null + + /** + * Next we'll create a typography instance. We only use the default app:fontFamily or + * android:fontFamily set in the theme. If neither of these are set, we return null. + */ + val typography = if (readTypography) { + val fontFamily = ta.parseFontFamily(R.styleable.ThemeAdapterAppCompatTheme_fontFamily) + ?: ta.parseFontFamily(R.styleable.ThemeAdapterAppCompatTheme_android_fontFamily) + fontFamily?.let { + Typography(defaultFontFamily = it.fontFamily) + } + } else null + + ThemeParameters(colors, typography) +} diff --git a/themeadapter-appcompat/src/main/java/com/google/accompanist/themeadapter/appcompat/Color.kt b/themeadapter-appcompat/src/main/java/com/google/accompanist/themeadapter/appcompat/Color.kt new file mode 100644 index 000000000..88b2d438c --- /dev/null +++ b/themeadapter-appcompat/src/main/java/com/google/accompanist/themeadapter/appcompat/Color.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils + +internal fun Color.calculateContrastForForeground(foreground: Color): Double { + return ColorUtils.calculateContrast(foreground.toArgb(), toArgb()) +} + +/** + * The WCAG AA minimum contrast for body text is 4.5:1. We may wish to increase this to + * the AAA level of 7:1 ratio. + */ +private const val MINIMUM_CONTRAST = 4.5 + +/** + * Calculates the 'on' color for this background color. + * + * This version of the function tries to use the given [textColorPrimary], as long as it + * meets the minimum contrast against this color. + */ +internal fun Color.calculateOnColorWithTextColorPrimary(textColorPrimary: Color): Color { + if (textColorPrimary != Color.Unspecified && + calculateContrastForForeground(textColorPrimary) >= MINIMUM_CONTRAST + ) { + return textColorPrimary + } + return calculateOnColor() +} + +/** + * Calculates the 'on' color for this background color. + * + * In practice this returns either black or white, depending on which has the highest + * contrast against this color. + */ +internal fun Color.calculateOnColor(): Color { + val contrastForBlack = calculateContrastForForeground(Color.Black) + val contrastForWhite = calculateContrastForForeground(Color.White) + return if (contrastForBlack > contrastForWhite) Color.Black else Color.White +} diff --git a/themeadapter-appcompat/src/main/res/values/theme_attrs.xml b/themeadapter-appcompat/src/main/res/values/theme_attrs.xml new file mode 100644 index 000000000..f3f30b6d0 --- /dev/null +++ b/themeadapter-appcompat/src/main/res/values/theme_attrs.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/BaseAppCompatThemeTest.kt b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/BaseAppCompatThemeTest.kt new file mode 100644 index 000000000..e5a9e16be --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/BaseAppCompatThemeTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import android.view.ContextThemeWrapper +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.toFontFamily +import androidx.test.filters.SdkSuppress +import com.google.accompanist.themeadapter.appcompat.test.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Class which contains the majority of the tests. This class is extended + * in both the `androidTest` and `test` source sets for setup of the relevant + * test runner. + */ +abstract class BaseAppCompatThemeTest( + activityClass: Class +) { + @get:Rule + val composeTestRule = createAndroidComposeRule(activityClass) + + @Test + fun colors() = composeTestRule.setContent { + AppCompatTheme { + val color = MaterialTheme.colors + + assertEquals(colorResource(R.color.aquamarine), color.primary) + // By default, onSecondary is calculated to the highest contrast of black/white + // against primary + assertEquals(Color.Black, color.onPrimary) + // primaryVariant == colorPrimaryDark + assertEquals(colorResource(R.color.royal_blue), color.primaryVariant) + + assertEquals(colorResource(R.color.dark_golden_rod), color.secondary) + // By default, onSecondary is calculated to the highest contrast of black/white + // against secondary + assertEquals(Color.Black, color.onSecondary) + // Assert that secondaryVariant == secondary + assertEquals(colorResource(R.color.dark_golden_rod), color.secondaryVariant) + + assertEquals(colorResource(R.color.dark_salmon), color.error) + // onError is calculated to the highest contrast of black/white against error + assertEquals(Color.Black, color.onError) + + assertEquals(colorResource(R.color.light_coral), color.background) + // By default, onBackground is calculated to the highest contrast of black/white + // against background + assertEquals(Color.Black, color.onBackground) + // AppCompatTheme updates the LocalContentColor to match the calculated onBackground + assertEquals(Color.Black, LocalContentColor.current) + } + } + + @Test + fun colors_textColorPrimary() = composeTestRule.setContent { + WithThemeOverlay(R.style.ThemeOverlay_AppCompatThemeTest_TextColorPrimary) { + AppCompatTheme { + val color = MaterialTheme.colors + + assertEquals(colorResource(R.color.aquamarine), color.primary) + assertEquals(Color.Black, color.onPrimary) + assertEquals(colorResource(R.color.royal_blue), color.primaryVariant) + assertEquals(colorResource(R.color.dark_golden_rod), color.secondary) + assertEquals(Color.Black, color.onSecondary) + assertEquals(colorResource(R.color.dark_golden_rod), color.secondaryVariant) + assertEquals(colorResource(R.color.dark_salmon), color.error) + assertEquals(Color.Black, color.onError) + + assertEquals(colorResource(R.color.light_coral), color.background) + // Our textColorPrimary (midnight_blue) contains provides enough contrast vs + // the background color, so it should be used. + assertEquals(colorResource(R.color.midnight_blue), color.onBackground) + // AppCompatTheme updates the LocalContentColor to match the calculated onBackground + assertEquals(colorResource(R.color.midnight_blue), LocalContentColor.current) + + if (!isSystemInDarkTheme()) { + // Our textColorPrimary (midnight_blue) provides enough contrast vs + // the light surface color, so it should be used. + assertEquals(colorResource(R.color.midnight_blue), color.onSurface) + } else { + // In dark theme, textColorPrimary (midnight_blue) does not provide + // enough contrast vs the light surface color, + // so we use a computed value of white + assertEquals(Color.White, color.onSurface) + } + } + } + } + + @Test + @SdkSuppress(minSdkVersion = 23) // XML font families with >1 fonts are only supported on API 23+ + open fun type_rubik_family_api23() = composeTestRule.setContent { + val rubik = FontFamily( + Font(R.font.rubik_300, FontWeight.W300), + Font(R.font.rubik_400, FontWeight.W400), + Font(R.font.rubik_500, FontWeight.W500), + Font(R.font.rubik_700, FontWeight.W700), + ) + + WithThemeOverlay(R.style.ThemeOverlay_AppCompatThemeTest_RubikFontFamily) { + AppCompatTheme { + MaterialTheme.typography.assertFontFamily(expected = rubik) + } + } + } + + @Test + fun type_rubik_fixed400() = composeTestRule.setContent { + val rubik400 = Font(R.font.rubik_400, FontWeight.W400).toFontFamily() + WithThemeOverlay(R.style.ThemeOverlay_AppCompatThemeTest_Rubik400) { + AppCompatTheme { + MaterialTheme.typography.assertFontFamily(expected = rubik400) + } + } + } +} + +internal fun Typography.assertFontFamily(expected: FontFamily) { + assertEquals(expected, h1.fontFamily) + assertEquals(expected, h2.fontFamily) + assertEquals(expected, h3.fontFamily) + assertEquals(expected, h4.fontFamily) + assertEquals(expected, h5.fontFamily) + assertEquals(expected, h5.fontFamily) + assertEquals(expected, h6.fontFamily) + assertEquals(expected, body1.fontFamily) + assertEquals(expected, body2.fontFamily) + assertEquals(expected, button.fontFamily) + assertEquals(expected, caption.fontFamily) + assertEquals(expected, overline.fontFamily) +} + +/** + * Function which applies an Android theme overlay to the current context. + */ +@Composable +fun WithThemeOverlay( + @StyleRes themeOverlayId: Int, + content: @Composable () -> Unit, +) { + val themedContext = ContextThemeWrapper(LocalContext.current, themeOverlayId) + CompositionLocalProvider(LocalContext provides themedContext, content = content) +} diff --git a/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/DarkAppCompatActivity.kt b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/DarkAppCompatActivity.kt new file mode 100644 index 000000000..61e1b8290 --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/DarkAppCompatActivity.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate + +/** + * An [AppCompatActivity] which forces the night mode to 'dark theme'. + */ +class DarkAppCompatActivity : AppCompatActivity() { + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES + super.attachBaseContext(newBase) + } +} diff --git a/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/FakeTests.kt b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/FakeTests.kt new file mode 100644 index 000000000..45a0fc7c8 --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/FakeTests.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Fake tests to help with sharding: https://github.com/android/android-test/issues/973 + */ +@RunWith(JUnit4::class) +class FakeTests { + @Test + fun fake1() = Unit + + @Test + fun fake2() = Unit + + @Test + fun fake3() = Unit + + @Test + fun fake4() = Unit + + @Test + fun fake5() = Unit + + @Test + fun fake6() = Unit + + @Test + fun fake7() = Unit + + @Test + fun fake8() = Unit + + @Test + fun fake9() = Unit + + @Test + fun fake10() = Unit +} diff --git a/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/LightAppCompatActivity.kt b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/LightAppCompatActivity.kt new file mode 100644 index 000000000..e569d73cd --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/LightAppCompatActivity.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate + +/** + * An [AppCompatActivity] which forces the night mode to 'light theme'. + */ +class LightAppCompatActivity : AppCompatActivity() { + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_NO + super.attachBaseContext(newBase) + } +} diff --git a/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/NotAppCompatActivity.kt b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/NotAppCompatActivity.kt new file mode 100644 index 000000000..12f4f3d8a --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/NotAppCompatActivity.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import androidx.activity.ComponentActivity + +class NotAppCompatActivity : ComponentActivity() diff --git a/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/NotAppCompatThemeTest.kt b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/NotAppCompatThemeTest.kt new file mode 100644 index 000000000..19bb8652d --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/kotlin/com/google/accompanist/themeadapter/appcompat/NotAppCompatThemeTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NotAppCompatThemeTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test(expected = IllegalArgumentException::class) + fun throwForNonAppCompatTheme() = composeTestRule.setContent { + AppCompatTheme { + // Nothing to do here, exception should be thrown + } + } +} diff --git a/themeadapter-appcompat/src/sharedTest/res/font/rubik.xml b/themeadapter-appcompat/src/sharedTest/res/font/rubik.xml new file mode 100644 index 000000000..881fa013a --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/res/font/rubik.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-appcompat/src/sharedTest/res/font/rubik_300.ttf b/themeadapter-appcompat/src/sharedTest/res/font/rubik_300.ttf new file mode 100644 index 000000000..8189d848f Binary files /dev/null and b/themeadapter-appcompat/src/sharedTest/res/font/rubik_300.ttf differ diff --git a/themeadapter-appcompat/src/sharedTest/res/font/rubik_400.ttf b/themeadapter-appcompat/src/sharedTest/res/font/rubik_400.ttf new file mode 100644 index 000000000..52b59ca4f Binary files /dev/null and b/themeadapter-appcompat/src/sharedTest/res/font/rubik_400.ttf differ diff --git a/themeadapter-appcompat/src/sharedTest/res/font/rubik_500.ttf b/themeadapter-appcompat/src/sharedTest/res/font/rubik_500.ttf new file mode 100644 index 000000000..9e358b2f4 Binary files /dev/null and b/themeadapter-appcompat/src/sharedTest/res/font/rubik_500.ttf differ diff --git a/themeadapter-appcompat/src/sharedTest/res/font/rubik_700.ttf b/themeadapter-appcompat/src/sharedTest/res/font/rubik_700.ttf new file mode 100644 index 000000000..4e77930f4 Binary files /dev/null and b/themeadapter-appcompat/src/sharedTest/res/font/rubik_700.ttf differ diff --git a/themeadapter-appcompat/src/sharedTest/res/values/test_colors.xml b/themeadapter-appcompat/src/sharedTest/res/values/test_colors.xml new file mode 100644 index 000000000..37daf3eb2 --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/res/values/test_colors.xml @@ -0,0 +1,33 @@ + + + + + + #7FFFD4 + #4169E1 + #191970 + #B8860B + #8A2BE2 + #708090 + #00FA9A + #000080 + #F08080 + #DA70D6 + #E9967A + #F5F5DC + #6B8E23 + \ No newline at end of file diff --git a/themeadapter-appcompat/src/sharedTest/res/values/themes.xml b/themeadapter-appcompat/src/sharedTest/res/values/themes.xml new file mode 100644 index 000000000..41b1e1374 --- /dev/null +++ b/themeadapter-appcompat/src/sharedTest/res/values/themes.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-appcompat/src/test/AndroidManifest.xml b/themeadapter-appcompat/src/test/AndroidManifest.xml new file mode 100644 index 000000000..13a5a7368 --- /dev/null +++ b/themeadapter-appcompat/src/test/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/themeadapter-appcompat/src/test/kotlin/com/google/accompanist/themeadapter/appcompat/RobolectricAppCompatThemeTest.kt b/themeadapter-appcompat/src/test/kotlin/com/google/accompanist/themeadapter/appcompat/RobolectricAppCompatThemeTest.kt new file mode 100644 index 000000000..f23519f7e --- /dev/null +++ b/themeadapter-appcompat/src/test/kotlin/com/google/accompanist/themeadapter/appcompat/RobolectricAppCompatThemeTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.appcompat + +import androidx.appcompat.app.AppCompatActivity +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +/** + * Version of [BaseAppCompatThemeTest] which is designed to be run using Robolectric. + * + * All of the tests are provided by [BaseAppCompatThemeTest]. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class RobolectricAppCompatThemeTest( + activityClass: Class +) : BaseAppCompatThemeTest(activityClass) { + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun activities() = listOf( + DarkAppCompatActivity::class.java, + LightAppCompatActivity::class.java + ) + } +} diff --git a/themeadapter-appcompat/src/test/resources/robolectric.properties b/themeadapter-appcompat/src/test/resources/robolectric.properties new file mode 100644 index 000000000..2806eaffa --- /dev/null +++ b/themeadapter-appcompat/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Pin SDK to 30 since Robolectric does not currently support API 31: +# https://github.com/robolectric/robolectric/issues/6635 +sdk=30 diff --git a/themeadapter-core/README.md b/themeadapter-core/README.md new file mode 100644 index 000000000..076051ee2 --- /dev/null +++ b/themeadapter-core/README.md @@ -0,0 +1,30 @@ +# Core Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-core)](https://search.maven.org/search?q=g:com.google.accompanist) + +Core Theme Adapter includes common utilities that enable the reuse of XML themes, for theming in [Jetpack Compose][compose]. + +## Usage + +This library includes common utilities that enable the reuse of XML themes, for theming in [Jetpack Compose][compose], +allowing composables like [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme. + +For more information, visit the documentation: https://google.github.io/accompanist/themeadapter-core + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-core:" +} +``` + +Snapshots of the development version are available in Sonatype's `snapshots` [repository][snap]. These are updated on every commit. + +[compose]: https://developer.android.com/jetpack/compose +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#materialtheme +[snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-themeadapter-core/ diff --git a/themeadapter-core/api/current.api b/themeadapter-core/api/current.api new file mode 100644 index 000000000..19d5373b6 --- /dev/null +++ b/themeadapter-core/api/current.api @@ -0,0 +1,26 @@ +// Signature format: 4.0 +package com.google.accompanist.themeadapter.core { + + public final class FontFamilyWithWeight { + ctor public FontFamilyWithWeight(androidx.compose.ui.text.font.FontFamily fontFamily, optional androidx.compose.ui.text.font.FontWeight weight); + method public androidx.compose.ui.text.font.FontFamily component1(); + method public androidx.compose.ui.text.font.FontWeight component2(); + method public com.google.accompanist.themeadapter.core.FontFamilyWithWeight copy(androidx.compose.ui.text.font.FontFamily fontFamily, androidx.compose.ui.text.font.FontWeight weight); + method public androidx.compose.ui.text.font.FontFamily getFontFamily(); + method public androidx.compose.ui.text.font.FontWeight getWeight(); + property public final androidx.compose.ui.text.font.FontFamily fontFamily; + property public final androidx.compose.ui.text.font.FontWeight weight; + } + + public final class ResourceUtilsKt { + method public static long parseColor(android.content.res.TypedArray, int index, optional long fallbackColor); + method public static androidx.compose.foundation.shape.CornerSize? parseCornerSize(android.content.res.TypedArray, int index); + method public static com.google.accompanist.themeadapter.core.FontFamilyWithWeight? parseFontFamily(android.content.res.TypedArray, int index); + method public static androidx.compose.foundation.shape.CornerBasedShape parseShapeAppearance(android.content.Context context, @StyleRes int id, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.shape.CornerBasedShape fallbackShape); + method public static androidx.compose.ui.text.TextStyle parseTextAppearance(android.content.Context context, @StyleRes int id, androidx.compose.ui.unit.Density density, boolean setTextColors, androidx.compose.ui.text.font.FontFamily? defaultFontFamily); + method public static long parseTextUnit(android.content.res.TypedArray, int index, androidx.compose.ui.unit.Density density, optional long fallbackTextUnit); + method @RequiresApi(23) public static androidx.compose.ui.text.font.FontFamily? parseXmlFontFamily(android.content.res.Resources, int id); + } + +} + diff --git a/themeadapter-core/build.gradle b/themeadapter-core/build.gradle new file mode 100644 index 000000000..c67c09c30 --- /dev/null +++ b/themeadapter-core/build.gradle @@ -0,0 +1,113 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +kotlin { + explicitApi() +} + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + // targetSdkVersion has no effect for libraries. This is only used for the test APK + targetSdkVersion 33 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose true + buildConfig false + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Multiple dependencies bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + animationsDisabled true + } + + sourceSets { + test { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + androidTest { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + } +} + +dependencies { + api libs.androidx.core + api libs.compose.foundation.foundation + api libs.kotlin.stdlib + api libs.androidx.appcompat + api libs.mdc + + // ====================== + // Test dependencies + // ====================== + + androidTestImplementation project(':internal-testutils') + testImplementation project(':internal-testutils') + + androidTestImplementation libs.junit + testImplementation libs.junit + + androidTestImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.junit4 + + androidTestImplementation libs.androidx.test.runner + testImplementation libs.androidx.test.runner + + androidTestImplementation libs.androidx.test.espressoCore + testImplementation libs.androidx.test.espressoCore + + testImplementation libs.robolectric +} + +apply plugin: 'com.vanniktech.maven.publish' diff --git a/themeadapter-core/gradle.properties b/themeadapter-core/gradle.properties new file mode 100644 index 000000000..e54d058dd --- /dev/null +++ b/themeadapter-core/gradle.properties @@ -0,0 +1,19 @@ +# +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +POM_ARTIFACT_ID=accompanist-themeadapter-core +POM_NAME=Core Theme Adapter for Compose +POM_PACKAGING=aar diff --git a/themeadapter-core/src/main/AndroidManifest.xml b/themeadapter-core/src/main/AndroidManifest.xml new file mode 100644 index 000000000..71690d04e --- /dev/null +++ b/themeadapter-core/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/themeadapter-core/src/main/java/com/google/accompanist/themeadapter/core/ResourceUtils.kt b/themeadapter-core/src/main/java/com/google/accompanist/themeadapter/core/ResourceUtils.kt new file mode 100644 index 000000000..81dead001 --- /dev/null +++ b/themeadapter-core/src/main/java/com/google/accompanist/themeadapter/core/ResourceUtils.kt @@ -0,0 +1,381 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.core + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.content.res.TypedArray +import android.graphics.Typeface +import android.os.Build +import android.util.TypedValue +import androidx.annotation.RequiresApi +import androidx.annotation.StyleRes +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.toFontFamily +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.core.content.res.FontResourcesParserCompat +import androidx.core.content.res.getColorOrThrow +import androidx.core.content.res.use +import kotlin.concurrent.getOrSet + +/** + * Returns the given index as a [Color], or [fallbackColor] if the value can't be coerced to a + * [Color]. + * + * @param index Index of attribute to retrieve. + * @param fallbackColor Value to return if the attribute is not defined or can't be coerced to a + * [Color]. + */ +fun TypedArray.parseColor( + index: Int, + fallbackColor: Color = Color.Unspecified +): Color = if (hasValue(index)) Color(getColorOrThrow(index)) else fallbackColor + +/** + * Returns the given style resource ID as a [TextStyle]. + * + * @param context The current context. + * @param id ID of style resource to retrieve. + * @param density The current display density. + * @param setTextColors Whether to read and set text colors from the style. Defaults to `false`. + * @param defaultFontFamily Optional default font family to use in [TextStyle]s. + */ +fun parseTextAppearance( + context: Context, + @StyleRes id: Int, + density: Density, + setTextColors: Boolean, + defaultFontFamily: FontFamily? +): TextStyle { + return context.obtainStyledAttributes(id, R.styleable.ThemeAdapterTextAppearance).use { a -> + val textStyle = a.getInt(R.styleable.ThemeAdapterTextAppearance_android_textStyle, -1) + val textFontWeight = a.getInt(R.styleable.ThemeAdapterTextAppearance_android_textFontWeight, -1) + val typeface = a.getInt(R.styleable.ThemeAdapterTextAppearance_android_typeface, -1) + + // TODO read and expand android:fontVariationSettings. + // Variable fonts are not supported in Compose yet + + // FYI, this only works with static font files in assets + val fontFamily: FontFamilyWithWeight? = a.parseFontFamily( + R.styleable.ThemeAdapterTextAppearance_fontFamily + ) ?: a.parseFontFamily(R.styleable.ThemeAdapterTextAppearance_android_fontFamily) + + TextStyle( + color = when { + setTextColors -> { + a.parseColor(R.styleable.ThemeAdapterTextAppearance_android_textColor) + } + else -> Color.Unspecified + }, + fontSize = a.parseTextUnit(R.styleable.ThemeAdapterTextAppearance_android_textSize, density), + lineHeight = run { + a.parseTextUnit( + R.styleable.ThemeAdapterTextAppearance_lineHeight, density, + fallbackTextUnit = a.parseTextUnit( + R.styleable.ThemeAdapterTextAppearance_android_lineHeight, density + ) + ) + }, + fontFamily = when { + defaultFontFamily != null -> defaultFontFamily + fontFamily != null -> fontFamily.fontFamily + // Values below are from frameworks/base attrs.xml + typeface == 1 -> FontFamily.SansSerif + typeface == 2 -> FontFamily.Serif + typeface == 3 -> FontFamily.Monospace + else -> null + }, + fontStyle = when { + (textStyle and Typeface.ITALIC) != 0 -> FontStyle.Italic + else -> FontStyle.Normal + }, + fontWeight = when { + textFontWeight in 0..149 -> FontWeight.W100 + textFontWeight in 150..249 -> FontWeight.W200 + textFontWeight in 250..349 -> FontWeight.W300 + textFontWeight in 350..449 -> FontWeight.W400 + textFontWeight in 450..549 -> FontWeight.W500 + textFontWeight in 550..649 -> FontWeight.W600 + textFontWeight in 650..749 -> FontWeight.W700 + textFontWeight in 750..849 -> FontWeight.W800 + textFontWeight in 850..999 -> FontWeight.W900 + // Else, check the text style for bold + (textStyle and Typeface.BOLD) != 0 -> FontWeight.Bold + // Else, the font family might have an implicit weight (san-serif-light, etc) + fontFamily != null -> fontFamily.weight + else -> null + }, + fontFeatureSettings = a.getString(R.styleable.ThemeAdapterTextAppearance_android_fontFeatureSettings), + shadow = run { + val shadowColor = a.parseColor(R.styleable.ThemeAdapterTextAppearance_android_shadowColor) + if (shadowColor != Color.Unspecified) { + val dx = a.getFloat(R.styleable.ThemeAdapterTextAppearance_android_shadowDx, 0f) + val dy = a.getFloat(R.styleable.ThemeAdapterTextAppearance_android_shadowDy, 0f) + val rad = a.getFloat(R.styleable.ThemeAdapterTextAppearance_android_shadowRadius, 0f) + Shadow(color = shadowColor, offset = Offset(dx, dy), blurRadius = rad) + } else null + }, + letterSpacing = when { + a.hasValue(R.styleable.ThemeAdapterTextAppearance_android_letterSpacing) -> { + a.getFloat(R.styleable.ThemeAdapterTextAppearance_android_letterSpacing, 0f).em + } + // FIXME: Normally we'd use TextUnit.Unspecified, + // but this can cause a crash due to mismatched Sp and Em TextUnits + // https://issuetracker.google.com/issues/182881244 + else -> 0.em + } + ) + } +} + +/** + * Returns the given index as a [FontFamilyWithWeight], or `null` if the value can't be coerced to + * a [FontFamilyWithWeight]. + * + * @param index Index of attribute to retrieve. + */ +fun TypedArray.parseFontFamily(index: Int): FontFamilyWithWeight? { + val tv = tempTypedValue.getOrSet(::TypedValue) + if (getValue(index, tv) && tv.type == TypedValue.TYPE_STRING) { + return when (tv.string) { + "sans-serif" -> FontFamilyWithWeight(FontFamily.SansSerif) + "sans-serif-thin" -> FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Thin) + "sans-serif-light" -> FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Light) + "sans-serif-medium" -> FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Medium) + "sans-serif-black" -> FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Black) + "serif" -> FontFamilyWithWeight(FontFamily.Serif) + "cursive" -> FontFamilyWithWeight(FontFamily.Cursive) + "monospace" -> FontFamilyWithWeight(FontFamily.Monospace) + // TODO: Compose does not expose a FontFamily for all strings yet + else -> { + // If there's a resource ID and the string starts with res/, + // it's probably a @font resource + if (tv.resourceId != 0 && tv.string.startsWith("res/")) { + // If we're running on API 23+ and the resource is an XML, we can parse + // the fonts into a full FontFamily. + if (Build.VERSION.SDK_INT >= 23 && tv.string.endsWith(".xml")) { + resources.parseXmlFontFamily(tv.resourceId)?.let(::FontFamilyWithWeight) + } else { + // Otherwise we just load it as a single font + FontFamilyWithWeight(Font(tv.resourceId).toFontFamily()) + } + } else null + } + } + } + return null +} + +/** + * A lightweight class for storing a [FontFamily] and [FontWeight]. + */ +data class FontFamilyWithWeight( + val fontFamily: FontFamily, + val weight: FontWeight = FontWeight.Normal +) + +/** + * Returns the given XML resource ID as a [FontFamily], or `null` if the value can't be coerced to + * a [FontFamily]. + * + * @param id ID of XML resource to retrieve. + */ +@SuppressLint("RestrictedApi") // FontResourcesParserCompat.* +@RequiresApi(23) // XML font families with > 1 fonts are only supported on API 23+ +fun Resources.parseXmlFontFamily(id: Int): FontFamily? { + val parser = getXml(id) + + // Can't use {} since XmlResourceParser is AutoCloseable, not Closeable + @Suppress("ConvertTryFinallyToUseCall") + try { + val result = FontResourcesParserCompat.parse(parser, this) + if (result is FontResourcesParserCompat.FontFamilyFilesResourceEntry) { + val fonts = result.entries.map { font -> + Font( + resId = font.resourceId, + weight = fontWeightOf(font.weight), + style = if (font.isItalic) FontStyle.Italic else FontStyle.Normal + ) + } + return FontFamily(fonts) + } + } finally { + parser.close() + } + return null +} + +private fun fontWeightOf(weight: Int): FontWeight = when (weight) { + in 0..149 -> FontWeight.W100 + in 150..249 -> FontWeight.W200 + in 250..349 -> FontWeight.W300 + in 350..449 -> FontWeight.W400 + in 450..549 -> FontWeight.W500 + in 550..649 -> FontWeight.W600 + in 650..749 -> FontWeight.W700 + in 750..849 -> FontWeight.W800 + in 850..999 -> FontWeight.W900 + // Else, we use the 'normal' weight + else -> FontWeight.W400 +} + +/** + * Returns the given index as a [TextUnit], or [fallbackTextUnit] if the value can't be coerced to + * a [TextUnit]. + * + * @param index Index of attribute to retrieve. + * @param density The current display density. + * @param fallbackTextUnit Value to return if the attribute is not defined or can't be coerced to a + * [TextUnit]. + */ +fun TypedArray.parseTextUnit( + index: Int, + density: Density, + fallbackTextUnit: TextUnit = TextUnit.Unspecified +): TextUnit { + val tv = tempTypedValue.getOrSet { TypedValue() } + if (getValue(index, tv) && tv.type == TypedValue.TYPE_DIMENSION) { + return when (tv.complexUnitCompat) { + // For SP values, we convert the value directly to an TextUnit.Sp + TypedValue.COMPLEX_UNIT_SP -> TypedValue.complexToFloat(tv.data).sp + // For DIP values, we convert the value to an TextUnit.Em (roughly equivalent) + TypedValue.COMPLEX_UNIT_DIP -> TypedValue.complexToFloat(tv.data).em + // For another other types, we let the TypedArray flatten to a px value, and + // we convert it to an Sp based on the current density + else -> with(density) { getDimension(index, 0f).toSp() } + } + } + return fallbackTextUnit +} + +/** + * Returns the given style resource ID as a [CornerBasedShape], or [fallbackShape] if the value + * can't be coerced to a [CornerBasedShape]. + * + * @param context The current context. + * @param id ID of style resource to retrieve. + * @param layoutDirection The current display layout direction. + * @param fallbackShape Value to return if the style resource ID is not defined or can't be coerced + * to a [CornerBasedShape]. + */ +fun parseShapeAppearance( + context: Context, + @StyleRes id: Int, + layoutDirection: LayoutDirection, + fallbackShape: CornerBasedShape +): CornerBasedShape { + return context.obtainStyledAttributes(id, R.styleable.ThemeAdapterShapeAppearance).use { a -> + val defaultCornerSize = a.parseCornerSize( + R.styleable.ThemeAdapterShapeAppearance_cornerSize + ) + val cornerSizeTL = a.parseCornerSize( + R.styleable.ThemeAdapterShapeAppearance_cornerSizeTopLeft + ) + val cornerSizeTR = a.parseCornerSize( + R.styleable.ThemeAdapterShapeAppearance_cornerSizeTopRight + ) + val cornerSizeBL = a.parseCornerSize( + R.styleable.ThemeAdapterShapeAppearance_cornerSizeBottomLeft + ) + val cornerSizeBR = a.parseCornerSize( + R.styleable.ThemeAdapterShapeAppearance_cornerSizeBottomRight + ) + val isRtl = layoutDirection == LayoutDirection.Rtl + val cornerSizeTS = if (isRtl) cornerSizeTR else cornerSizeTL + val cornerSizeTE = if (isRtl) cornerSizeTL else cornerSizeTR + val cornerSizeBS = if (isRtl) cornerSizeBR else cornerSizeBL + val cornerSizeBE = if (isRtl) cornerSizeBL else cornerSizeBR + + /** + * We do not support the individual `cornerFamilyTopLeft`, etc, since Compose only supports + * one corner type per shape. Therefore we only read the `cornerFamily` attribute. + */ + when (a.getInt(R.styleable.ThemeAdapterShapeAppearance_cornerFamily, 0)) { + 0 -> { + RoundedCornerShape( + topStart = cornerSizeTS ?: defaultCornerSize ?: fallbackShape.topStart, + topEnd = cornerSizeTE ?: defaultCornerSize ?: fallbackShape.topEnd, + bottomEnd = cornerSizeBE ?: defaultCornerSize ?: fallbackShape.bottomEnd, + bottomStart = cornerSizeBS ?: defaultCornerSize ?: fallbackShape.bottomStart + ) + } + 1 -> { + CutCornerShape( + topStart = cornerSizeTS ?: defaultCornerSize ?: fallbackShape.topStart, + topEnd = cornerSizeTE ?: defaultCornerSize ?: fallbackShape.topEnd, + bottomEnd = cornerSizeBE ?: defaultCornerSize ?: fallbackShape.bottomEnd, + bottomStart = cornerSizeBS ?: defaultCornerSize ?: fallbackShape.bottomStart + ) + } + else -> throw IllegalArgumentException("Unknown cornerFamily set in ShapeAppearance") + } + } +} + +/** + * Returns the given index as a [CornerSize], or `null` if the value can't be coerced to a + * [CornerSize]. + * + * @param index Index of attribute to retrieve. + */ +fun TypedArray.parseCornerSize(index: Int): CornerSize? { + val tv = tempTypedValue.getOrSet { TypedValue() } + if (getValue(index, tv)) { + return when (tv.type) { + TypedValue.TYPE_DIMENSION -> { + when (tv.complexUnitCompat) { + // For DIP and PX values, we convert the value to the equivalent + TypedValue.COMPLEX_UNIT_DIP -> CornerSize(TypedValue.complexToFloat(tv.data).dp) + TypedValue.COMPLEX_UNIT_PX -> CornerSize(TypedValue.complexToFloat(tv.data)) + // For another other dim types, we let the TypedArray flatten to a px value + else -> CornerSize(getDimensionPixelSize(index, 0)) + } + } + TypedValue.TYPE_FRACTION -> CornerSize(tv.getFraction(1f, 1f)) + else -> null + } + } + return null +} + +/** + * A workaround since [TypedValue.getComplexUnit] is API 22+ + */ +private inline val TypedValue.complexUnitCompat + get() = when { + Build.VERSION.SDK_INT > 22 -> complexUnit + else -> TypedValue.COMPLEX_UNIT_MASK and (data shr TypedValue.COMPLEX_UNIT_SHIFT) + } + +private val tempTypedValue = ThreadLocal() diff --git a/themeadapter-core/src/main/res/values/theme_attrs.xml b/themeadapter-core/src/main/res/values/theme_attrs.xml new file mode 100644 index 000000000..17f9b3564 --- /dev/null +++ b/themeadapter-core/src/main/res/values/theme_attrs.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themeadapter-material/README.md b/themeadapter-material/README.md new file mode 100644 index 000000000..0273389e6 --- /dev/null +++ b/themeadapter-material/README.md @@ -0,0 +1,38 @@ +# Material Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-material)](https://search.maven.org/search?q=g:com.google.accompanist) + +Material Theme Adapter enables the reuse of [MDC-Android][mdc] Material 2 XML themes, for theming in [Jetpack Compose][compose]. + +## Usage + +This library attempts to bridge the gap between [MDC-Android][mdc] Material 2 XML themes, and themes in [Jetpack Compose][compose], +allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: + +``` kotlin +MdcTheme { + // MaterialTheme.colors, MaterialTheme.typography and MaterialTheme.shapes + // will now contain copies of the context's theme +} +``` + +For more information, visit the documentation: https://google.github.io/accompanist/themeadapter-material + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-material:" +} +``` + +Snapshots of the development version are available in Sonatype's `snapshots` [repository][snap]. These are updated on every commit. + +[mdc]: https://github.com/material-components/material-components-android +[compose]: https://developer.android.com/jetpack/compose +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#materialtheme +[snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-themeadapter-material/ diff --git a/themeadapter-material/api/current.api b/themeadapter-material/api/current.api new file mode 100644 index 000000000..c087c8356 --- /dev/null +++ b/themeadapter-material/api/current.api @@ -0,0 +1,27 @@ +// Signature format: 4.0 +package com.google.accompanist.themeadapter.material { + + public final class MdcTheme { + method @androidx.compose.runtime.Composable public static void MdcTheme(optional android.content.Context context, optional boolean readColors, optional boolean readTypography, optional boolean readShapes, optional boolean setTextColors, optional boolean setDefaultFontFamily, kotlin.jvm.functions.Function0 content); + method public static com.google.accompanist.themeadapter.material.ThemeParameters createMdcTheme(android.content.Context context, androidx.compose.ui.unit.LayoutDirection layoutDirection, optional androidx.compose.ui.unit.Density density, optional boolean readColors, optional boolean readTypography, optional boolean readShapes, optional boolean setTextColors, optional boolean setDefaultFontFamily); + } + + public final class ThemeParameters { + ctor public ThemeParameters(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography, androidx.compose.material.Shapes? shapes); + method public androidx.compose.material.Colors? component1(); + method public androidx.compose.material.Typography? component2(); + method public androidx.compose.material.Shapes? component3(); + method public com.google.accompanist.themeadapter.material.ThemeParameters copy(androidx.compose.material.Colors? colors, androidx.compose.material.Typography? typography, androidx.compose.material.Shapes? shapes); + method public androidx.compose.material.Colors? getColors(); + method public androidx.compose.material.Shapes? getShapes(); + method public androidx.compose.material.Typography? getTypography(); + property public final androidx.compose.material.Colors? colors; + property public final androidx.compose.material.Shapes? shapes; + property public final androidx.compose.material.Typography? typography; + } + + public final class TypographyKt { + } + +} + diff --git a/themeadapter-material/build.gradle b/themeadapter-material/build.gradle new file mode 100644 index 000000000..183d38441 --- /dev/null +++ b/themeadapter-material/build.gradle @@ -0,0 +1,111 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +kotlin { + explicitApi() +} + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + // targetSdkVersion has no effect for libraries. This is only used for the test APK + targetSdkVersion 33 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose true + buildConfig false + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Multiple dependencies bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + animationsDisabled true + } + + sourceSets { + test { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + androidTest { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + } +} + +dependencies { + implementation(project(':themeadapter-core')) + + implementation libs.compose.material.material + + // ====================== + // Test dependencies + // ====================== + + androidTestImplementation project(':internal-testutils') + testImplementation project(':internal-testutils') + + androidTestImplementation libs.junit + testImplementation libs.junit + + androidTestImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.junit4 + + androidTestImplementation libs.androidx.test.runner + testImplementation libs.androidx.test.runner + + androidTestImplementation libs.androidx.test.espressoCore + testImplementation libs.androidx.test.espressoCore + + testImplementation libs.robolectric +} + +apply plugin: 'com.vanniktech.maven.publish' diff --git a/themeadapter-material/gradle.properties b/themeadapter-material/gradle.properties new file mode 100644 index 000000000..27967de2d --- /dev/null +++ b/themeadapter-material/gradle.properties @@ -0,0 +1,19 @@ +# +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +POM_ARTIFACT_ID=accompanist-themeadapter-material +POM_NAME=Material Theme Adapter for Compose +POM_PACKAGING=aar diff --git a/themeadapter-material/src/androidTest/AndroidManifest.xml b/themeadapter-material/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..1388817e6 --- /dev/null +++ b/themeadapter-material/src/androidTest/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/themeadapter-material/src/androidTest/kotlin/com/google/accompanist/themeadapter/material/InstrumentedMdcThemeTest.kt b/themeadapter-material/src/androidTest/kotlin/com/google/accompanist/themeadapter/material/InstrumentedMdcThemeTest.kt new file mode 100644 index 000000000..68f980c78 --- /dev/null +++ b/themeadapter-material/src/androidTest/kotlin/com/google/accompanist/themeadapter/material/InstrumentedMdcThemeTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.toFontFamily +import androidx.test.filters.SdkSuppress +import com.google.accompanist.themeadapter.material.test.R +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Version of [BaseMdcThemeTest] which is designed to be run on device/emulators. + */ +@RunWith(Parameterized::class) +class InstrumentedMdcThemeTest( + activityClass: Class +) : BaseMdcThemeTest(activityClass) { + companion object { + @JvmStatic + @Parameterized.Parameters + fun activities() = listOf( + DarkMdcActivity::class.java, + LightMdcActivity::class.java + ) + } + + /** + * On API 21-22, the family is loaded with only the 400 font. + * + * This only works on device as Robolectric seems to always use the behavior from API 23+, + * which is not what we want to test. + */ + @Test + @SdkSuppress(maxSdkVersion = 22) + fun type_rubik_family_api21() = composeTestRule.setContent { + val rubik = Font(R.font.rubik, FontWeight.W400).toFontFamily() + WithThemeOverlay(R.style.ThemeOverlay_MdcThemeTest_DefaultFontFamily_Rubik) { + MdcTheme(setDefaultFontFamily = true) { + MaterialTheme.typography.assertFontFamilies(expected = rubik) + } + } + WithThemeOverlay(R.style.ThemeOverlay_MdcThemeTest_DefaultAndroidFontFamily_Rubik) { + MdcTheme(setDefaultFontFamily = true) { + MaterialTheme.typography.assertFontFamilies(expected = rubik) + } + } + } +} diff --git a/themeadapter-material/src/main/AndroidManifest.xml b/themeadapter-material/src/main/AndroidManifest.xml new file mode 100644 index 000000000..3c63a2f40 --- /dev/null +++ b/themeadapter-material/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/themeadapter-material/src/main/java/com/google/accompanist/themeadapter/material/MdcTheme.kt b/themeadapter-material/src/main/java/com/google/accompanist/themeadapter/material/MdcTheme.kt new file mode 100644 index 000000000..c677d539d --- /dev/null +++ b/themeadapter-material/src/main/java/com/google/accompanist/themeadapter/material/MdcTheme.kt @@ -0,0 +1,402 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("MdcTheme") + +package com.google.accompanist.themeadapter.material + +import android.content.Context +import android.content.res.Resources +import android.view.View +import androidx.compose.material.Colors +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.Typography +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.core.content.res.getResourceIdOrThrow +import androidx.core.content.res.use +import com.google.accompanist.themeadapter.core.FontFamilyWithWeight +import com.google.accompanist.themeadapter.core.parseColor +import com.google.accompanist.themeadapter.core.parseFontFamily +import com.google.accompanist.themeadapter.core.parseShapeAppearance +import com.google.accompanist.themeadapter.core.parseTextAppearance +import java.lang.reflect.Method + +/** + * A [MaterialTheme] which reads the corresponding values from a Material Components for Android + * theme in the given [context]. + * + * By default the text colors from any associated `TextAppearance`s from the theme are *not* read. + * This is because setting a fixed color in the resulting [TextStyle] breaks the usage of + * [androidx.compose.material.ContentAlpha] through [androidx.compose.material.LocalContentAlpha]. + * You can customize this through the [setTextColors] parameter. + * + * For [Shapes], the configuration layout direction is taken into account when reading corner sizes + * of `ShapeAppearance`s from the theme. For example, [Shapes.medium.topStart] will be read from + * `cornerSizeTopLeft` for [View.LAYOUT_DIRECTION_LTR] and `cornerSizeTopRight` for + * [View.LAYOUT_DIRECTION_RTL]. + * + * @param context The context to read the theme from. + * @param readColors whether the read the MDC color palette from the [context]'s theme. + * If `false`, the current value of [MaterialTheme.colors] is preserved. + * @param readTypography whether the read the MDC text appearances from [context]'s theme. + * If `false`, the current value of [MaterialTheme.typography] is preserved. + * @param readShapes whether the read the MDC shape appearances from the [context]'s theme. + * If `false`, the current value of [MaterialTheme.shapes] is preserved. + * @param setTextColors whether to read the colors from the `TextAppearance`s associated from the + * theme. Defaults to `false`. + * @param setDefaultFontFamily whether to read and prioritize the `fontFamily` attributes from + * [context]'s theme, over any specified in the MDC text appearances. Defaults to `false`. + */ +@Composable +fun MdcTheme( + context: Context = LocalContext.current, + readColors: Boolean = true, + readTypography: Boolean = true, + readShapes: Boolean = true, + setTextColors: Boolean = false, + setDefaultFontFamily: Boolean = false, + content: @Composable () -> Unit +) { + // We try and use the theme key value if available, which should be a perfect key for caching + // and avoid the expensive theme lookups in re-compositions. + // + // If the key is not available, we use the Theme itself as a rough approximation. Using the + // Theme instance as the key is not perfect, but it should work for 90% of cases. + // It falls down when the theme is manually mutated after a composition has happened + // (via `applyStyle()`, `rebase()`, `setTo()`), but the majority of apps do not use those. + val key = context.theme.key ?: context.theme + + val layoutDirection = LocalLayoutDirection.current + + val themeParams = remember(key) { + createMdcTheme( + context = context, + layoutDirection = layoutDirection, + readColors = readColors, + readTypography = readTypography, + readShapes = readShapes, + setTextColors = setTextColors, + setDefaultFontFamily = setDefaultFontFamily + ) + } + + MaterialTheme( + colors = themeParams.colors ?: MaterialTheme.colors, + typography = themeParams.typography ?: MaterialTheme.typography, + shapes = themeParams.shapes ?: MaterialTheme.shapes, + ) { + // We update the LocalContentColor to match our onBackground. This allows the default + // content color to be more appropriate to the theme background + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colors.onBackground, + content = content + ) + } +} + +/** + * This class contains the individual components of a [MaterialTheme]: [Colors], [Typography] + * and [Shapes]. + */ +data class ThemeParameters( + val colors: Colors?, + val typography: Typography?, + val shapes: Shapes? +) + +/** + * This function creates the components of a [androidx.compose.material.MaterialTheme], reading the + * values from an Material Components for Android theme. + * + * By default the text colors from any associated `TextAppearance`s from the theme are *not* read. + * This is because setting a fixed color in the resulting [TextStyle] breaks the usage of + * [androidx.compose.material.ContentAlpha] through [androidx.compose.material.LocalContentAlpha]. + * You can customize this through the [setTextColors] parameter. + * + * For [Shapes], the [layoutDirection] is taken into account when reading corner sizes of + * `ShapeAppearance`s from the theme. For example, [Shapes.medium.topStart] will be read from + * `cornerSizeTopLeft` for [LayoutDirection.Ltr] and `cornerSizeTopRight` for [LayoutDirection.Rtl]. + * + * The individual components of the returned [ThemeParameters] may be `null`, depending on the + * matching 'read' parameter. For example, if you set [readColors] to `false`, + * [ThemeParameters.colors] will be null. + * + * @param context The context to read the theme from. + * @param layoutDirection The layout direction to be used when reading shapes. + * @param density The current density. + * @param readColors whether the read the MDC color palette from the [context]'s theme. + * @param readTypography whether the read the MDC text appearances from [context]'s theme. + * @param readShapes whether the read the MDC shape appearances from the [context]'s theme. + * @param setTextColors whether to read the colors from the `TextAppearance`s associated from the + * theme. Defaults to `false`. + * @param setDefaultFontFamily whether to read and prioritize the `fontFamily` attributes from + * [context]'s theme, over any specified in the MDC text appearances. Defaults to `false`. + * @return [ThemeParameters] instance containing the resulting [Colors], [Typography] + * and [Shapes]. + */ +fun createMdcTheme( + context: Context, + layoutDirection: LayoutDirection, + density: Density = Density(context), + readColors: Boolean = true, + readTypography: Boolean = true, + readShapes: Boolean = true, + setTextColors: Boolean = false, + setDefaultFontFamily: Boolean = false +): ThemeParameters { + return context.obtainStyledAttributes(R.styleable.ThemeAdapterMaterialTheme).use { ta -> + require(ta.hasValue(R.styleable.ThemeAdapterMaterialTheme_isMaterialTheme)) { + "createMdcTheme requires the host context's theme" + + " to extend Theme.MaterialComponents" + } + + val colors: Colors? = if (readColors) { + /* First we'll read the Material color palette */ + val primary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorPrimary) + val primaryVariant = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorPrimaryVariant) + val onPrimary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnPrimary) + val secondary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSecondary) + val secondaryVariant = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSecondaryVariant) + val onSecondary = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSecondary) + val background = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_android_colorBackground) + val onBackground = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnBackground) + val surface = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorSurface) + val onSurface = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnSurface) + val error = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorError) + val onError = ta.parseColor(R.styleable.ThemeAdapterMaterialTheme_colorOnError) + + val isLightTheme = ta.getBoolean(R.styleable.ThemeAdapterMaterialTheme_isLightTheme, true) + + if (isLightTheme) { + lightColors( + primary = primary, + primaryVariant = primaryVariant, + onPrimary = onPrimary, + secondary = secondary, + secondaryVariant = secondaryVariant, + onSecondary = onSecondary, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + error = error, + onError = onError + ) + } else { + darkColors( + primary = primary, + primaryVariant = primaryVariant, + onPrimary = onPrimary, + secondary = secondary, + secondaryVariant = secondaryVariant, + onSecondary = onSecondary, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + error = error, + onError = onError + ) + } + } else null + + /** + * Next we'll create a typography instance, using the Material Theme text appearances + * for TextStyles. + * + * We create a normal 'empty' instance first to start from the defaults, then merge in our + * created text styles from the Android theme. + */ + + val typography = if (readTypography) { + val defaultFontFamily = if (setDefaultFontFamily) { + val defaultFontFamilyWithWeight: FontFamilyWithWeight? = ta.parseFontFamily( + R.styleable.ThemeAdapterMaterialTheme_fontFamily + ) ?: ta.parseFontFamily(R.styleable.ThemeAdapterMaterialTheme_android_fontFamily) + defaultFontFamilyWithWeight?.fontFamily + } else { + null + } + Typography(defaultFontFamily = defaultFontFamily ?: FontFamily.Default).merge( + h1 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceHeadline1), + density, + setTextColors, + defaultFontFamily + ), + h2 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceHeadline2), + density, + setTextColors, + defaultFontFamily + ), + h3 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceHeadline3), + density, + setTextColors, + defaultFontFamily + ), + h4 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceHeadline4), + density, + setTextColors, + defaultFontFamily + ), + h5 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceHeadline5), + density, + setTextColors, + defaultFontFamily + ), + h6 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceHeadline6), + density, + setTextColors, + defaultFontFamily + ), + subtitle1 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceSubtitle1), + density, + setTextColors, + defaultFontFamily + ), + subtitle2 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceSubtitle2), + density, + setTextColors, + defaultFontFamily + ), + body1 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceBody1), + density, + setTextColors, + defaultFontFamily + ), + body2 = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceBody2), + density, + setTextColors, + defaultFontFamily + ), + button = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceButton), + density, + setTextColors, + defaultFontFamily + ), + caption = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceCaption), + density, + setTextColors, + defaultFontFamily + ), + overline = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_textAppearanceOverline), + density, + setTextColors, + defaultFontFamily + ) + ) + } else null + + /** + * Now read the shape appearances, taking into account the layout direction. + */ + val shapes = if (readShapes) { + Shapes( + small = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_shapeAppearanceSmallComponent), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.small + ), + medium = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_shapeAppearanceMediumComponent), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.medium, + ), + large = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterialTheme_shapeAppearanceLargeComponent), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.large + ) + ) + } else null + + ThemeParameters(colors, typography, shapes) + } +} + +private val emptyShapes = Shapes() + +/** + * This is gross, but we need a way to check for theme equality. Theme does not implement + * `equals()` or `hashCode()`, but it does have a hidden method called `getKey()`. + * + * The cost of this reflective invoke is a lot cheaper than the full theme read which can + * happen on each re-composition. + */ +private inline val Resources.Theme.key: Any? + get() { + if (!sThemeGetKeyMethodFetched) { + try { + @Suppress("SoonBlockedPrivateApi") + sThemeGetKeyMethod = Resources.Theme::class.java.getDeclaredMethod("getKey") + .apply { isAccessible = true } + } catch (e: ReflectiveOperationException) { + // Failed to retrieve Theme.getKey method + } + sThemeGetKeyMethodFetched = true + } + if (sThemeGetKeyMethod != null) { + return try { + sThemeGetKeyMethod?.invoke(this) + } catch (e: ReflectiveOperationException) { + // Failed to invoke Theme.getKey() + } + } + return null + } + +private var sThemeGetKeyMethodFetched = false +private var sThemeGetKeyMethod: Method? = null diff --git a/themeadapter-material/src/main/java/com/google/accompanist/themeadapter/material/Typography.kt b/themeadapter-material/src/main/java/com/google/accompanist/themeadapter/material/Typography.kt new file mode 100644 index 000000000..b8ce85d92 --- /dev/null +++ b/themeadapter-material/src/main/java/com/google/accompanist/themeadapter/material/Typography.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle + +private val emptyTextStyle = TextStyle() + +internal fun Typography.merge( + h1: TextStyle = emptyTextStyle, + h2: TextStyle = emptyTextStyle, + h3: TextStyle = emptyTextStyle, + h4: TextStyle = emptyTextStyle, + h5: TextStyle = emptyTextStyle, + h6: TextStyle = emptyTextStyle, + subtitle1: TextStyle = emptyTextStyle, + subtitle2: TextStyle = emptyTextStyle, + body1: TextStyle = emptyTextStyle, + body2: TextStyle = emptyTextStyle, + button: TextStyle = emptyTextStyle, + caption: TextStyle = emptyTextStyle, + overline: TextStyle = emptyTextStyle +) = copy( + h1 = this.h1.merge(h1), + h2 = this.h2.merge(h2), + h3 = this.h3.merge(h3), + h4 = this.h4.merge(h4), + h5 = this.h5.merge(h5), + h6 = this.h6.merge(h6), + subtitle1 = this.subtitle1.merge(subtitle1), + subtitle2 = this.subtitle2.merge(subtitle2), + body1 = this.body1.merge(body1), + body2 = this.body2.merge(body2), + button = this.button.merge(button), + caption = this.caption.merge(caption), + overline = this.overline.merge(overline) +) diff --git a/themeadapter-material/src/main/res/values/theme_attrs.xml b/themeadapter-material/src/main/res/values/theme_attrs.xml new file mode 100644 index 000000000..29ba5f193 --- /dev/null +++ b/themeadapter-material/src/main/res/values/theme_attrs.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/BaseMdcThemeTest.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/BaseMdcThemeTest.kt new file mode 100644 index 000000000..a5722cbd9 --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/BaseMdcThemeTest.kt @@ -0,0 +1,311 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import android.view.ContextThemeWrapper +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.toFontFamily +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.test.filters.SdkSuppress +import com.google.accompanist.themeadapter.core.FontFamilyWithWeight +import com.google.accompanist.themeadapter.material.test.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * Class which contains the majority of the tests. This class is extended + * in both the `androidTest` and `test` source sets for setup of the relevant + * test runner. + */ +abstract class BaseMdcThemeTest( + activityClass: Class +) { + @get:Rule + val composeTestRule = createAndroidComposeRule(activityClass) + + @Test + fun colors() = composeTestRule.setContent { + MdcTheme { + val color = MaterialTheme.colors + + assertEquals(colorResource(R.color.aquamarine), color.primary) + assertEquals(colorResource(R.color.royal_blue), color.primaryVariant) + assertEquals(colorResource(R.color.midnight_blue), color.onPrimary) + + assertEquals(colorResource(R.color.dark_golden_rod), color.secondary) + assertEquals(colorResource(R.color.slate_gray), color.onSecondary) + assertEquals(colorResource(R.color.blue_violet), color.secondaryVariant) + + assertEquals(colorResource(R.color.spring_green), color.surface) + assertEquals(colorResource(R.color.navy), color.onSurface) + + assertEquals(colorResource(R.color.dark_salmon), color.error) + assertEquals(colorResource(R.color.beige), color.onError) + + assertEquals(colorResource(R.color.light_coral), color.background) + assertEquals(colorResource(R.color.orchid), color.onBackground) + + // MdcTheme updates the LocalContentColor to match the calculated onBackground + assertEquals(colorResource(R.color.orchid), LocalContentColor.current) + } + } + + @Test + fun shapes() = composeTestRule.setContent { + MdcTheme { + val shapes = MaterialTheme.shapes + val density = LocalDensity.current + + shapes.small.run { + assertTrue(this is CutCornerShape) + assertEquals(4f, topStart.toPx(density)) + assertEquals(9.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(5f, bottomEnd.toPx(density)) + assertEquals(3.dp.scaleToPx(density), bottomStart.toPx(density)) + } + shapes.medium.run { + assertTrue(this is RoundedCornerShape) + assertEquals(12.dp.scaleToPx(density), topStart.toPx(density)) + assertEquals(12.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(12.dp.scaleToPx(density), bottomEnd.toPx(density)) + assertEquals(12.dp.scaleToPx(density), bottomStart.toPx(density)) + } + shapes.large.run { + assertTrue(this is CutCornerShape) + assertEquals(0f, topStart.toPx(density)) + assertEquals(0f, topEnd.toPx(density)) + assertEquals(0f, bottomEnd.toPx(density)) + assertEquals(0f, bottomStart.toPx(density)) + } + } + } + + @Test + fun type() = composeTestRule.setContent { + MdcTheme { + val typography = MaterialTheme.typography + val density = LocalDensity.current + + val rubik300 = Font(R.font.rubik_300).toFontFamily() + val rubik400 = Font(R.font.rubik_400).toFontFamily() + val rubik500 = Font(R.font.rubik_500).toFontFamily() + val sansSerif = FontFamilyWithWeight(FontFamily.SansSerif) + val sansSerifLight = FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Light) + val sansSerifBlack = FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Black) + val serif = FontFamilyWithWeight(FontFamily.Serif) + val cursive = FontFamilyWithWeight(FontFamily.Cursive) + val monospace = FontFamilyWithWeight(FontFamily.Monospace) + + typography.h1.run { + assertTextUnitEquals(97.54.sp, fontSize, density) + assertTextUnitEquals((-0.0015).em, letterSpacing, density) + assertEquals(rubik300, fontFamily) + } + + assertNotNull(typography.h2.shadow) + typography.h2.shadow!!.run { + assertEquals(colorResource(R.color.olive_drab), color) + assertEquals(4.43f, offset.x) + assertEquals(8.19f, offset.y) + assertEquals(2.13f, blurRadius) + } + + typography.h3.run { + assertEquals(sansSerif.fontFamily, fontFamily) + assertEquals(sansSerif.weight, fontWeight) + } + + typography.h4.run { + assertEquals(sansSerifLight.fontFamily, fontFamily) + assertEquals(sansSerifLight.weight, fontWeight) + } + + typography.h5.run { + assertEquals(sansSerifBlack.fontFamily, fontFamily) + assertEquals(sansSerifBlack.weight, fontWeight) + } + + typography.h6.run { + assertEquals(serif.fontFamily, fontFamily) + assertEquals(serif.weight, fontWeight) + } + + typography.body1.run { + assertTextUnitEquals(16.26.sp, fontSize, density) + assertTextUnitEquals(0.005.em, letterSpacing, density) + assertEquals(rubik400, fontFamily) + assertNull(shadow) + } + + typography.body2.run { + assertEquals(cursive.fontFamily, fontFamily) + assertEquals(cursive.weight, fontWeight) + } + + typography.subtitle1.run { + assertEquals(monospace.fontFamily, fontFamily) + assertEquals(monospace.weight, fontWeight) + assertTextUnitEquals(0.em, letterSpacing, density) + } + + typography.subtitle2.run { + assertEquals(FontFamily.SansSerif, fontFamily) + } + + typography.button.run { + assertEquals(rubik500, fontFamily) + } + + typography.caption.run { + assertEquals(FontFamily.SansSerif, fontFamily) + assertTextUnitEquals(0.04.em, letterSpacing, density) + } + + typography.overline.run { + assertEquals(FontFamily.SansSerif, fontFamily) + } + } + } + + @Test + @SdkSuppress(minSdkVersion = 23) // XML font families with >1 fonts are only supported on API 23+ + fun type_rubik_family_api23() = composeTestRule.setContent { + val rubik = FontFamily( + Font(R.font.rubik_300, FontWeight.W300), + Font(R.font.rubik_400, FontWeight.W400), + Font(R.font.rubik_500, FontWeight.W500), + Font(R.font.rubik_700, FontWeight.W700), + ) + WithThemeOverlay(R.style.ThemeOverlay_MdcThemeTest_DefaultFontFamily_Rubik) { + MdcTheme(setDefaultFontFamily = true) { + MaterialTheme.typography.assertFontFamilies(expected = rubik) + } + } + WithThemeOverlay(R.style.ThemeOverlay_MdcThemeTest_DefaultAndroidFontFamily_Rubik) { + MdcTheme(setDefaultFontFamily = true) { + MaterialTheme.typography.assertFontFamilies(expected = rubik) + } + } + } + + @Test + fun type_rubik_fixed400() = composeTestRule.setContent { + val rubik400 = Font(R.font.rubik_400, FontWeight.W400).toFontFamily() + WithThemeOverlay(R.style.ThemeOverlay_MdcThemeTest_DefaultFontFamily_Rubik400) { + MdcTheme(setDefaultFontFamily = true) { + MaterialTheme.typography.assertFontFamilies(expected = rubik400) + } + } + WithThemeOverlay(R.style.ThemeOverlay_MdcThemeTest_DefaultAndroidFontFamily_Rubik400) { + MdcTheme(setDefaultFontFamily = true) { + MaterialTheme.typography.assertFontFamilies(expected = rubik400) + } + } + } + + @Test + fun type_rubik_fixed700_withTextAppearances() = composeTestRule.setContent { + val rubik700 = Font(R.font.rubik_700, FontWeight.W700).toFontFamily() + WithThemeOverlay( + R.style.ThemeOverlay_MdcThemeTest_DefaultFontFamilies_Rubik700_WithTextAppearances + ) { + MdcTheme { + MaterialTheme.typography.assertFontFamilies( + expected = rubik700, + notEquals = true + ) + } + } + } +} + +private fun Dp.scaleToPx(density: Density): Float { + val dp = this + return with(density) { dp.toPx() } +} + +private fun assertTextUnitEquals(expected: TextUnit, actual: TextUnit, density: Density) { + if (expected.javaClass == actual.javaClass) { + // If the expected and actual are the same type, compare the raw values with a + // delta to account for float inaccuracy + assertEquals(expected.value, actual.value, 0.001f) + } else { + // Otherwise we need to flatten to a px to compare the values. Again using a + // delta to account for float inaccuracy + with(density) { assertEquals(expected.toPx(), actual.toPx(), 0.001f) } + } +} + +private fun CornerSize.toPx(density: Density) = toPx(Size.Unspecified, density) + +internal fun Typography.assertFontFamilies( + expected: FontFamily, + notEquals: Boolean = false +) { + if (notEquals) assertNotEquals(expected, h1.fontFamily) else assertEquals(expected, h1.fontFamily) + if (notEquals) assertNotEquals(expected, h2.fontFamily) else assertEquals(expected, h2.fontFamily) + if (notEquals) assertNotEquals(expected, h3.fontFamily) else assertEquals(expected, h3.fontFamily) + if (notEquals) assertNotEquals(expected, h4.fontFamily) else assertEquals(expected, h4.fontFamily) + if (notEquals) assertNotEquals(expected, h5.fontFamily) else assertEquals(expected, h5.fontFamily) + if (notEquals) assertNotEquals(expected, h6.fontFamily) else assertEquals(expected, h6.fontFamily) + if (notEquals) assertNotEquals(expected, subtitle1.fontFamily) else assertEquals(expected, subtitle1.fontFamily) + if (notEquals) assertNotEquals(expected, subtitle2.fontFamily) else assertEquals(expected, subtitle2.fontFamily) + if (notEquals) assertNotEquals(expected, body1.fontFamily) else assertEquals(expected, body1.fontFamily) + if (notEquals) assertNotEquals(expected, body2.fontFamily) else assertEquals(expected, body2.fontFamily) + if (notEquals) assertNotEquals(expected, button.fontFamily) else assertEquals(expected, button.fontFamily) + if (notEquals) assertNotEquals(expected, caption.fontFamily) else assertEquals(expected, caption.fontFamily) + if (notEquals) assertNotEquals(expected, overline.fontFamily) else assertEquals(expected, overline.fontFamily) +} + +/** + * Function which applies an Android theme overlay to the current context. + */ +@Composable +fun WithThemeOverlay( + @StyleRes themeOverlayId: Int, + content: @Composable () -> Unit, +) { + val themedContext = ContextThemeWrapper(LocalContext.current, themeOverlayId) + CompositionLocalProvider(LocalContext provides themedContext, content = content) +} diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/DarkMdcActivity.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/DarkMdcActivity.kt new file mode 100644 index 000000000..5d27cc6cb --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/DarkMdcActivity.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate + +/** + * An [AppCompatActivity] which forces the night mode to 'dark theme'. + */ +class DarkMdcActivity : AppCompatActivity() { + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES + super.attachBaseContext(newBase) + } +} diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/DarkTheme.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/DarkTheme.kt new file mode 100644 index 000000000..1df3e3c5c --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/DarkTheme.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import android.content.Context +import android.content.res.Configuration + +/** + * This allows us to check whether this [Context]s resource configuration is in 'night mode', + * which is also known as dark theme. + */ +fun Context.isInDarkTheme(): Boolean { + return resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES +} diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/FakeTests.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/FakeTests.kt new file mode 100644 index 000000000..d995aed53 --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/FakeTests.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Fake tests to help with sharding: https://github.com/android/android-test/issues/973 + */ +@RunWith(JUnit4::class) +class FakeTests { + @Test + fun fake1() = Unit + + @Test + fun fake2() = Unit + + @Test + fun fake3() = Unit + + @Test + fun fake4() = Unit + + @Test + fun fake5() = Unit + + @Test + fun fake6() = Unit + + @Test + fun fake7() = Unit + + @Test + fun fake8() = Unit + + @Test + fun fake9() = Unit + + @Test + fun fake10() = Unit +} diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/LightMdcActivity.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/LightMdcActivity.kt new file mode 100644 index 000000000..7176fadd6 --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/LightMdcActivity.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate + +/** + * An [AppCompatActivity] which forces the night mode to 'light theme'. + */ +class LightMdcActivity : AppCompatActivity() { + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_NO + super.attachBaseContext(newBase) + } +} diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/NotMdcActivity.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/NotMdcActivity.kt new file mode 100644 index 000000000..340a37aa7 --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/NotMdcActivity.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import androidx.appcompat.app.AppCompatActivity + +class NotMdcActivity : AppCompatActivity() diff --git a/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/NotMdcThemeTest.kt b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/NotMdcThemeTest.kt new file mode 100644 index 000000000..be7bc1daf --- /dev/null +++ b/themeadapter-material/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material/NotMdcThemeTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NotMdcThemeTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test(expected = IllegalArgumentException::class) + fun throwForNonMdcTheme() = composeTestRule.setContent { + MdcTheme { + // Nothing to do here, exception should be thrown + } + } +} diff --git a/themeadapter-material/src/sharedTest/res/font/rubik.xml b/themeadapter-material/src/sharedTest/res/font/rubik.xml new file mode 100644 index 000000000..881fa013a --- /dev/null +++ b/themeadapter-material/src/sharedTest/res/font/rubik.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material/src/sharedTest/res/font/rubik_300.ttf b/themeadapter-material/src/sharedTest/res/font/rubik_300.ttf new file mode 100644 index 000000000..8189d848f Binary files /dev/null and b/themeadapter-material/src/sharedTest/res/font/rubik_300.ttf differ diff --git a/themeadapter-material/src/sharedTest/res/font/rubik_400.ttf b/themeadapter-material/src/sharedTest/res/font/rubik_400.ttf new file mode 100644 index 000000000..52b59ca4f Binary files /dev/null and b/themeadapter-material/src/sharedTest/res/font/rubik_400.ttf differ diff --git a/themeadapter-material/src/sharedTest/res/font/rubik_500.ttf b/themeadapter-material/src/sharedTest/res/font/rubik_500.ttf new file mode 100644 index 000000000..9e358b2f4 Binary files /dev/null and b/themeadapter-material/src/sharedTest/res/font/rubik_500.ttf differ diff --git a/themeadapter-material/src/sharedTest/res/font/rubik_700.ttf b/themeadapter-material/src/sharedTest/res/font/rubik_700.ttf new file mode 100644 index 000000000..4e77930f4 Binary files /dev/null and b/themeadapter-material/src/sharedTest/res/font/rubik_700.ttf differ diff --git a/themeadapter-material/src/sharedTest/res/values/styles.xml b/themeadapter-material/src/sharedTest/res/values/styles.xml new file mode 100644 index 000000000..9dab015a8 --- /dev/null +++ b/themeadapter-material/src/sharedTest/res/values/styles.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material/src/sharedTest/res/values/test_colors.xml b/themeadapter-material/src/sharedTest/res/values/test_colors.xml new file mode 100644 index 000000000..37daf3eb2 --- /dev/null +++ b/themeadapter-material/src/sharedTest/res/values/test_colors.xml @@ -0,0 +1,33 @@ + + + + + + #7FFFD4 + #4169E1 + #191970 + #B8860B + #8A2BE2 + #708090 + #00FA9A + #000080 + #F08080 + #DA70D6 + #E9967A + #F5F5DC + #6B8E23 + \ No newline at end of file diff --git a/themeadapter-material/src/sharedTest/res/values/themes.xml b/themeadapter-material/src/sharedTest/res/values/themes.xml new file mode 100644 index 000000000..3ce7358d7 --- /dev/null +++ b/themeadapter-material/src/sharedTest/res/values/themes.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material/src/sharedTest/res/values/type.xml b/themeadapter-material/src/sharedTest/res/values/type.xml new file mode 100644 index 000000000..e82ba0217 --- /dev/null +++ b/themeadapter-material/src/sharedTest/res/values/type.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sans-serif-condensed-light + + + + \ No newline at end of file diff --git a/themeadapter-material/src/test/AndroidManifest.xml b/themeadapter-material/src/test/AndroidManifest.xml new file mode 100644 index 000000000..1388817e6 --- /dev/null +++ b/themeadapter-material/src/test/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/themeadapter-material/src/test/kotlin/com/google/accompanist/themeadapter/material/RobolectricMdcThemeTest.kt b/themeadapter-material/src/test/kotlin/com/google/accompanist/themeadapter/material/RobolectricMdcThemeTest.kt new file mode 100644 index 000000000..da774a831 --- /dev/null +++ b/themeadapter-material/src/test/kotlin/com/google/accompanist/themeadapter/material/RobolectricMdcThemeTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import androidx.appcompat.app.AppCompatActivity +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +/** + * Version of [BaseMdcThemeTest] which is designed to be run using Robolectric. + * + * All of the tests are provided by [BaseMdcThemeTest]. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class RobolectricMdcThemeTest( + activityClass: Class +) : BaseMdcThemeTest(activityClass) { + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun activities() = listOf( + DarkMdcActivity::class.java, + LightMdcActivity::class.java + ) + } +} diff --git a/themeadapter-material/src/test/resources/robolectric.properties b/themeadapter-material/src/test/resources/robolectric.properties new file mode 100644 index 000000000..2806eaffa --- /dev/null +++ b/themeadapter-material/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Pin SDK to 30 since Robolectric does not currently support API 31: +# https://github.com/robolectric/robolectric/issues/6635 +sdk=30 diff --git a/themeadapter-material3/README.md b/themeadapter-material3/README.md new file mode 100644 index 000000000..fb199a98c --- /dev/null +++ b/themeadapter-material3/README.md @@ -0,0 +1,38 @@ +# Material 3 Theme Adapter + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-themeadapter-material3)](https://search.maven.org/search?q=g:com.google.accompanist) + +Material 3 Theme Adapter enables the reuse of [MDC-Android][mdc] Material 3 XML themes, for theming in [Jetpack Compose][compose]. + +## Usage + +This library attempts to bridge the gap between [MDC-Android][mdc] Material 3 XML themes, and themes in [Jetpack Compose][compose], +allowing your composable [`MaterialTheme`][materialtheme] to be based on the `Activity`'s XML theme: + +``` kotlin +Mdc3Theme { + // MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes + // will now contain copies of the context's theme +} +``` + +For more information, visit the documentation: https://google.github.io/accompanist/themeadapter-material3 + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-themeadapter-material3:" +} +``` + +Snapshots of the development version are available in Sonatype's `snapshots` [repository][snap]. These are updated on every commit. + +[mdc]: https://github.com/material-components/material-components-android +[compose]: https://developer.android.com/jetpack/compose +[materialtheme]: https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#materialtheme +[snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-themeadapter-material3/ diff --git a/themeadapter-material3/api/current.api b/themeadapter-material3/api/current.api new file mode 100644 index 000000000..1aac4b9e2 --- /dev/null +++ b/themeadapter-material3/api/current.api @@ -0,0 +1,24 @@ +// Signature format: 4.0 +package com.google.accompanist.themeadapter.material3 { + + public final class Mdc3Theme { + method @androidx.compose.runtime.Composable public static void Mdc3Theme(optional android.content.Context context, optional boolean readColorScheme, optional boolean readTypography, optional boolean readShapes, optional boolean setTextColors, optional boolean setDefaultFontFamily, kotlin.jvm.functions.Function0 content); + method public static com.google.accompanist.themeadapter.material3.Theme3Parameters createMdc3Theme(android.content.Context context, androidx.compose.ui.unit.LayoutDirection layoutDirection, optional androidx.compose.ui.unit.Density density, optional boolean readColorScheme, optional boolean readTypography, optional boolean readShapes, optional boolean setTextColors, optional boolean setDefaultFontFamily); + } + + public final class Theme3Parameters { + ctor public Theme3Parameters(androidx.compose.material3.ColorScheme? colorScheme, androidx.compose.material3.Typography? typography, androidx.compose.material3.Shapes? shapes); + method public androidx.compose.material3.ColorScheme? component1(); + method public androidx.compose.material3.Typography? component2(); + method public androidx.compose.material3.Shapes? component3(); + method public com.google.accompanist.themeadapter.material3.Theme3Parameters copy(androidx.compose.material3.ColorScheme? colorScheme, androidx.compose.material3.Typography? typography, androidx.compose.material3.Shapes? shapes); + method public androidx.compose.material3.ColorScheme? getColorScheme(); + method public androidx.compose.material3.Shapes? getShapes(); + method public androidx.compose.material3.Typography? getTypography(); + property public final androidx.compose.material3.ColorScheme? colorScheme; + property public final androidx.compose.material3.Shapes? shapes; + property public final androidx.compose.material3.Typography? typography; + } + +} + diff --git a/themeadapter-material3/build.gradle b/themeadapter-material3/build.gradle new file mode 100644 index 000000000..2d08925ef --- /dev/null +++ b/themeadapter-material3/build.gradle @@ -0,0 +1,111 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +kotlin { + explicitApi() +} + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + // targetSdkVersion has no effect for libraries. This is only used for the test APK + targetSdkVersion 33 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose true + buildConfig false + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Multiple dependencies bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + animationsDisabled true + } + + sourceSets { + test { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + androidTest { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + } +} + +dependencies { + implementation(project(':themeadapter-core')) + + implementation libs.compose.material3.material3 + + // ====================== + // Test dependencies + // ====================== + + androidTestImplementation project(':internal-testutils') + testImplementation project(':internal-testutils') + + androidTestImplementation libs.junit + testImplementation libs.junit + + androidTestImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.junit4 + + androidTestImplementation libs.androidx.test.runner + testImplementation libs.androidx.test.runner + + androidTestImplementation libs.androidx.test.espressoCore + testImplementation libs.androidx.test.espressoCore + + testImplementation libs.robolectric +} + +apply plugin: 'com.vanniktech.maven.publish' diff --git a/themeadapter-material3/gradle.properties b/themeadapter-material3/gradle.properties new file mode 100644 index 000000000..741065449 --- /dev/null +++ b/themeadapter-material3/gradle.properties @@ -0,0 +1,19 @@ +# +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +POM_ARTIFACT_ID=accompanist-themeadapter-material3 +POM_NAME=Material 3 Theme Adapter for Compose +POM_PACKAGING=aar diff --git a/themeadapter-material3/src/androidTest/AndroidManifest.xml b/themeadapter-material3/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..d0601dd44 --- /dev/null +++ b/themeadapter-material3/src/androidTest/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/themeadapter-material3/src/androidTest/kotlin/com/google/accompanist/themeadapter/material3/InstrumentedMdc3ThemeTest.kt b/themeadapter-material3/src/androidTest/kotlin/com/google/accompanist/themeadapter/material3/InstrumentedMdc3ThemeTest.kt new file mode 100644 index 000000000..ff7eeff8b --- /dev/null +++ b/themeadapter-material3/src/androidTest/kotlin/com/google/accompanist/themeadapter/material3/InstrumentedMdc3ThemeTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material3 + +import androidx.appcompat.app.AppCompatActivity +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Version of [BaseMdc3ThemeTest] which is designed to be run on device/emulators. + */ +@RunWith(Parameterized::class) +class InstrumentedMdc3ThemeTest( + activityClass: Class +) : BaseMdc3ThemeTest(activityClass) { + companion object { + @JvmStatic + @Parameterized.Parameters + fun activities() = listOf( + Mdc3Activity::class.java + ) + } +} diff --git a/themeadapter-material3/src/main/AndroidManifest.xml b/themeadapter-material3/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ae5d6c5db --- /dev/null +++ b/themeadapter-material3/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/themeadapter-material3/src/main/java/com/google/accompanist/themeadapter/material3/Mdc3Theme.kt b/themeadapter-material3/src/main/java/com/google/accompanist/themeadapter/material3/Mdc3Theme.kt new file mode 100644 index 000000000..e7fbfe857 --- /dev/null +++ b/themeadapter-material3/src/main/java/com/google/accompanist/themeadapter/material3/Mdc3Theme.kt @@ -0,0 +1,469 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("Mdc3Theme") + +package com.google.accompanist.themeadapter.material3 + +import android.content.Context +import android.content.res.Resources +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.core.content.res.getResourceIdOrThrow +import androidx.core.content.res.use +import com.google.accompanist.themeadapter.core.FontFamilyWithWeight +import com.google.accompanist.themeadapter.core.parseColor +import com.google.accompanist.themeadapter.core.parseFontFamily +import com.google.accompanist.themeadapter.core.parseShapeAppearance +import com.google.accompanist.themeadapter.core.parseTextAppearance +import java.lang.reflect.Method + +/** + * A [MaterialTheme] which reads the corresponding values from a Material Components for Android + * theme in the given [context]. + * + * By default the text colors from any associated `TextAppearance`s from the theme are *not* read. + * This is because setting a fixed color in the resulting [TextStyle] breaks the usage of + * [androidx.compose.material.ContentAlpha] through [androidx.compose.material.LocalContentAlpha]. + * You can customize this through the [setTextColors] parameter. + * + * @param context The context to read the theme from. + * @param readColorScheme whether the read the MDC color palette from the [context]'s theme. + * If `false`, the current value of [MaterialTheme.colorScheme] is preserved. + * @param readTypography whether the read the MDC text appearances from [context]'s theme. + * If `false`, the current value of [MaterialTheme.typography] is preserved. + * @param readShapes whether the read the MDC shape appearances from the [context]'s theme. + * If `false`, the current value of [MaterialTheme.shapes] is preserved. + * @param setTextColors whether to read the colors from the `TextAppearance`s associated from the + * theme. Defaults to `false`. + * @param setDefaultFontFamily whether to read and prioritize the `fontFamily` attributes from + * [context]'s theme, over any specified in the MDC text appearances. Defaults to `false`. + */ +@Composable +fun Mdc3Theme( + context: Context = LocalContext.current, + readColorScheme: Boolean = true, + readTypography: Boolean = true, + readShapes: Boolean = true, + setTextColors: Boolean = false, + setDefaultFontFamily: Boolean = false, + content: @Composable () -> Unit +) { + // We try and use the theme key value if available, which should be a perfect key for caching + // and avoid the expensive theme lookups in re-compositions. + // + // If the key is not available, we use the Theme itself as a rough approximation. Using the + // Theme instance as the key is not perfect, but it should work for 90% of cases. + // It falls down when the theme is manually mutated after a composition has happened + // (via `applyStyle()`, `rebase()`, `setTo()`), but the majority of apps do not use those. + val key = context.theme.key ?: context.theme + + val layoutDirection = LocalLayoutDirection.current + + val themeParams = remember(key) { + createMdc3Theme( + context = context, + layoutDirection = layoutDirection, + readColorScheme = readColorScheme, + readTypography = readTypography, + readShapes = readShapes, + setTextColors = setTextColors, + setDefaultFontFamily = setDefaultFontFamily + ) + } + + MaterialTheme( + colorScheme = themeParams.colorScheme ?: MaterialTheme.colorScheme, + typography = themeParams.typography ?: MaterialTheme.typography, + shapes = themeParams.shapes ?: MaterialTheme.shapes + ) { + // We update the LocalContentColor to match our onBackground. This allows the default + // content color to be more appropriate to the theme background + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onBackground, + content = content + ) + } +} + +/** + * This class contains the individual components of a [MaterialTheme]: [ColorScheme] and + * [Typography]. + */ +data class Theme3Parameters( + val colorScheme: ColorScheme?, + val typography: Typography?, + val shapes: Shapes? +) + +/** + * This function creates the components of a [androidx.compose.material.MaterialTheme], reading the + * values from an Material Components for Android theme. + * + * By default the text colors from any associated `TextAppearance`s from the theme are *not* read. + * This is because setting a fixed color in the resulting [TextStyle] breaks the usage of + * [androidx.compose.material.ContentAlpha] through [androidx.compose.material.LocalContentAlpha]. + * You can customize this through the [setTextColors] parameter. + * + * For [Shapes], the [layoutDirection] is taken into account when reading corner sizes of + * `ShapeAppearance`s from the theme. For example, [Shapes.medium.topStart] will be read from + * `cornerSizeTopLeft` for [LayoutDirection.Ltr] and `cornerSizeTopRight` for [LayoutDirection.Rtl]. + * + * The individual components of the returned [Theme3Parameters] may be `null`, depending on the + * matching 'read' parameter. For example, if you set [readColorScheme] to `false`, + * [Theme3Parameters.colors] will be null. + * + * @param context The context to read the theme from. + * @param layoutDirection The layout direction to be used when reading shapes. + * @param density The current density. + * @param readColorScheme whether the read the MDC color palette from the [context]'s theme. + * @param readTypography whether the read the MDC text appearances from [context]'s theme. + * @param readShapes whether the read the MDC shape appearances from the [context]'s theme. + * @param setTextColors whether to read the colors from the `TextAppearance`s associated from the + * theme. Defaults to `false`. + * @param setDefaultFontFamily whether to read and prioritize the `fontFamily` attributes from + * [context]'s theme, over any specified in the MDC text appearances. Defaults to `false`. + * @return [Theme3Parameters] instance containing the resulting [ColorScheme] and [Typography]. + */ +fun createMdc3Theme( + context: Context, + layoutDirection: LayoutDirection, + density: Density = Density(context), + readColorScheme: Boolean = true, + readTypography: Boolean = true, + readShapes: Boolean = true, + setTextColors: Boolean = false, + setDefaultFontFamily: Boolean = false +): Theme3Parameters { + return context.obtainStyledAttributes(R.styleable.ThemeAdapterMaterial3Theme).use { ta -> + require(ta.hasValue(R.styleable.ThemeAdapterMaterial3Theme_isMaterial3Theme)) { + "createMdc3Theme requires the host context's theme to extend Theme.Material3" + } + + val colorScheme: ColorScheme? = if (readColorScheme) { + /* First we'll read the Material 3 color palette */ + val primary = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorPrimary) + val onPrimary = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnPrimary) + val primaryInverse = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorPrimaryInverse) + val primaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorPrimaryContainer) + val onPrimaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnPrimaryContainer) + val secondary = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorSecondary) + val onSecondary = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnSecondary) + val secondaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorSecondaryContainer) + val onSecondaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnSecondaryContainer) + val tertiary = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorTertiary) + val onTertiary = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnTertiary) + val tertiaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorTertiaryContainer) + val onTertiaryContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnTertiaryContainer) + val background = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_android_colorBackground) + val onBackground = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnBackground) + val surface = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorSurface) + val onSurface = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnSurface) + val surfaceVariant = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorSurfaceVariant) + val onSurfaceVariant = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnSurfaceVariant) + val elevationOverlay = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_elevationOverlayColor) + val surfaceInverse = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorSurfaceInverse) + val onSurfaceInverse = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnSurfaceInverse) + val outline = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOutline) + val error = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorError) + val onError = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnError) + val errorContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorErrorContainer) + val onErrorContainer = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_colorOnErrorContainer) + val scrimBackground = ta.parseColor(R.styleable.ThemeAdapterMaterial3Theme_scrimBackground) + + val isLightTheme = ta.getBoolean(R.styleable.ThemeAdapterMaterial3Theme_isLightTheme, true) + + if (isLightTheme) { + lightColorScheme( + primary = primary, + onPrimary = onPrimary, + inversePrimary = primaryInverse, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceTint = elevationOverlay, + inverseSurface = surfaceInverse, + inverseOnSurface = onSurfaceInverse, + outline = outline, + // TODO: MDC-Android doesn't include outlineVariant yet, add when available + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + scrim = scrimBackground + ) + } else { + darkColorScheme( + primary = primary, + onPrimary = onPrimary, + inversePrimary = primaryInverse, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceTint = elevationOverlay, + inverseSurface = surfaceInverse, + inverseOnSurface = onSurfaceInverse, + outline = outline, + // TODO: MDC-Android doesn't include outlineVariant yet, add when available + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + scrim = scrimBackground + ) + } + } else null + + /** + * Next we'll create a typography instance, using the Material Theme text appearances + * for TextStyles. + * + * We create a normal 'empty' instance first to start from the defaults, then merge in our + * created text styles from the Android theme. + */ + + val typography = if (readTypography) { + val defaultFontFamily = if (setDefaultFontFamily) { + val defaultFontFamilyWithWeight: FontFamilyWithWeight? = ta.parseFontFamily( + R.styleable.ThemeAdapterMaterial3Theme_fontFamily + ) ?: ta.parseFontFamily(R.styleable.ThemeAdapterMaterial3Theme_android_fontFamily) + defaultFontFamilyWithWeight?.fontFamily + } else { + null + } + Typography( + displayLarge = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceDisplayLarge), + density, + setTextColors, + defaultFontFamily + ), + displayMedium = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceDisplayMedium), + density, + setTextColors, + defaultFontFamily + ), + displaySmall = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceDisplaySmall), + density, + setTextColors, + defaultFontFamily + ), + headlineLarge = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceHeadlineLarge), + density, + setTextColors, + defaultFontFamily + ), + headlineMedium = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceHeadlineMedium), + density, + setTextColors, + defaultFontFamily + ), + headlineSmall = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceHeadlineSmall), + density, + setTextColors, + defaultFontFamily + ), + titleLarge = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceTitleLarge), + density, + setTextColors, + defaultFontFamily + ), + titleMedium = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceTitleMedium), + density, + setTextColors, + defaultFontFamily + ), + titleSmall = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceTitleSmall), + density, + setTextColors, + defaultFontFamily + ), + bodyLarge = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceBodyLarge), + density, + setTextColors, + defaultFontFamily + ), + bodyMedium = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceBodyMedium), + density, + setTextColors, + defaultFontFamily + ), + bodySmall = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceBodySmall), + density, + setTextColors, + defaultFontFamily + ), + labelLarge = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceLabelLarge), + density, + setTextColors, + defaultFontFamily + ), + labelMedium = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceLabelMedium), + density, + setTextColors, + defaultFontFamily + ), + labelSmall = parseTextAppearance( + context, + ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_textAppearanceLabelSmall), + density, + setTextColors, + defaultFontFamily + ), + ) + } else null + + /** + * Now read the shape appearances, taking into account the layout direction. + */ + val shapes = if (readShapes) { + Shapes( + extraSmall = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_shapeAppearanceCornerExtraSmall), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.extraSmall + ), + small = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_shapeAppearanceCornerSmall), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.small + ), + medium = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_shapeAppearanceCornerMedium), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.medium + ), + large = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_shapeAppearanceCornerLarge), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.large + ), + extraLarge = parseShapeAppearance( + context = context, + id = ta.getResourceIdOrThrow(R.styleable.ThemeAdapterMaterial3Theme_shapeAppearanceCornerExtraLarge), + layoutDirection = layoutDirection, + fallbackShape = emptyShapes.extraLarge + ) + ) + } else null + + Theme3Parameters(colorScheme, typography, shapes) + } +} + +private val emptyShapes = Shapes() + +/** + * This is gross, but we need a way to check for theme equality. Theme does not implement + * `equals()` or `hashCode()`, but it does have a hidden method called `getKey()`. + * + * The cost of this reflective invoke is a lot cheaper than the full theme read which can + * happen on each re-composition. + */ +private inline val Resources.Theme.key: Any? + get() { + if (!sThemeGetKeyMethodFetched) { + try { + @Suppress("SoonBlockedPrivateApi") + sThemeGetKeyMethod = Resources.Theme::class.java.getDeclaredMethod("getKey") + .apply { isAccessible = true } + } catch (e: ReflectiveOperationException) { + // Failed to retrieve Theme.getKey method + } + sThemeGetKeyMethodFetched = true + } + if (sThemeGetKeyMethod != null) { + return try { + sThemeGetKeyMethod?.invoke(this) + } catch (e: ReflectiveOperationException) { + // Failed to invoke Theme.getKey() + } + } + return null + } + +private var sThemeGetKeyMethodFetched = false +private var sThemeGetKeyMethod: Method? = null diff --git a/themeadapter-material3/src/main/res/values/theme_attrs.xml b/themeadapter-material3/src/main/res/values/theme_attrs.xml new file mode 100644 index 000000000..0041dfead --- /dev/null +++ b/themeadapter-material3/src/main/res/values/theme_attrs.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/BaseMdc3ThemeTest.kt b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/BaseMdc3ThemeTest.kt new file mode 100644 index 000000000..216480b50 --- /dev/null +++ b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/BaseMdc3ThemeTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material3 + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.toFontFamily +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.google.accompanist.themeadapter.core.FontFamilyWithWeight +import com.google.accompanist.themeadapter.material3.test.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * Class which contains the majority of the tests. This class is extended + * in both the `androidTest` and `test` source sets for setup of the relevant + * test runner. + */ +abstract class BaseMdc3ThemeTest( + activityClass: Class +) { + @get:Rule + val composeTestRule = createAndroidComposeRule(activityClass) + + @Test + fun colors() = composeTestRule.setContent { + Mdc3Theme { + val colorScheme = MaterialTheme.colorScheme + + assertEquals(colorResource(R.color.aquamarine), colorScheme.primary) + assertEquals(colorResource(R.color.pale_turquoise), colorScheme.onPrimary) + assertEquals(colorResource(R.color.midnight_blue), colorScheme.inversePrimary) + assertEquals(colorResource(R.color.royal_blue), colorScheme.primaryContainer) + assertEquals(colorResource(R.color.steel_blue), colorScheme.onPrimaryContainer) + + assertEquals(colorResource(R.color.dodger_blue), colorScheme.secondary) + assertEquals(colorResource(R.color.dark_golden_rod), colorScheme.onSecondary) + assertEquals(colorResource(R.color.peru), colorScheme.secondaryContainer) + assertEquals(colorResource(R.color.blue_violet), colorScheme.onSecondaryContainer) + + assertEquals(colorResource(R.color.dark_orchid), colorScheme.tertiary) + assertEquals(colorResource(R.color.slate_gray), colorScheme.onTertiary) + assertEquals(colorResource(R.color.gray), colorScheme.tertiaryContainer) + assertEquals(colorResource(R.color.spring_green), colorScheme.onTertiaryContainer) + + assertEquals(colorResource(R.color.medium_spring_green), colorScheme.background) + assertEquals(colorResource(R.color.navy), colorScheme.onBackground) + + assertEquals(colorResource(R.color.dark_blue), colorScheme.surface) + assertEquals(colorResource(R.color.light_coral), colorScheme.onSurface) + assertEquals(colorResource(R.color.salmon), colorScheme.surfaceVariant) + assertEquals(colorResource(R.color.dark_salmon), colorScheme.onSurfaceVariant) + assertEquals(colorResource(R.color.indian_red), colorScheme.surfaceTint) + assertEquals(colorResource(R.color.light_salmon), colorScheme.inverseSurface) + assertEquals(colorResource(R.color.orchid), colorScheme.inverseOnSurface) + + assertEquals(colorResource(R.color.violet), colorScheme.outline) + // TODO: MDC-Android doesn't include outlineVariant yet, add when available + + assertEquals(colorResource(R.color.beige), colorScheme.error) + assertEquals(colorResource(R.color.white_smoke), colorScheme.onError) + assertEquals(colorResource(R.color.olive), colorScheme.errorContainer) + assertEquals(colorResource(R.color.olive_drab), colorScheme.onErrorContainer) + + assertEquals(colorResource(R.color.crimson), colorScheme.scrim) + + // Mdc3Theme updates the LocalContentColor to match the calculated onBackground + assertEquals(colorResource(R.color.navy), LocalContentColor.current) + } + } + + @Test + fun shapes() = composeTestRule.setContent { + Mdc3Theme { + val shapes = MaterialTheme.shapes + val density = LocalDensity.current + + shapes.extraSmall.run { + assertTrue(this is RoundedCornerShape) + assertEquals(4f, topStart.toPx(density)) + assertEquals(9.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(5f, bottomEnd.toPx(density)) + assertEquals(3.dp.scaleToPx(density), bottomStart.toPx(density)) + } + shapes.small.run { + assertTrue(this is CutCornerShape) + assertEquals(4f, topStart.toPx(density)) + assertEquals(9.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(5f, bottomEnd.toPx(density)) + assertEquals(3.dp.scaleToPx(density), bottomStart.toPx(density)) + } + shapes.medium.run { + assertTrue(this is RoundedCornerShape) + assertEquals(12.dp.scaleToPx(density), topStart.toPx(density)) + assertEquals(12.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(12.dp.scaleToPx(density), bottomEnd.toPx(density)) + assertEquals(12.dp.scaleToPx(density), bottomStart.toPx(density)) + } + shapes.large.run { + assertTrue(this is CutCornerShape) + assertEquals(16.dp.scaleToPx(density), topStart.toPx(density)) + assertEquals(16.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(16.dp.scaleToPx(density), bottomEnd.toPx(density)) + assertEquals(16.dp.scaleToPx(density), bottomStart.toPx(density)) + } + shapes.extraLarge.run { + assertTrue(this is RoundedCornerShape) + assertEquals(28.dp.scaleToPx(density), topStart.toPx(density)) + assertEquals(28.dp.scaleToPx(density), topEnd.toPx(density)) + assertEquals(28.dp.scaleToPx(density), bottomEnd.toPx(density)) + assertEquals(28.dp.scaleToPx(density), bottomStart.toPx(density)) + } + } + } + + @Test + fun type() = composeTestRule.setContent { + Mdc3Theme { + val typography = MaterialTheme.typography + val density = LocalDensity.current + + val rubik300 = Font(R.font.rubik_300).toFontFamily() + val rubik400 = Font(R.font.rubik_400).toFontFamily() + val rubik500 = Font(R.font.rubik_500).toFontFamily() + val rubik700 = Font(R.font.rubik_700).toFontFamily() + val sansSerif = FontFamilyWithWeight(FontFamily.SansSerif) + val sansSerifLight = FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Light) + val sansSerifBlack = FontFamilyWithWeight(FontFamily.SansSerif, FontWeight.Black) + val serif = FontFamilyWithWeight(FontFamily.Serif) + val cursive = FontFamilyWithWeight(FontFamily.Cursive) + val monospace = FontFamilyWithWeight(FontFamily.Monospace) + + typography.displayLarge.run { + com.google.accompanist.themeadapter.material3.assertTextUnitEquals(97.54.sp, fontSize, density) + com.google.accompanist.themeadapter.material3.assertTextUnitEquals((-0.0015).em, letterSpacing, density) + assertEquals(rubik300, fontFamily) + } + + assertNotNull(typography.displayMedium.shadow) + typography.displayMedium.shadow!!.run { + assertEquals(colorResource(R.color.olive_drab), color) + assertEquals(4.43f, offset.x) + assertEquals(8.19f, offset.y) + assertEquals(2.13f, blurRadius) + } + + typography.displaySmall.run { + assertEquals(sansSerif.fontFamily, fontFamily) + assertEquals(sansSerif.weight, fontWeight) + } + + typography.headlineLarge.run { + assertEquals(sansSerifLight.fontFamily, fontFamily) + assertEquals(sansSerifLight.weight, fontWeight) + } + + typography.headlineMedium.run { + assertEquals(sansSerifLight.fontFamily, fontFamily) + assertEquals(sansSerifLight.weight, fontWeight) + } + + typography.headlineSmall.run { + assertEquals(sansSerifBlack.fontFamily, fontFamily) + assertEquals(sansSerifBlack.weight, fontWeight) + } + + typography.titleLarge.run { + assertEquals(serif.fontFamily, fontFamily) + assertEquals(serif.weight, fontWeight) + } + + typography.titleMedium.run { + assertEquals(monospace.fontFamily, fontFamily) + assertEquals(monospace.weight, fontWeight) + com.google.accompanist.themeadapter.material3.assertTextUnitEquals(0.em, letterSpacing, density) + } + + typography.titleSmall.run { + assertEquals(FontFamily.SansSerif, fontFamily) + } + + typography.bodyLarge.run { + com.google.accompanist.themeadapter.material3.assertTextUnitEquals(16.26.sp, fontSize, density) + com.google.accompanist.themeadapter.material3.assertTextUnitEquals(0.005.em, letterSpacing, density) + assertEquals(rubik400, fontFamily) + assertNull(shadow) + } + + typography.bodyMedium.run { + assertEquals(cursive.fontFamily, fontFamily) + assertEquals(cursive.weight, fontWeight) + } + + typography.bodySmall.run { + assertEquals(FontFamily.SansSerif, fontFamily) + com.google.accompanist.themeadapter.material3.assertTextUnitEquals(0.04.em, letterSpacing, density) + } + + typography.labelLarge.run { + assertEquals(rubik500, fontFamily) + } + + typography.labelMedium.run { + assertEquals(rubik700, fontFamily) + } + + typography.labelSmall.run { + assertEquals(FontFamily.SansSerif, fontFamily) + } + } + } +} + +private fun Dp.scaleToPx(density: Density): Float { + val dp = this + return with(density) { dp.toPx() } +} + +private fun assertTextUnitEquals(expected: TextUnit, actual: TextUnit, density: Density) { + if (expected.javaClass == actual.javaClass) { + // If the expected and actual are the same type, compare the raw values with a + // delta to account for float inaccuracy + assertEquals(expected.value, actual.value, 0.001f) + } else { + // Otherwise we need to flatten to a px to compare the values. Again using a + // delta to account for float inaccuracy + with(density) { assertEquals(expected.toPx(), actual.toPx(), 0.001f) } + } +} + +private fun CornerSize.toPx(density: Density) = toPx(Size.Unspecified, density) diff --git a/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/FakeTests.kt b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/FakeTests.kt new file mode 100644 index 000000000..d995aed53 --- /dev/null +++ b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/FakeTests.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Fake tests to help with sharding: https://github.com/android/android-test/issues/973 + */ +@RunWith(JUnit4::class) +class FakeTests { + @Test + fun fake1() = Unit + + @Test + fun fake2() = Unit + + @Test + fun fake3() = Unit + + @Test + fun fake4() = Unit + + @Test + fun fake5() = Unit + + @Test + fun fake6() = Unit + + @Test + fun fake7() = Unit + + @Test + fun fake8() = Unit + + @Test + fun fake9() = Unit + + @Test + fun fake10() = Unit +} diff --git a/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/Mdc3Activity.kt b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/Mdc3Activity.kt new file mode 100644 index 000000000..40ee37e6b --- /dev/null +++ b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/Mdc3Activity.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material3 + +import androidx.appcompat.app.AppCompatActivity + +class Mdc3Activity : AppCompatActivity() diff --git a/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/NotMdc3Activity.kt b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/NotMdc3Activity.kt new file mode 100644 index 000000000..452b659dc --- /dev/null +++ b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/NotMdc3Activity.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material3 + +import androidx.appcompat.app.AppCompatActivity + +class NotMdc3Activity : AppCompatActivity() diff --git a/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/NotMdc3ThemeTest.kt b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/NotMdc3ThemeTest.kt new file mode 100644 index 000000000..5f1de8eeb --- /dev/null +++ b/themeadapter-material3/src/sharedTest/kotlin/com/google/accompanist/themeadapter/material3/NotMdc3ThemeTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material3 + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NotMdc3ThemeTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test(expected = IllegalArgumentException::class) + fun throwForNonMdc3Theme() = composeTestRule.setContent { + Mdc3Theme { + // Nothing to do here, exception should be thrown + } + } +} diff --git a/themeadapter-material3/src/sharedTest/res/font/rubik.xml b/themeadapter-material3/src/sharedTest/res/font/rubik.xml new file mode 100644 index 000000000..881fa013a --- /dev/null +++ b/themeadapter-material3/src/sharedTest/res/font/rubik.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material3/src/sharedTest/res/font/rubik_300.ttf b/themeadapter-material3/src/sharedTest/res/font/rubik_300.ttf new file mode 100644 index 000000000..8189d848f Binary files /dev/null and b/themeadapter-material3/src/sharedTest/res/font/rubik_300.ttf differ diff --git a/themeadapter-material3/src/sharedTest/res/font/rubik_400.ttf b/themeadapter-material3/src/sharedTest/res/font/rubik_400.ttf new file mode 100644 index 000000000..52b59ca4f Binary files /dev/null and b/themeadapter-material3/src/sharedTest/res/font/rubik_400.ttf differ diff --git a/themeadapter-material3/src/sharedTest/res/font/rubik_500.ttf b/themeadapter-material3/src/sharedTest/res/font/rubik_500.ttf new file mode 100644 index 000000000..9e358b2f4 Binary files /dev/null and b/themeadapter-material3/src/sharedTest/res/font/rubik_500.ttf differ diff --git a/themeadapter-material3/src/sharedTest/res/font/rubik_700.ttf b/themeadapter-material3/src/sharedTest/res/font/rubik_700.ttf new file mode 100644 index 000000000..4e77930f4 Binary files /dev/null and b/themeadapter-material3/src/sharedTest/res/font/rubik_700.ttf differ diff --git a/themeadapter-material3/src/sharedTest/res/values/styles.xml b/themeadapter-material3/src/sharedTest/res/values/styles.xml new file mode 100644 index 000000000..af4a28a5f --- /dev/null +++ b/themeadapter-material3/src/sharedTest/res/values/styles.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material3/src/sharedTest/res/values/test_colors.xml b/themeadapter-material3/src/sharedTest/res/values/test_colors.xml new file mode 100644 index 000000000..a4fefe7d9 --- /dev/null +++ b/themeadapter-material3/src/sharedTest/res/values/test_colors.xml @@ -0,0 +1,48 @@ + + + + + + #7FFFD4 + #AFEEEE + #191970 + #4169E1 + #4682B4 + #1E90FF + #B8860B + #CD853F + #8A2BE2 + #9932CC + #708090 + #808080 + #00FF7F + #00FA9A + #000080 + #00008B + #DC143C + #CD5C5C + #F08080 + #FA8072 + #E9967A + #FFA07A + #DA70D6 + #EE82EE + #F5F5DC + #F5F5F5 + #808000 + #6B8E23 + \ No newline at end of file diff --git a/themeadapter-material3/src/sharedTest/res/values/themes.xml b/themeadapter-material3/src/sharedTest/res/values/themes.xml new file mode 100644 index 000000000..1d0e3e1bf --- /dev/null +++ b/themeadapter-material3/src/sharedTest/res/values/themes.xml @@ -0,0 +1,77 @@ + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material3/src/sharedTest/res/values/type.xml b/themeadapter-material3/src/sharedTest/res/values/type.xml new file mode 100644 index 000000000..1b4504bb6 --- /dev/null +++ b/themeadapter-material3/src/sharedTest/res/values/type.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sans-serif-condensed-light + + + + + + + + \ No newline at end of file diff --git a/themeadapter-material3/src/test/AndroidManifest.xml b/themeadapter-material3/src/test/AndroidManifest.xml new file mode 100644 index 000000000..d0601dd44 --- /dev/null +++ b/themeadapter-material3/src/test/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/themeadapter-material3/src/test/kotlin/com/google/accompanist/themeadapter/material3/RobolectricMdc3ThemeTest.kt b/themeadapter-material3/src/test/kotlin/com/google/accompanist/themeadapter/material3/RobolectricMdc3ThemeTest.kt new file mode 100644 index 000000000..a57ab3ffc --- /dev/null +++ b/themeadapter-material3/src/test/kotlin/com/google/accompanist/themeadapter/material3/RobolectricMdc3ThemeTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.themeadapter.material3 + +import androidx.appcompat.app.AppCompatActivity +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +/** + * Version of [BaseMdc3ThemeTest] which is designed to be run using Robolectric. + * + * All of the tests are provided by [BaseMdc3ThemeTest]. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class RobolectricMdc3ThemeTest( + activityClass: Class +) : BaseMdc3ThemeTest(activityClass) { + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun activities() = listOf( + Mdc3Activity::class.java + ) + } +} diff --git a/themeadapter-material3/src/test/resources/robolectric.properties b/themeadapter-material3/src/test/resources/robolectric.properties new file mode 100644 index 000000000..2806eaffa --- /dev/null +++ b/themeadapter-material3/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Pin SDK to 30 since Robolectric does not currently support API 31: +# https://github.com/robolectric/robolectric/issues/6635 +sdk=30