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.
This commit is contained in:
Adam Brown 2026-07-11 02:06:27 -07:00
parent 44d252d0dc
commit 21d1719bd4
6 changed files with 130 additions and 5 deletions

View file

@ -149,4 +149,6 @@ kover {
}
}
}
}
}
apply(from = "crowdin-screenshots.gradle.kts")

View file

@ -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.")
}
}

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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%