diff --git a/platform/autos-test/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/test/theme/AutosThemeExtensionsTests.kt b/platform/autos-test/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/test/theme/AutosThemeExtensionsTests.kt new file mode 100644 index 000000000..10cefa99d --- /dev/null +++ b/platform/autos-test/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/test/theme/AutosThemeExtensionsTests.kt @@ -0,0 +1,35 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.test.theme + +import androidx.compose.ui.test.junit4.createComposeRule +import com.jeanbarrossilva.orca.platform.autos.theme.AutosTheme +import org.junit.Rule +import org.junit.Test + +internal class AutosThemeExtensionsTests { + @get:Rule val composeRule = createComposeRule() + + @Test(expected = MissingThemingException::class) + fun throwsWhenContentIsRequiredToBeThemedButIsNot() { + composeRule.setContent { AutosTheme.require() } + } + + @Test + fun doesNotThrowWhenContentIsRequiredToBeThemedAndIs() { + composeRule.setContent { AutosTheme { AutosTheme.require() } } + } +} diff --git a/platform/autos-test/src/main/java/com/jeanbarrossilva/orca/platform/autos/test/theme/AutosTheme.extensions.kt b/platform/autos-test/src/main/java/com/jeanbarrossilva/orca/platform/autos/test/theme/AutosTheme.extensions.kt new file mode 100644 index 000000000..5eac151da --- /dev/null +++ b/platform/autos-test/src/main/java/com/jeanbarrossilva/orca/platform/autos/test/theme/AutosTheme.extensions.kt @@ -0,0 +1,45 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.test.theme + +import androidx.compose.runtime.Composable +import com.jeanbarrossilva.orca.platform.autos.theme.AutosTheme + +/** + * [IllegalStateException] thrown if some content is required to be themed with [AutosTheme] but + * isn't. + * + * @see AutosTheme.require + */ +class MissingThemingException internal constructor() : + IllegalStateException("AutosTheme was required but content wasn't themed.") + +/** + * Requires the content to be themed. + * + * @throws MissingThemingException If [AutosTheme] isn't applied. + */ +@Composable +@Suppress("ComposableNaming") +@Throws(IllegalStateException::class) +fun AutosTheme.require() { + runCatching { spacings } + .onFailure { + if (it is IllegalStateException) { + throw MissingThemingException() + } + } +} diff --git a/platform/autos/build.gradle.kts b/platform/autos/build.gradle.kts index 7bc5210f5..d1ac55c31 100644 --- a/platform/autos/build.gradle.kts +++ b/platform/autos/build.gradle.kts @@ -26,9 +26,11 @@ android { dependencies { androidTestImplementation(project(":platform:autos-test")) + androidTestImplementation(project(":platform:testing")) androidTestImplementation(libs.android.compose.ui.test.junit) androidTestImplementation(libs.android.compose.ui.test.manifest) androidTestImplementation(libs.android.test.core) + androidTestImplementation(libs.android.test.espresso.core) androidTestImplementation(libs.android.test.runner) androidTestImplementation(libs.assertk) androidTestImplementation(libs.kotlin.test) diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/NavigationBarViewTests.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/NavigationBarViewTests.kt new file mode 100644 index 000000000..88d74c80a --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/NavigationBarViewTests.kt @@ -0,0 +1,213 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view + +import android.content.res.ColorStateList +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import assertk.assertThat +import assertk.assertions.isTrue +import com.jeanbarrossilva.orca.platform.autos.R +import com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.NavigationBarDefaults +import com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.image.withDrawable +import com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.image.withImageTint +import com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.text.hasTextColors +import com.jeanbarrossilva.orca.platform.autos.test.kit.scaffold.bar.navigation.onTab +import com.jeanbarrossilva.orca.platform.autos.test.theme.require +import com.jeanbarrossilva.orca.platform.autos.theme.AutosTheme +import com.jeanbarrossilva.orca.platform.testing.context +import com.jeanbarrossilva.orca.platform.testing.emptyStringResourceID +import kotlin.test.Test +import org.junit.Rule + +internal class NavigationBarViewTests { + @get:Rule val composeRule = createComposeRule() + + @Test + fun composes() { + var hasComposed = false + composeRule.setContent { + CompositionLocalProvider(LocalInspectionMode provides true) { + AndroidView(::NavigationBarView) { + it.setOnCompositionListener { + hasComposed = true + it.setOnCompositionListener(null) + } + } + } + } + assertThat(hasComposed).isTrue() + } + + @Test + fun isThemed() { + var hasBeenThemed = false + composeRule.setContent { + AndroidView(::NavigationBarView) { + it.setOnCompositionListener { + AutosTheme.require() + hasBeenThemed = true + it.setOnCompositionListener(null) + } + } + } + assertThat(hasBeenThemed).isTrue() + } + + @Test + fun identifiesTitle() { + val view = NavigationBarView(context) + composeRule.setContent { AndroidView({ view }) { it.setTitle(":P") } } + onView(withId(view.titleViewID)).check(matches(isCompletelyDisplayed())) + } + + @Test + fun titleIsColoredWithCurrentContentColor() { + val view = NavigationBarView(context) + composeRule.setContent { AndroidView({ view }) } + onView(withId(view.titleViewID)) + .check( + matches(hasTextColors(ColorStateList.valueOf(NavigationBarDefaults.ContentColor.toArgb()))) + ) + } + + @Test + fun setsTitle() { + val view = NavigationBarView(context) + composeRule.setContent { AndroidView({ view }) { it.setTitle(":)") } } + onView(withId(view.titleViewID)).check(matches(isDisplayed())) + } + + @Test + fun addsTab() { + composeRule + .apply { + setContent { + AndroidView(::NavigationBarView) { + it.addTab(android.R.id.tabs, R.drawable.icon_home_outlined, emptyStringResourceID) { + false + } + } + } + } + .onTab() + .assertIsDisplayed() + } + + @Test + fun selectsTab() { + var hasBeenSelected = false + composeRule + .apply { + setContent { + AndroidView(::NavigationBarView) { + it.addTab(android.R.id.tabs, R.drawable.icon_home_outlined, emptyStringResourceID) { + hasBeenSelected = true + true + } + } + } + } + .onTab() + .performClick() + assertThat(hasBeenSelected).isTrue() + } + + @Test + fun setsCurrentTab() { + var hasBeenSet = false + composeRule.setContent { + AndroidView(::NavigationBarView) { + it.addTab(android.R.id.tabs, R.drawable.icon_home_outlined, emptyStringResourceID) { + hasBeenSet = true + true + } + it.setCurrentTab(android.R.id.tabs) + } + } + assertThat(hasBeenSet).isTrue() + } + + @Test + fun identifiesActionButton() { + val view = NavigationBarView(context) + composeRule.setContent { AndroidView({ view }) } + onView(withId(view.actionButtonID)).check(matches(isCompletelyDisplayed())) + } + + @Test + fun tintsActionIconWithCurrentContentColor() { + val view = NavigationBarView(context) + composeRule.setContent { + AndroidView({ view }) { it.setAction(R.drawable.icon_back, emptyStringResourceID) {} } + } + onView(withId(view.actionButtonID)) + .check( + matches(withImageTint(ColorStateList.valueOf(NavigationBarDefaults.ContentColor.toArgb()))) + ) + } + + @Test + fun setsActionIcon() { + val view = NavigationBarView(context) + composeRule.setContent { + AndroidView({ view }) { it.setAction(R.drawable.icon_back, emptyStringResourceID) {} } + } + onView(withId(view.actionButtonID)) + .check( + matches( + withDrawable(R.drawable.icon_back) { + it.setTint(NavigationBarDefaults.ContentColor.toArgb()) + } + ) + ) + } + + @Test + fun describesAction() { + val view = NavigationBarView(context) + composeRule.setContent { + AndroidView({ view }) { it.setAction(R.drawable.icon_back, emptyStringResourceID) {} } + } + onView(withId(view.actionButtonID)) + .check(matches(withContentDescription(emptyStringResourceID))) + } + + @Test + fun clicksAction() { + val view = NavigationBarView(context) + var hasBeenClicked = false + composeRule.setContent { + AndroidView({ view }) { + it.setAction(R.drawable.icon_back, emptyStringResourceID) { hasBeenClicked = true } + } + } + onView(withId(view.actionButtonID)).perform(click()) + assertThat(hasBeenClicked).isTrue() + } +} diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithDrawableMatcher.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithDrawableMatcher.kt new file mode 100644 index 000000000..680fba48d --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithDrawableMatcher.kt @@ -0,0 +1,80 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.image + +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import com.jeanbarrossilva.orca.platform.testing.context +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher + +/** + * [Matcher] that matches an [ImageView] whose [Drawable] is the one to which the [resource] is a + * reference. + * + * @param resource Resource of the [Drawable] that the [ImageView] is expected to contain. + * @param transform Changes the [Drawable] to the state that it should be. + * @see ImageView.getDrawable + */ +private class WithDrawableMatcher( + @DrawableRes private val resource: Int, + private val transform: (Drawable) -> Unit +) : BaseMatcher() { + /** [Drawable] with the [resource] that has been found. */ + private var drawable: Drawable? = null + + override fun describeTo(description: Description?) { + description + ?.appendText("view.getDrawable() to match resource ID ") + ?.appendValue(resource) + ?.appendText("[") + ?.appendValue( + runCatching { context.resources?.getResourceEntryName(resource) } + .onSuccess { drawable = ContextCompat.getDrawable(context, resource)?.apply(transform) } + .recover { if (it is Resources.NotFoundException) null else throw it } + .getOrThrow() + ) + ?.appendText("]") + } + + override fun matches(item: Any?): Boolean { + return item is ImageView && + (((resource == Resources.ID_NULL && item.drawable == null) || + drawable?.constantState == item.drawable?.constantState || + drawable?.toBitmap()?.sameAs(item.drawable?.toBitmap()) ?: false)) + } +} + +/** + * [Matcher] that matches an [ImageView] whose [Drawable] is the one to which the [resource] is a + * reference. + * + * @param resource Resource of the [Drawable] that the [ImageView] is expected to contain. + * @param transform Changes the [Drawable] to the state that it should be. + * @see ImageView.getDrawable + */ +internal fun withDrawable( + @DrawableRes resource: Int, + transform: (Drawable) -> Unit = {} +): Matcher { + return WithDrawableMatcher(resource, transform) +} diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithDrawableMatcherTests.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithDrawableMatcherTests.kt new file mode 100644 index 000000000..84ca55ca7 --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithDrawableMatcherTests.kt @@ -0,0 +1,84 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.image + +import android.content.res.ColorStateList +import android.content.res.Resources +import android.graphics.Color +import android.widget.ImageView +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import com.jeanbarrossilva.orca.platform.autos.R +import com.jeanbarrossilva.orca.platform.testing.context +import kotlin.test.Test +import org.junit.Rule + +internal class WithDrawableMatcherTests { + @get:Rule val composeRule = createComposeRule() + + @Test(expected = AssertionError::class) + fun doesNotMatchImageViewWithoutDrawableWhenOneIsExpected() { + composeRule.setContent { AndroidView(::ImageView) } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withDrawable(R.drawable.icon_add))) + } + + @Test(expected = AssertionError::class) + fun doesNotMatchImageViewWithoutExpectedDrawable() { + composeRule.setContent { + AndroidView(::ImageView) { it.setImageResource(R.drawable.icon_back) } + } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withDrawable(R.drawable.icon_add))) + } + + @Test + fun matchesImageViewWithoutDrawableWhenNoneIsExpected() { + composeRule.setContent { AndroidView(::ImageView) } + onView(isAssignableFrom(ImageView::class.java)).check(matches(withDrawable(Resources.ID_NULL))) + } + + @Test + fun matchesImageViewWithExpectedDrawable() { + composeRule.setContent { + AndroidView(::ImageView) { it.setImageDrawable(context.getDrawable(R.drawable.icon_add)) } + } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withDrawable(R.drawable.icon_add))) + } + + @Test + fun matchesImageViewWithExpectedDrawableFromIDResource() { + composeRule.setContent { AndroidView(::ImageView) { it.setImageResource(R.drawable.icon_add) } } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withDrawable(R.drawable.icon_add))) + } + + @Test + fun transformsDrawable() { + composeRule.setContent { + AndroidView(::ImageView) { + it.setImageResource(R.drawable.icon_add) + it.imageTintList = ColorStateList.valueOf(Color.GREEN) + } + } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withDrawable(R.drawable.icon_add) { it.setTint(Color.GREEN) })) + } +} diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithImageTintMatcher.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithImageTintMatcher.kt new file mode 100644 index 000000000..591dc087a --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithImageTintMatcher.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.image + +import android.content.res.ColorStateList +import android.view.View +import android.widget.ImageView +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher + +/** + * [Matcher] that matches an [ImageView] whose image's tint equals to the given one. + * + * @param tint [ColorStateList] by which the image of the [ImageView] is expected to be tinted. + * @see ImageView.getImageTintList + */ +private class WithImageTintMatcher(private val tint: ColorStateList) : BaseMatcher() { + override fun describeTo(description: Description?) { + description?.appendText("view.getImageTintList() to be ")?.appendValue(tint) + } + + override fun matches(item: Any?): Boolean { + return item is ImageView && item.imageTintList == tint + } +} + +/** + * [Matcher] that matches an [ImageView] whose image's tint equals to the given one. + * + * @param tint [ColorStateList] by which the image of the [ImageView] is expected to be tinted. + * @see ImageView.getImageTintList + */ +internal fun withImageTint(tint: ColorStateList): Matcher { + return WithImageTintMatcher(tint) +} diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithTintMatcherTests.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithTintMatcherTests.kt new file mode 100644 index 000000000..7f1245df6 --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/image/WithTintMatcherTests.kt @@ -0,0 +1,57 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.image + +import android.content.res.ColorStateList +import android.graphics.Color +import android.widget.ImageButton +import android.widget.ImageView +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import kotlin.test.Test +import org.junit.Rule + +internal class WithTintMatcherTests { + @get:Rule val composeRule = createComposeRule() + + @Test(expected = AssertionError::class) + fun doesNotMatchNonImageView() { + composeRule.setContent { AndroidView(::ImageButton) } + onView(isAssignableFrom(ImageButton::class.java)) + .check(matches(withImageTint(ColorStateList.valueOf(Color.TRANSPARENT)))) + } + + @Test(expected = AssertionError::class) + fun doesNotMatchImageViewWithoutExpectedImageTint() { + composeRule.setContent { + AndroidView(::ImageView) { it.imageTintList = ColorStateList.valueOf(Color.BLACK) } + } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withImageTint(ColorStateList.valueOf(Color.TRANSPARENT)))) + } + + @Test + fun matchesImageViewWithExpectedImageTint() { + composeRule.setContent { + AndroidView(::ImageView) { it.imageTintList = ColorStateList.valueOf(Color.TRANSPARENT) } + } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(withImageTint(ColorStateList.valueOf(Color.TRANSPARENT)))) + } +} diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/text/HasTextColorsMatcher.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/text/HasTextColorsMatcher.kt new file mode 100644 index 000000000..61909a972 --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/text/HasTextColorsMatcher.kt @@ -0,0 +1,51 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.text + +import android.content.res.ColorStateList +import android.view.View +import android.widget.TextView +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher + +/** + * [Matcher] that matches a [TextView] whose text is colored by the given colors. + * + * @param colors [ColorStateList] containing the colors by which the text is expected to be colored. + * @see TextView.getText + * @see TextView.getTextColors + */ +private class HasTextColorsMatcher(private val colors: ColorStateList) : BaseMatcher() { + override fun describeTo(description: Description?) { + description?.appendText("view.getTextColors() to be ")?.appendValue(colors) + } + + override fun matches(item: Any?): Boolean { + return item is TextView && item.textColors == colors + } +} + +/** + * [Matcher] that matches a [TextView] whose text is colored by the given colors. + * + * @param colors [ColorStateList] containing the colors by which the text is expected to be colored. + * @see TextView.getText + * @see TextView.getTextColors + */ +internal fun hasTextColors(colors: ColorStateList): Matcher { + return HasTextColorsMatcher(colors) +} diff --git a/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/text/HasTextColorsMatcherTests.kt b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/text/HasTextColorsMatcherTests.kt new file mode 100644 index 000000000..fc9ab6e40 --- /dev/null +++ b/platform/autos/src/androidTest/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/text/HasTextColorsMatcherTests.kt @@ -0,0 +1,65 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view.text + +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.withId +import kotlin.test.Test +import org.junit.Rule + +internal class HasTextColorsMatcherTests { + @get:Rule val composeRule = createComposeRule() + + @Test(expected = AssertionError::class) + fun doesNotMatchNonTextView() { + composeRule.setContent { AndroidView(::ImageView) } + onView(isAssignableFrom(ImageView::class.java)) + .check(matches(hasTextColors(ColorStateList.valueOf(Color.TRANSPARENT)))) + } + + @Test(expected = AssertionError::class) + fun doesNotMatchTextViewWhoseTextColorsAreNotTheExpectedOnes() { + val id = View.generateViewId() + composeRule.setContent { + AndroidView(::TextView) { + it.id = id + it.setTextColor(Color.BLUE) + } + } + onView(withId(id)).check(matches(hasTextColors(ColorStateList.valueOf(Color.GRAY)))) + } + + @Test + fun matchesTextViewWhoseTextColorsAreTheExpectedOnes() { + val id = View.generateViewId() + composeRule.setContent { + AndroidView(::TextView) { + it.id = id + it.setTextColor(ColorStateList.valueOf(Color.TRANSPARENT)) + } + } + onView(withId(id)).check(matches(hasTextColors(ColorStateList.valueOf(Color.TRANSPARENT)))) + } +} diff --git a/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBar.kt b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBar.kt index 0d2d25787..ef3ee3cbb 100644 --- a/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBar.kt +++ b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBar.kt @@ -45,6 +45,9 @@ const val NAVIGATION_BAR_TAG = "navigation-bar" object NavigationBarDefaults { /** [Color] by which the container of a [NavigationBar] is colored. */ val ContainerColor = Color.Black + + /** [Color] by which the content on the container is colored. */ + val ContentColor = Color.White } /** @@ -66,10 +69,41 @@ fun NavigationBar( subtitle: @Composable () -> Unit = {}, content: NavigationBarScope.() -> Unit ) { - val scope = remember(content) { NavigationBarScope().apply(content) } + NavigationBar( + remember(content) { NavigationBarScope().apply(content) }, + title, + action, + modifier, + subtitle + ) +} + +/** + * Bar to which tabs for accessing different contexts within Orca can be added and/or a prominent + * navigation action (such as going back to a previous context) may be performed. + * + * @param scope [NavigationBarScope] to which tabs are added. + * @param title [AutoSizeText] that describes the current context. + * @param action Main navigation action. + * @param modifier [Modifier] to be applied to the underlying [Surface]. + * @param subtitle [AutoSizeText] for more details on the current context. + * @see NavigationBarScope.tab + */ +@Composable +internal fun NavigationBar( + scope: NavigationBarScope, + title: @Composable () -> Unit, + action: @Composable () -> Unit, + modifier: Modifier = Modifier, + subtitle: @Composable () -> Unit = {} +) { val spacing = AutosTheme.spacings.medium.dp - Surface(modifier, color = NavigationBarDefaults.ContainerColor, contentColor = Color.White) { + Surface( + modifier, + color = NavigationBarDefaults.ContainerColor, + contentColor = NavigationBarDefaults.ContentColor + ) { Column(Modifier.padding(spacing).testTag(NAVIGATION_BAR_TAG), Arrangement.spacedBy(spacing)) { Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { ProvideTextStyle( diff --git a/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBarScope.kt b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBarScope.kt index 2899afc34..3a6464869 100644 --- a/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBarScope.kt +++ b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/NavigationBarScope.kt @@ -15,6 +15,7 @@ package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation +import androidx.appcompat.view.menu.MenuItemImpl import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -22,7 +23,9 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.testTag +import androidx.core.graphics.drawable.toBitmap import com.jeanbarrossilva.orca.platform.autos.kit.action.button.icon.HoverableIconButton /** @@ -48,6 +51,20 @@ class NavigationBarScope internal constructor() { tabs.add { HoverableIconButton(onClick, modifier.testTag(TAB_TAG), content) } } + /** + * Adds a selectable navigation tab from a [MenuItemImpl]. + * + * @param menuItem [MenuItemImpl] based on which the tab will be added. + */ + @Suppress("RestrictedApi") + internal fun tab(menuItem: MenuItemImpl) { + tab(onClick = { menuItem.invoke() }) { + menuItem.icon?.toBitmap()?.asImageBitmap()?.let { + Icon(it, menuItem.contentDescription?.toString() ?: "") + } + } + } + companion object { /** Tag by which a tab is identified for testing purposes. */ const val TAB_TAG = "tab" diff --git a/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/NavigationBarView.kt b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/NavigationBarView.kt new file mode 100644 index 000000000..4418ed838 --- /dev/null +++ b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/NavigationBarView.kt @@ -0,0 +1,288 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view + +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageButton +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuItemImpl +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.content.res.getStringOrThrow +import androidx.core.content.withStyledAttributes +import androidx.core.view.forEach +import com.google.android.material.navigation.NavigationBarView +import com.jeanbarrossilva.orca.platform.autos.R +import com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.NavigationBar +import com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.NavigationBarScope +import com.jeanbarrossilva.orca.platform.autos.theme.AutosTheme + +/** + * Bar to which tabs for accessing different contexts within Orca can be added and/or a prominent + * navigation action (such as going back to a previous context) may be performed. + * + * @param context [Context] in which this [NavigationBarView] is. + * @param attributeSet [AttributeSet] from the XML tag by which this [NavigationBarView] is + * inflated. + * @param defaultAttributeResource Attribute resource that supplies default values. + * @throws IllegalArgumentException If an icon is specified but for the action but it hasn't been + * described. + * @see NavigationBarScope.tab + */ +class NavigationBarView +@JvmOverloads +@Throws(IllegalArgumentException::class) +constructor( + context: Context, + attributeSet: AttributeSet? = null, + @AttrRes defaultAttributeResource: Int = 0 +) : AbstractComposeView(context, attributeSet, defaultAttributeResource) { + /** [OnThemedCompositionListener] to be notified when the [Content] is composed and themed. */ + private var onThemedCompositionListener: OnThemedCompositionListener? = null + + /** + * [NavigationBarScope] by which tabs are added. + * + * @see NavigationBarScope.tab + */ + private val scope = NavigationBarScope() + + /** [MenuBuilder] to which the [MenuItem]s representing the tabs are added. */ + @Suppress("RestrictedApi") private val menuBuilder = MenuBuilder(context) + + /** ID resource of the currently selected tab. */ + @IdRes private var currentTabIDResource = 0 + + /** [Drawable] of the action [ImageButton] icon. */ + private var actionIcon by mutableStateOf(null) + + /** Description of what the action does. */ + private var actionContentDescription by mutableStateOf(null) + + /** [View.OnClickListener] to be notified of clicks on the action [ImageButton]. */ + private var onActionClickListener by mutableStateOf(null) + + /** Describes the current context. */ + private var title by mutableStateOf("") + @JvmName("_setTitle") set + + /** + * ID of the action [ImageButton]. + * + * @see ImageButton.getId + */ + @VisibleForTesting internal val actionButtonID = View.generateViewId() + + /** ID of the title [TextView]. */ + @VisibleForTesting internal val titleViewID = View.generateViewId() + + /** + * [Menu] to which the [MenuItem]s representing the tabs are added. + * + * @see NavigationBarScope.tab + */ + val menu + get() = menuBuilder as Menu + + /** + * Listener that is notified when the [Content] is composed and themed. + * + * @see onThemedComposition + */ + @VisibleForTesting + internal fun interface OnThemedCompositionListener { + /** Callback run when the [Content] is composed and themed. */ + @Composable @Suppress("ComposableNaming") fun onThemedComposition() + } + + init { + context.withStyledAttributes( + attributeSet, + R.styleable.NavigationBarView, + defStyleAttr = defaultAttributeResource + ) { + setTitleFromAttribute() + addTabsFromMenu() + setActionFromAttributes() + } + } + + @Composable + override fun Content() { + AutosTheme { + onThemedCompositionListener?.onThemedComposition() + + NavigationBar( + scope, + title = { + LocalContentColor.current.toArgb().let { contentColorInArgb -> + AndroidView({ TextView(it, null, 0, R.style.Theme_Orca_Typography_HeadlineLarge) }) { + it.id = titleViewID + it.text = title + it.setTextColor(contentColorInArgb) + } + } + }, + action = { + LocalContentColor.current.toArgb().let { contentColorInArgb -> + AndroidView(::ImageButton) { view -> + view.id = actionButtonID + view.background = null + view.contentDescription = actionContentDescription + view.imageTintList = ColorStateList.valueOf(contentColorInArgb) + view.setOnClickListener(onActionClickListener) + view.setImageDrawable(actionIcon) + } + } + } + ) + } + } + + /** + * Changes the title that describes the current context. + * + * @param title Title to be set. + */ + fun setTitle(title: String) { + this.title = title + } + + /** + * Adds a tab. + * + * @param idResource ID resource by which the tab will be identified. + * @param iconResource Resource of the icon of the tab to be added. + * @param contentDescriptionResource Resource of the description for the tab. + * @param onClickListener [MenuItem.OnMenuItemClickListener] to be notified when the tab is + * clicked. + * @see NavigationBarScope.tab + */ + fun addTab( + @IdRes idResource: Int, + @DrawableRes iconResource: Int, + @StringRes contentDescriptionResource: Int, + onClickListener: MenuItem.OnMenuItemClickListener + ) { + @Suppress("RestrictedApi") + menuBuilder + .add(0, idResource, 0, null) + ?.apply { + contentDescription = context.getString(contentDescriptionResource) + setOnMenuItemClickListener(onClickListener) + setIcon(iconResource) + } + ?.run { scope.tab(this as MenuItemImpl) } + } + + /** + * Changes the tab that is currently selected. + * + * @param currentTabIDResource ID resource of the tab to be set as the current one. + */ + fun setCurrentTab(@IdRes currentTabIDResource: Int) { + this.currentTabIDResource = currentTabIDResource + + @Suppress("RestrictedApi") + menu.findItem(currentTabIDResource).let { it as MenuItemImpl }.invoke() + } + + /** + * Updates the action [ImageButton]. + * + * @param iconResource Resource of the [Drawable] of the icon to be set. + * @param contentDescriptionResource Resource of the description to be set. + * @param onClickListener [View.OnClickListener] to be notified when the [ImageButton] is clicked. + */ + fun setAction( + @DrawableRes iconResource: Int, + @StringRes contentDescriptionResource: Int, + onClickListener: OnClickListener + ) { + actionIcon = ContextCompat.getDrawable(context, iconResource) + actionContentDescription = context.getString(contentDescriptionResource) + onActionClickListener = onClickListener + } + + /** + * Changes the [OnThemedCompositionListener] to be notified when the [Content] is composed and + * themed. + * + * @param onThemedCompositionListener [OnThemedCompositionListener] to be set. + */ + @VisibleForTesting + internal fun setOnCompositionListener(onThemedCompositionListener: OnThemedCompositionListener?) { + this.onThemedCompositionListener = onThemedCompositionListener + } + + /** + * Changes the title to the one specified in the attribute. + * + * @see R.styleable.NavigationBarView_android_title + */ + private fun TypedArray.setTitleFromAttribute() { + getString(R.styleable.NavigationBarView_android_title)?.run(::setTitle) + } + + /** + * Adds tabs from the specified [Menu]. + * + * @see R.styleable.NavigationBarView_menu + * @see TypedArray.inflateMenu + */ + private fun TypedArray.addTabsFromMenu() { + @Suppress("PrivateResource") + inflateMenu(context, menuBuilder, R.styleable.NavigationBarView_menu) + + menuBuilder.forEach { @Suppress("RestrictedApi") scope.tab(it as MenuItemImpl) } + } + + /** + * Changes the action with the specified attributes. + * + * @throws IllegalArgumentException If an icon is specified for the action but it hasn't been + * described. + * @see R.styleable.NavigationBarView_actionIcon + * @see R.styleable.NavigationBarView_actionContentDescription + */ + private fun TypedArray.setActionFromAttributes() { + getDrawable(R.styleable.NavigationBarView_actionIcon)?.let { + actionIcon = it + actionContentDescription = + getStringOrThrow(R.styleable.NavigationBarView_actionContentDescription) + } + } +} diff --git a/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/TypedArray.extensions.kt b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/TypedArray.extensions.kt new file mode 100644 index 000000000..5880e7647 --- /dev/null +++ b/platform/autos/src/main/java/com/jeanbarrossilva/orca/platform/autos/kit/scaffold/bar/navigation/view/TypedArray.extensions.kt @@ -0,0 +1,35 @@ +/* + * Copyright © 2024 Orca + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If + * not, see https://www.gnu.org/licenses. + */ + +package com.jeanbarrossilva.orca.platform.autos.kit.scaffold.bar.navigation.view + +import android.content.Context +import android.content.res.TypedArray +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.annotation.StyleableRes +import androidx.appcompat.view.menu.MenuBuilder + +/** + * Inflates the [Menu] that has been specified at the given [index] (or `null` if none has been). + * + * @param context [Context] from which a [MenuBuilder] will be created. + * @param menu [Menu] to which the [MenuItem]s will be added. + * @param index Index at which the [Menu] is expected to be. + */ +internal fun TypedArray.inflateMenu(context: Context, menu: Menu, @StyleableRes index: Int) { + getResourceId(index, 0).takeUnless { it == 0 }?.let { MenuInflater(context).inflate(it, menu) } +} diff --git a/platform/autos/src/main/res/values/styleables.xml b/platform/autos/src/main/res/values/styleables.xml new file mode 100644 index 000000000..9547eb397 --- /dev/null +++ b/platform/autos/src/main/res/values/styleables.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/platform/testing/src/androidTest/java/com/jeanbarrossilva/orca/platform/testing/IntExtensionsTests.kt b/platform/testing/src/androidTest/java/com/jeanbarrossilva/orca/platform/testing/IntExtensionsTests.kt index f83f7232d..01c4f6ae6 100644 --- a/platform/testing/src/androidTest/java/com/jeanbarrossilva/orca/platform/testing/IntExtensionsTests.kt +++ b/platform/testing/src/androidTest/java/com/jeanbarrossilva/orca/platform/testing/IntExtensionsTests.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2023 Orca + * Copyright © 2023-2024 Orca * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation, either version 3 of the @@ -16,11 +16,17 @@ package com.jeanbarrossilva.orca.platform.testing import assertk.assertThat +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import com.jeanbarrossilva.orca.platform.testing.test.R import org.junit.Test internal class IntExtensionsTests { + @Test + fun stringFromEmptyStringResourceIDIsEmpty() { + assertThat(emptyStringResourceID.asString()).isEmpty() + } + @Test fun getsStringFromResourceID() { assertThat(R.string.string.asString()).isEqualTo("5️⃣👍🏽🏞️👩🏻‍💻⏩🎇") diff --git a/platform/testing/src/main/java/com/jeanbarrossilva/orca/platform/testing/Int.extensions.kt b/platform/testing/src/main/java/com/jeanbarrossilva/orca/platform/testing/Int.extensions.kt index 1423f9918..4378043f4 100644 --- a/platform/testing/src/main/java/com/jeanbarrossilva/orca/platform/testing/Int.extensions.kt +++ b/platform/testing/src/main/java/com/jeanbarrossilva/orca/platform/testing/Int.extensions.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2023 Orca + * Copyright © 2023-2024 Orca * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation, either version 3 of the @@ -19,6 +19,9 @@ import android.content.res.Resources import androidx.annotation.StringRes import androidx.test.platform.app.InstrumentationRegistry +/** Resource ID of an empty [String]. */ +@StringRes val emptyStringResourceID = R.string.empty + /** * Gets the [String] from this resource ID. * diff --git a/platform/testing/src/main/res/values/strings.xml b/platform/testing/src/main/res/values/strings.xml new file mode 100644 index 000000000..c39260c11 --- /dev/null +++ b/platform/testing/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file