From 21d1719bd4976182dfeb7e289cab494de232cdaf Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 11 Jul 2026 02:06:27 -0700 Subject: [PATCH] Add Crowdin screenshot upload task and taller screen previews - uploadCrowdinScreenshots Gradle task (composeUi/crowdin-screenshots.gradle.kts): renders Compose previews and uploads the tablet screen previews to Crowdin with auto-tagging. Cross-platform via ProcessBuilder; auth from -P props, env vars, or interactive prompt. Defaults to Screen*Tablet*.png. - crowdin.yml: declare env-based project_id/api_token. - TABLET_TALL_HEIGHT_DP for screens whose content scrolls past a normal tablet height; applied to ProjectStats and ViewEntry tablet previews so their full content renders instead of clipping. --- composeUi/build.gradle.kts | 4 +- composeUi/crowdin-screenshots.gradle.kts | 113 ++++++++++++++++++ .../hammer/common/preview/TabletPreview.kt | 7 ++ .../encyclopedia/ViewEntryUi.Preview.kt | 4 +- .../projecthome/ProjectStatsUi.Preview.kt | 4 +- crowdin.yml | 3 + 6 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 composeUi/crowdin-screenshots.gradle.kts diff --git a/composeUi/build.gradle.kts b/composeUi/build.gradle.kts index f71ef6740..754ea998c 100644 --- a/composeUi/build.gradle.kts +++ b/composeUi/build.gradle.kts @@ -149,4 +149,6 @@ kover { } } } -} \ No newline at end of file +} + +apply(from = "crowdin-screenshots.gradle.kts") \ No newline at end of file diff --git a/composeUi/crowdin-screenshots.gradle.kts b/composeUi/crowdin-screenshots.gradle.kts new file mode 100644 index 000000000..4bb9dd82d --- /dev/null +++ b/composeUi/crowdin-screenshots.gradle.kts @@ -0,0 +1,113 @@ +// Renders Compose previews and uploads them to Crowdin with automatic string +// tagging. Auto-tag is a best-effort text match; mono labels and format +// strings (%1$s) usually still need a manual pass in the Crowdin editor. +// +// ./gradlew :composeUi:uploadCrowdinScreenshots +// +// By default only the tablet-form screen previews (Screen*Tablet*.png) are +// uploaded. +// +// Auth resolves in order: -Pcrowdin.projectId / -Pcrowdin.token, then the +// CROWDIN_PROJECT_ID / CROWDIN_PERSONAL_TOKEN env vars, then an interactive +// prompt (token hidden). Interactive prompting needs a real console — run with +// --console=plain --no-daemon if a prompt appears blank. +// +// Options (Gradle properties): +// -Pcrowdin.noRender Skip the render step; upload existing PNGs. +// -Pcrowdin.screenshots.pattern=X Filename glob to override the default, e.g. 'Screen*'. +// -Pcrowdin.branch=NAME Attach screenshots to a Crowdin branch. + +fun globToRegex(glob: String): Regex { + val sb = StringBuilder() + for (c in glob) when (c) { + '*' -> sb.append(".*") + '?' -> sb.append('.') + '.', '(', ')', '+', '|', '^', '$', '@', '%', '\\', '{', '}', '[', ']' -> + sb.append('\\').append(c) + else -> sb.append(c) + } + return Regex(sb.toString(), RegexOption.IGNORE_CASE) +} + +fun promptFor(message: String, hidden: Boolean): String? { + val console = System.console() + val value = if (console != null) { + if (hidden) console.readPassword(message)?.concatToString() + else console.readLine(message) + } else { + print(message) + System.out.flush() + readlnOrNull() + } + return value?.trim()?.takeIf { it.isNotEmpty() } +} + +tasks.register("uploadCrowdinScreenshots") { + group = "crowdin" + description = "Render Compose previews and upload them to Crowdin with auto-tagging." + + if (!providers.gradleProperty("crowdin.noRender").isPresent) { + dependsOn("composePreviewRender") + } + + val rendersDir = layout.buildDirectory.dir("compose-previews/renders") + val patternProp = providers.gradleProperty("crowdin.screenshots.pattern").orNull + val branchProp = providers.gradleProperty("crowdin.branch").orNull + val projectIdProp = providers.gradleProperty("crowdin.projectId").orNull + val tokenProp = providers.gradleProperty("crowdin.token").orNull + val repoRoot = rootProject.layout.projectDirectory.asFile + val isWindows = System.getProperty("os.name").lowercase().contains("win") + + doLast { + val dir = rendersDir.get().asFile + require(dir.isDirectory) { + "No renders at ${dir.path}. Run without -Pcrowdin.noRender first." + } + + val glob = patternProp ?: "Screen*Tablet*.png" + val regex = globToRegex(glob) + val files = dir.listFiles { f -> + f.isFile && f.name.endsWith(".png", ignoreCase = true) && regex.matches(f.name) + }?.sortedBy { it.name }.orEmpty() + require(files.isNotEmpty()) { "No PNGs in ${dir.path} matching '$glob'." } + + val projectId = projectIdProp + ?: System.getenv("CROWDIN_PROJECT_ID") + ?: promptFor("Crowdin project ID: ", hidden = false) + ?: throw GradleException("No Crowdin project ID provided.") + val token = tokenProp + ?: System.getenv("CROWDIN_PERSONAL_TOKEN") + ?: promptFor("Crowdin personal token (hidden): ", hidden = true) + ?: throw GradleException("No Crowdin personal token provided.") + + val launcher = if (isWindows) listOf("cmd", "/c", "crowdin") else listOf("crowdin") + + logger.lifecycle("Uploading ${files.size} screenshot(s) to Crowdin with auto-tagging") + var failed = 0 + for (f in files) { + logger.lifecycle("--> ${f.name}") + val command = launcher + listOf( + "screenshot", "upload", f.absolutePath, + "--name", f.name, + "--auto-tag", + "--update-strings", + ) + (branchProp?.let { listOf("--branch", it) } ?: emptyList()) + + val process = ProcessBuilder(command) + .directory(repoRoot) + .inheritIO() + .apply { + environment()["CROWDIN_PROJECT_ID"] = projectId + environment()["CROWDIN_PERSONAL_TOKEN"] = token + } + .start() + if (process.waitFor() != 0) { + failed++ + logger.error(" upload failed for ${f.name}") + } + } + + if (failed > 0) throw GradleException("$failed screenshot upload(s) failed.") + logger.lifecycle("Done. Review tag placement in the Crowdin editor.") + } +} diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/TabletPreview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/TabletPreview.kt index a98f2c79b..49b99ce3a 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/TabletPreview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/TabletPreview.kt @@ -22,6 +22,13 @@ import com.darkrockstudios.apps.hammer.common.uiNeedsExplicitCloseButtons const val TABLET_WIDTH_DP: Int = 1280 const val TABLET_HEIGHT_DP: Int = 800 +/** + * Extra-tall canvas for screens whose content scrolls well past a normal tablet + * height (e.g. project stats, encyclopedia entries). Use it as the `heightDp` so + * the whole screen renders for translation screenshots instead of being clipped. + */ +const val TABLET_TALL_HEIGHT_DP: Int = 1400 + /** * Wraps [content] in the app theme and a wide [ScreenCharacteristics] so screens * that branch on `LocalScreenCharacteristic` render their expanded/wide layouts diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/encyclopedia/ViewEntryUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/encyclopedia/ViewEntryUi.Preview.kt index 3974cb1b9..e7eea1aca 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/encyclopedia/ViewEntryUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/encyclopedia/ViewEntryUi.Preview.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState import com.darkrockstudios.apps.hammer.common.encyclopedia.ViewEntryUi import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview -import com.darkrockstudios.apps.hammer.common.preview.TABLET_HEIGHT_DP +import com.darkrockstudios.apps.hammer.common.preview.TABLET_TALL_HEIGHT_DP import com.darkrockstudios.apps.hammer.common.preview.TABLET_WIDTH_DP import com.darkrockstudios.apps.hammer.common.preview.TabletPreviewSurface import com.darkrockstudios.apps.hammer.common.preview.fakeViewEntryComponent @@ -37,7 +37,7 @@ fun ScreenViewEntryUiPreview() { } @OptIn(ExperimentalSharedTransitionApi::class) -@Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_HEIGHT_DP) +@Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_TALL_HEIGHT_DP) @Composable fun ScreenViewEntryUiTabletPreview() { val scope = rememberCoroutineScope() diff --git a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectStatsUi.Preview.kt b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectStatsUi.Preview.kt index 566d980b6..de1a793b5 100644 --- a/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectStatsUi.Preview.kt +++ b/composeUi/src/desktopMain/kotlin/com/darkrockstudios/apps/hammer/common/preview/projecthome/ProjectStatsUi.Preview.kt @@ -24,7 +24,7 @@ import com.darkrockstudios.apps.hammer.common.data.projectbackup.ProjectBackupDe import com.darkrockstudios.apps.hammer.common.data.projectstatistics.EntryAppearance import com.darkrockstudios.apps.hammer.common.fileio.HPath import com.darkrockstudios.apps.hammer.common.preview.KoinApplicationPreview -import com.darkrockstudios.apps.hammer.common.preview.TABLET_HEIGHT_DP +import com.darkrockstudios.apps.hammer.common.preview.TABLET_TALL_HEIGHT_DP import com.darkrockstudios.apps.hammer.common.preview.TABLET_WIDTH_DP import com.darkrockstudios.apps.hammer.common.preview.TabletPreviewSurface import com.darkrockstudios.apps.hammer.common.preview.dummyBackHandler @@ -57,7 +57,7 @@ fun ScreenProjectStatsUiPreview() { } } -@Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_HEIGHT_DP) +@Preview(widthDp = TABLET_WIDTH_DP, heightDp = TABLET_TALL_HEIGHT_DP) @Composable fun ScreenProjectStatsUiTabletPreview() { val scope = rememberCoroutineScope() diff --git a/crowdin.yml b/crowdin.yml index d5575aae7..998dcecb4 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,3 +1,6 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN + files: - source: /common/src/commonMain/composeResources/values/strings*.xml translation: /common/src/commonMain/composeResources/values-%android_code%/%original_file_name%