diff --git a/.gitattributes b/.gitattributes index 6fee576c89..61d9cc4993 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,5 @@ *.properties text *.xml text *.yml text + +**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a93e4a559e..eae6fe6c55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,6 +86,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + lfs: 'true' - name: Cache Maven uses: actions/cache@v4 with: @@ -101,7 +102,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - name: Run Unit Tests - run: ./gradlew test koverXmlReportDebug koverXmlReportProductionDebug --max-workers 1 --scan + run: ./gradlew test verifyPaparazzi koverXmlReportDebug koverXmlReportProductionDebug --max-workers 1 --scan - name: Codecov uses: codecov/codecov-action@v4 with: diff --git a/.github/workflows/git-lfs-validation.yml b/.github/workflows/git-lfs-validation.yml new file mode 100644 index 0000000000..dc2c8b498b --- /dev/null +++ b/.github/workflows/git-lfs-validation.yml @@ -0,0 +1,15 @@ +name: "Validate Git LFS" + +on: + push: + branches: [develop, master, feature/*] + pull_request: + branches: [develop, master, feature/*] + +jobs: + validate-lfs-pointers: + name: "Validate Git LFS pointers" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: git lfs fsck --pointers diff --git a/README.md b/README.md index 363948ad8b..c632411a32 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ GodTools Android [![codecov](https://codecov.io/gh/CruGlobal/godtools-android/branch/develop/graph/badge.svg)](https://codecov.io/gh/CruGlobal/godtools-android) +# Git LFS + +We use [Git LFS](https://git-lfs.com/) for storing Paparazzi snapshots. You will need to setup [Git LFS](https://git-lfs.com/) on your local machine in order to store new paparazzi snapshots or validate existing paparazzi snapshots. + # OneSky To enable OneSky translation downloads/uploads configure the following [gradle properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties): diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ccb0ca21b0..d55f89bcee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.grgit) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.paparazzi) } android { @@ -232,6 +233,7 @@ dependencies { testImplementation(libs.androidx.arch.core.testing) testImplementation(libs.androidx.lifecycle.runtime.testing) testImplementation(libs.androidx.test.espresso.core) + testImplementation(libs.coil.test) testImplementation(libs.gtoSupport.testing.dagger) testImplementation(libs.hilt.testing) testImplementation(libs.kotlin.coroutines.test) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicator.kt b/app/src/main/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicator.kt index c659fa927f..c028f5bef2 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicator.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicator.kt @@ -2,6 +2,9 @@ package org.cru.godtools.ui.languages.downloadable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.border +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -14,20 +17,23 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +private val DEFAULT_ICON_SIZE = 24.dp + @Composable internal fun LanguageDownloadProgressIndicator( isPinned: Boolean, downloaded: Int, total: Int, modifier: Modifier = Modifier, - iconSize: Dp = 24.dp, ) { val total = total.coerceAtLeast(0) val downloaded = downloaded.coerceIn(0, total) - val contentModifier = modifier.size(iconSize) + val contentModifier = modifier + .size(DEFAULT_ICON_SIZE) + .aspectRatio(1f) when { !isPinned -> Icon( @@ -51,15 +57,19 @@ internal fun LanguageDownloadProgressIndicator( }, ) - val iconPadding = iconSize / 12 - CircularProgressIndicator( - progress = { progress }, - color = MaterialTheme.colorScheme.primary, - strokeWidth = (iconSize / 2) - iconPadding, - modifier = contentModifier - .padding(iconPadding) - .border(iconSize / 12, MaterialTheme.colorScheme.primary, CircleShape) - ) + BoxWithConstraints(contentModifier) { + val size = with(LocalDensity.current) { constraints.maxWidth.toDp() } + val iconPadding = size / 12 + CircularProgressIndicator( + progress = { progress }, + color = MaterialTheme.colorScheme.primary, + strokeWidth = (size / 2) - iconPadding, + modifier = Modifier + .fillMaxSize() + .padding(iconPadding) + .border(size / 12, MaterialTheme.colorScheme.primary, CircleShape) + ) + } } } } diff --git a/app/src/test/resources/org/cru/godtools/ui/tools/banner.jpg b/app/src/test/resources/org/cru/godtools/ui/tools/banner.jpg new file mode 100644 index 0000000000..3d87fcae0f Binary files /dev/null and b/app/src/test/resources/org/cru/godtools/ui/tools/banner.jpg differ diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.languages.downloadable_LanguageDownloadProgressIndicatorPaparazziTest_LanguageDownloadProgressIndicator().png b/app/src/test/snapshots/images/org.cru.godtools.ui.languages.downloadable_LanguageDownloadProgressIndicatorPaparazziTest_LanguageDownloadProgressIndicator().png new file mode 100644 index 0000000000..6935f93a05 --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.languages.downloadable_LanguageDownloadProgressIndicatorPaparazziTest_LanguageDownloadProgressIndicator().png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6f61efafe1de02467b685c34a38021c17af15ca469fcd6650ee6de5160740ed +size 9218 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Default.png b/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Default.png new file mode 100644 index 0000000000..4f9ea98b7f --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dfcb46e7242d767a6175f7f912fc5d2cd6d5ab8b189aedf6c33f4f97e4660eb +size 37294 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Downloading.png b/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Downloading.png new file mode 100644 index 0000000000..e33b72ea1f --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Downloading.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37f9e27c861999582d0413ee81e1778f74a4c70d4444d4289e5400435a56ea93 +size 36695 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Show Second Language.png b/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Show Second Language.png new file mode 100644 index 0000000000..fdbdc1d104 --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.tools_SquareToolCardPaparazziTest_SquareToolCard() - Show Second Language.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abacd4fdb55657953e55003f7645529ca04b9ab3302c848094c53b5307657c98 +size 38116 diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicatorPaparazziTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicatorPaparazziTest.kt new file mode 100644 index 0000000000..015e659f3d --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/languages/downloadable/LanguageDownloadProgressIndicatorPaparazziTest.kt @@ -0,0 +1,30 @@ +package org.cru.godtools.ui.languages.downloadable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.cash.paparazzi.Paparazzi +import kotlin.test.Test +import org.cru.godtools.base.ui.theme.GodToolsTheme +import org.junit.Rule + +class LanguageDownloadProgressIndicatorPaparazziTest { + @get:Rule + val paparazzi = Paparazzi() + + @Test + fun `LanguageDownloadProgressIndicator()`() { + paparazzi.snapshot { + GodToolsTheme(disableDagger = true) { + Box { + Row(Modifier.align(Alignment.Center)) { + for (it in 0..5) { + LanguageDownloadProgressIndicator(isPinned = true, downloaded = it, total = 5) + } + } + } + } + } + } +} diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardPaparazziTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardPaparazziTest.kt new file mode 100644 index 0000000000..4fe0c90199 --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardPaparazziTest.kt @@ -0,0 +1,113 @@ +package org.cru.godtools.ui.tools + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.cash.paparazzi.Paparazzi +import coil.Coil +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.test.FakeImageLoaderEngine +import io.mockk.mockk +import java.util.Locale +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.cru.godtools.base.ui.theme.GodToolsTheme +import org.cru.godtools.downloadmanager.DownloadProgress +import org.cru.godtools.model.Language +import org.cru.godtools.model.Tool +import org.cru.godtools.model.randomTool +import org.cru.godtools.model.randomTranslation +import org.junit.Rule + +class SquareToolCardPaparazziTest { + @get:Rule + val paparazzi = Paparazzi() + + private val toolState = ToolCard.State( + tool = randomTool( + name = "Tool Title", + category = Tool.CATEGORY_GOSPEL, + ), + banner = mockk(), + secondLanguage = Language(Locale.FRENCH), + secondTranslation = randomTranslation() + ) + + @BeforeTest + @OptIn(ExperimentalCoilApi::class, ExperimentalCoroutinesApi::class) + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + val banner = Drawable.createFromStream(javaClass.getResourceAsStream("banner.jpg"), "banner.jpg")!! + Coil.setImageLoader( + ImageLoader.Builder(paparazzi.context) + .components { + add( + FakeImageLoaderEngine.Builder() + .intercept(toolState.banner!!, banner) + .build() + ) + } + .build() + ) + } + + @AfterTest + @OptIn(ExperimentalCoroutinesApi::class) + fun cleanup() { + Coil.reset() + Dispatchers.resetMain() + } + + @Test + fun `SquareToolCard() - Default`() { + paparazzi.snapshot { + GodToolsTheme(disableDagger = true) { + Box { + SquareToolCard( + state = toolState, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + + @Test + fun `SquareToolCard() - Downloading`() { + paparazzi.snapshot { + GodToolsTheme(disableDagger = true) { + Box { + SquareToolCard( + state = toolState.copy( + downloadProgress = DownloadProgress(2, 5) + ), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + + @Test + fun `SquareToolCard() - Show Second Language`() { + paparazzi.snapshot { + GodToolsTheme(disableDagger = true) { + Box { + SquareToolCard( + state = toolState, + showSecondLanguage = true, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } +} diff --git a/build-logic/src/main/kotlin/AndroidTestConfiguration.kt b/build-logic/src/main/kotlin/AndroidTestConfiguration.kt index 12c6206a49..13c0680758 100644 --- a/build-logic/src/main/kotlin/AndroidTestConfiguration.kt +++ b/build-logic/src/main/kotlin/AndroidTestConfiguration.kt @@ -59,4 +59,7 @@ internal fun TestedExtension.configureTestOptions(project: Project) { } } } + + // Paparazzi + project.fixGuavaDependencyForPaparazzi() } diff --git a/build-logic/src/main/kotlin/PaparazziConfiguration.kt b/build-logic/src/main/kotlin/PaparazziConfiguration.kt new file mode 100644 index 0000000000..a5dcd1f420 --- /dev/null +++ b/build-logic/src/main/kotlin/PaparazziConfiguration.kt @@ -0,0 +1,24 @@ +import org.gradle.api.Project +import org.gradle.api.attributes.java.TargetJvmEnvironment + +internal fun Project.fixGuavaDependencyForPaparazzi() { + plugins.withId("app.cash.paparazzi") { + // Defer until afterEvaluate so that testImplementation is created by Android plugin. + afterEvaluate { + dependencies.constraints { + add("testImplementation", "com.google.guava:guava") { + attributes { + attribute( + TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, + objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM) + ) + } + because( + "LayoutLib and sdk-common depend on Guava's -jre published variant. " + + "See https://github.com/cashapp/paparazzi/issues/906." + ) + } + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ded019cd1..c19703309a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-room = "2.6.1" androidx-viewpager2 = "1.0.0" androidx-work = "2.9.0" circuit = "0.19.1" +coil = "2.6.0" dagger = "2.51" eventbus = "3.3.1" facebook = "16.3.0" @@ -88,7 +89,8 @@ circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version. circuit-test = { module = "com.slack.circuit:circuit-test", version.ref = "circuit" } circuitx-android = { module = "com.slack.circuit:circuitx-android", version.ref = "circuit" } circuitx-effects = { module = "com.slack.circuit:circuitx-effects", version.ref = "circuit" } -coil-compose = "io.coil-kt:coil-compose:2.6.0" +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +coil-test = { module = "io.coil-kt:coil-test", version.ref = "coil" } compose-reorderable = "org.burnoutcrew.composereorderable:reorderable:0.9.6" dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } @@ -240,3 +242,4 @@ grgit = { id = "org.ajoberstar.grgit", version = "5.2.2" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } +paparazzi = { id = "app.cash.paparazzi", version = "1.3.3" } diff --git a/ui/base/src/main/kotlin/org/cru/godtools/base/ui/compose/CompositionLocals.kt b/ui/base/src/main/kotlin/org/cru/godtools/base/ui/compose/CompositionLocals.kt index 353e063323..8798296b25 100644 --- a/ui/base/src/main/kotlin/org/cru/godtools/base/ui/compose/CompositionLocals.kt +++ b/ui/base/src/main/kotlin/org/cru/godtools/base/ui/compose/CompositionLocals.kt @@ -22,11 +22,13 @@ import org.greenrobot.eventbus.EventBus val LocalEventBus = staticCompositionLocalOf { EventBus() } @Composable -internal fun CompositionLocals(content: @Composable () -> Unit) { +internal fun CompositionLocals(disableDagger: Boolean = false, content: @Composable () -> Unit) { val context = LocalContext.current val daggerComponents = when { - LocalInspectionMode.current -> object : ComposeEntryPoint { - override val eventBus = EventBus() + LocalInspectionMode.current || disableDagger -> remember { + object : ComposeEntryPoint { + override val eventBus = EventBus() + } } else -> remember { EntryPointAccessors.fromApplication(context) } } diff --git a/ui/base/src/main/kotlin/org/cru/godtools/base/ui/theme/GodToolsTheme.kt b/ui/base/src/main/kotlin/org/cru/godtools/base/ui/theme/GodToolsTheme.kt index 64f2207b9b..f03d56ead2 100644 --- a/ui/base/src/main/kotlin/org/cru/godtools/base/ui/theme/GodToolsTheme.kt +++ b/ui/base/src/main/kotlin/org/cru/godtools/base/ui/theme/GodToolsTheme.kt @@ -114,7 +114,7 @@ object GodToolsTheme { } @Composable -fun GodToolsTheme(content: @Composable () -> Unit) { +fun GodToolsTheme(disableDagger: Boolean = false, content: @Composable () -> Unit) { val isDarkTheme = isSystemInDarkTheme() && BuildConfig.DEBUG MaterialTheme( colorScheme = when { @@ -123,7 +123,7 @@ fun GodToolsTheme(content: @Composable () -> Unit) { }, typography = GodToolsTheme.typography ) { - CompositionLocals { + CompositionLocals(disableDagger) { CompositionLocalProvider( LocalLightColorSchemeActive provides !isDarkTheme, LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background),