diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6fe8b7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ + +### Describe the bug +<-- A clear and concise description of what the bug is. --!> + +### To Reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +### Expected behavior +A clear and concise description of what you expected to happen. + +### Screenshots +If applicable, add screenshots to help explain your problem. + +### Environment (please complete the following information): +- **Device:** [e.g. Pixel 7] +- **OS:** [e.g. Android 14] +- **App Version:** [e.g. 1.0.1] + +### Additional context +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2dd8717 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ + + +### Is your feature request related to a problem? +<-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] --!> + +### Describe the solution you'd like +<-- A clear and concise description of what you want to happen. --!> + +### Describe alternatives you've considered +<-- A clear and concise description of any alternative solutions or features you've considered. --!> + +### Additional context +<-- Add any other context or screenshots about the feature request here. --!> + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a8fb724..f713a2b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,3 @@ ---- -name: Pull Request -about: Create a new pull request -labels: '' ---- ## Task summery diff --git a/build-logic/convention/src/main/java/dev/love/winter/convention/KotlinAndroid.kt b/build-logic/convention/src/main/java/dev/love/winter/convention/KotlinAndroid.kt index 00eeedd..4411d43 100644 --- a/build-logic/convention/src/main/java/dev/love/winter/convention/KotlinAndroid.kt +++ b/build-logic/convention/src/main/java/dev/love/winter/convention/KotlinAndroid.kt @@ -24,7 +24,7 @@ internal fun Project.configureApplication() { } androidResources { - localeFilters += listOf("en", "ko") + localeFilters += listOf("en", "ko", "ja", "zh-rCN", "vi", "th") } buildTypes { diff --git a/config/scripts/.gitignore b/config/scripts/.gitignore new file mode 100644 index 0000000..beac9d4 --- /dev/null +++ b/config/scripts/.gitignore @@ -0,0 +1,7 @@ +# Service Account credentials +*.json +service-account*.json + +# Translation config files (except example) +translations/* +!translations/example.properties diff --git a/config/scripts/build.gradle.kts b/config/scripts/build.gradle.kts new file mode 100644 index 0000000..341423e --- /dev/null +++ b/config/scripts/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("winter.kotlin.library") +} + +dependencies { + // Google Sheets API v4 + implementation("com.google.apis:google-api-services-sheets:v4-rev20220927-2.0.0") + + // Google Auth Library for Service Account + implementation("com.google.auth:google-auth-library-oauth2-http:1.40.0") + + // Required for JSON parsing by Google's libraries + implementation("com.google.code.gson:gson:2.13.2") + + // HTTP transport for Google API + implementation("com.google.http-client:google-http-client-jackson2:2.0.2") +} + +/** + * ./gradlew :config:scripts:updateTranslations -Pargs="example" + * ./gradlew :config:scripts:updateTranslations -Pargs="sample-app" + * ./gradlew :config:scripts:updateTranslations -Pargs="app" + */ +tasks.register("updateTranslations") { + group = "translation" + description = "Update translations from Google Sheets to Android strings.xml" + mainClass.set("dev.love.winter.scripts.UpdateTranslationsKt") + classpath = sourceSets["main"].runtimeClasspath + workingDir = rootProject.projectDir + + // Pass command line arguments to the script + val argsProperty = providers.gradleProperty("args") + if (argsProperty.isPresent) { + args(argsProperty.get().split(" ")) + } +} diff --git a/config/scripts/src/main/AndroidManifest.xml b/config/scripts/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1d26c87 --- /dev/null +++ b/config/scripts/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/config/scripts/src/main/java/dev/love/winter/scripts/UpdateTranslations.kt b/config/scripts/src/main/java/dev/love/winter/scripts/UpdateTranslations.kt new file mode 100644 index 0000000..e0cc34a --- /dev/null +++ b/config/scripts/src/main/java/dev/love/winter/scripts/UpdateTranslations.kt @@ -0,0 +1,273 @@ +@file:Suppress("MatchingDeclarationName") + +package dev.love.winter.scripts + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.sheets.v4.Sheets +import com.google.api.services.sheets.v4.SheetsScopes +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.ServiceAccountCredentials +import java.io.File +import java.io.FileInputStream +import java.io.FileWriter +import java.util.Properties +import kotlin.system.exitProcess + +data class ScriptConfig( + val spreadsheetId: String, + val sheetName: String, + val serviceAccountJsonPath: String, + val appModulePath: String, +) + +/** + * ./gradlew :config:scripts:updateTranslations -Pargs="example" + * ./gradlew :config:scripts:updateTranslations -Pargs="sample-app" + * ./gradlew :config:scripts:updateTranslations -Pargs="app" + */ +fun main(args: Array) { + if (args.isEmpty()) { + printUsage() + exitProcess(1) + } + + val config = loadConfigFromFile(args[0]) + runTranslationUpdate(config) +} + +fun loadConfigFromFile(configPathOrAppName: String): ScriptConfig { + val configFile = if (configPathOrAppName.endsWith(".properties")) { + File(configPathOrAppName) + } else { + File("config/scripts/translations/$configPathOrAppName.properties") + } + + check(configFile.exists()) { + "Config file not found: ${configFile.absolutePath}\n" + + "Available configs: ${File("config/scripts/translations").listFiles()?.joinToString { it.name } ?: "none"}" + } + + val properties = Properties() + FileInputStream(configFile).use { properties.load(it) } + + val configDir = configFile.parentFile + + return ScriptConfig( + spreadsheetId = properties.getProperty("spreadsheet.id") ?: error("Missing required property: spreadsheet.id"), + sheetName = properties.getProperty("sheet.name") ?: error("Missing required property: sheet.name"), + serviceAccountJsonPath = resolveFilePath( + path = properties.getProperty("service.account.json.path") ?: error("Missing required property: service.account.json.path"), + baseDir = configDir, + ), + appModulePath = properties.getProperty("app.module.path") + ?: error("Missing required property: app.module.path"), + ) +} + +fun resolveFilePath(path: String, baseDir: File): String { + val file = File(path) + return if (file.isAbsolute) { + path + } else { + File(baseDir, path).absolutePath + } +} + +fun runTranslationUpdate(config: ScriptConfig) { + try { + println("Starting translation update...") + println(" Spreadsheet ID: ${config.spreadsheetId}") + println(" Sheet Name: ${config.sheetName}") + println(" App Module: ${config.appModulePath}") + + val translationData = fetchTranslationsFromSheet(config) + val parsedData = parseTranslationData(translationData) + saveTranslationsAsXml(parsedData, config.appModulePath) + + println("✓ Translations successfully saved!") + println(" Languages: ${parsedData.languages.joinToString()}") + println(" Total keys: ${parsedData.translations.size}") + } catch (e: Exception) { + System.err.println("✗ Error occurred: ${e.message}") + exitProcess(1) + } +} + +fun printUsage() { + val availableConfigs = File("config/scripts/translations").listFiles() + ?.filter { it.extension == "properties" } + ?.map { it.nameWithoutExtension } + ?: emptyList() + + println( + """ + Usage: ./gradlew :config:scripts:updateTranslations --args="" + + Available configurations in translations/: + ${availableConfigs.joinToString("\n ") { "- $it" }} + + Examples: + # Use app config from translations/example.properties + ./gradlew :config:scripts:updateTranslations --args="example" + + # Use custom config file path + ./gradlew :config:scripts:updateTranslations --args="path/to/custom.properties" + + To add a new app configuration: + 1. Copy translations/example.properties to translations/your-app.properties + 2. Update the properties file with your app's settings + 3. Run: ./gradlew :config:scripts:updateTranslations --args="your-app" + """.trimIndent() + ) +} + +fun fetchTranslationsFromSheet(config: ScriptConfig): List> { + val credentials = ServiceAccountCredentials.fromStream( + FileInputStream(config.serviceAccountJsonPath) + ).createScoped(listOf(SheetsScopes.SPREADSHEETS_READONLY)) + + val httpTransport = GoogleNetHttpTransport.newTrustedTransport() + val jsonFactory = GsonFactory.getDefaultInstance() + + val sheetsService = Sheets.Builder( + httpTransport, + jsonFactory, + HttpCredentialsAdapter(credentials) + ) + .setApplicationName("Translation Update Script") + .build() + + val response = sheetsService.spreadsheets().values() + .get(config.spreadsheetId, config.sheetName) + .execute() + + val values = response.getValues() ?: emptyList() + check(values.isNotEmpty()) { "No data found in sheet: ${config.sheetName}" } + + return values +} + +data class TranslationData( + val languages: List, + val translations: List, +) + +data class TranslationEntry( + val key: String, + val values: Map, + val context: String?, +) + +fun parseTranslationData(data: List>): TranslationData { + check(data.isNotEmpty()) { "Translation data is empty" } + + val headers = data[0].map { it.toString() } + val keyIndex = headers.indexOf("key") + check(keyIndex >= 0) { "Missing 'key' column in spreadsheet" } + + val contextIndex = headers.indexOf("context").takeIf { it >= 0 } + + val languageColumns = headers.mapIndexedNotNull { index, header -> + if (header != "key" && header != "context" && header != "screen" && header.length <= 5) { + index to header + } else { + null + } + } + + check(languageColumns.isNotEmpty()) { "No language columns found in spreadsheet" } + + val languages = languageColumns.map { it.second } + val translations = data.drop(1).mapNotNull { row -> + if (row.size <= keyIndex) return@mapNotNull null + + val key = row[keyIndex].toString().trim() + if (key.isEmpty()) return@mapNotNull null + + val values = languageColumns.associate { (index, lang) -> + lang to (row.getOrNull(index)?.toString()?.trim() ?: "") + } + + val context = contextIndex?.let { row.getOrNull(it)?.toString()?.trim() } + + TranslationEntry( + key = key, + values = values, + context = context, + ) + } + + return TranslationData( + languages = languages, + translations = translations, + ) +} + +fun convertToAndroidResourceQualifier(languageCode: String): String { + val parts = languageCode.split("-") + return when (parts.size) { + 1 -> { + parts[0].lowercase() + } + 2 -> { + val language = parts[0].lowercase() + val region = parts[1].uppercase() + "$language-r$region" + } + else -> { + languageCode.lowercase() + } + } +} + +fun saveTranslationsAsXml(data: TranslationData, appModulePath: String) { + data.languages.forEach { language -> + val androidLanguageCode = convertToAndroidResourceQualifier(language) + val valuesDir = if (language == "en") { + File(appModulePath, "src/main/res/values") + } else { + File(appModulePath, "src/main/res/values-$androidLanguageCode") + } + + valuesDir.mkdirs() + val stringsFile = File(valuesDir, "strings.xml") + + FileWriter(stringsFile).use { writer -> + writer.write("\n") + writer.write("\n") + + data.translations.forEach { entry -> + val value = entry.values[language] ?: "" + if (value.isNotEmpty()) { + if (!entry.context.isNullOrBlank()) { + writer.write(" \n") + } + val escapedValue = escapeXml(value) + writer.write(" $escapedValue\n") + } + } + + writer.write("\n") + } + + println(" ✓ ${stringsFile.relativeTo(File(appModulePath)).path}") + } +} + +fun escapeXml(text: String): String { + var result = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "\\'") + .replace("\n", "\\n") + + // Escape @ symbol at the beginning of the string (Android resource reference) + if (result.startsWith("@")) { + result = "\\@${result.substring(1)}" + } + + return result +} diff --git a/config/scripts/translations/example.properties b/config/scripts/translations/example.properties new file mode 100644 index 0000000..c590125 --- /dev/null +++ b/config/scripts/translations/example.properties @@ -0,0 +1,17 @@ +# Example Translation Configuration +# Copy this file and rename it for each app (e.g., design-system-catalog.properties) + +# Google Spreadsheet ID (from URL) +# https://docs.google.com/spreadsheets/d/[THIS_PART]/edit +spreadsheet.id=YOUR_SPREADSHEET_ID_HERE + +# Sheet name (will fetch all data from this sheet) +sheet.name=Translations + +# Service Account JSON key file path (relative to this properties file or absolute path) +service.account.json.path=service-account.json + +# App module path (relative to project root) +# Example: sample/design-system-catalog +# Example: app +app.module.path=sample/design-system-catalog diff --git a/sample/common/src/main/java/dev/love/winter/sample/common/util/LocaleManager.kt b/sample/common/src/main/java/dev/love/winter/sample/common/util/LocaleManager.kt new file mode 100644 index 0000000..379f14b --- /dev/null +++ b/sample/common/src/main/java/dev/love/winter/sample/common/util/LocaleManager.kt @@ -0,0 +1,11 @@ +package dev.love.winter.sample.common.util + +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat + +object LocaleManager { + fun setLocale(languageTag: String) { + val appLocale = LocaleListCompat.forLanguageTags(languageTag) + AppCompatDelegate.setApplicationLocales(appLocale) + } +} diff --git a/sample/common/src/main/res/values-ja/strings.xml b/sample/common/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..35d0669 --- /dev/null +++ b/sample/common/src/main/res/values-ja/strings.xml @@ -0,0 +1,57 @@ + + + + LanguageStudy + + デザインシステム + + コンポーネントとスタイルの総合カタログ + + デザイントークン + + デザイントークン + + コンポーネント + + コンポーネント + Winter + Designed & Development by Winter + \@ 2025 All rights reserved. + オプション + カラーシステム + ディスプレイ + ディスプレイフォントスタイルは、大きな見出しや強調が必要な主要テキストに使用してください。ユーザーの注意を引き、力強い印象を与えるのに最適です。長いテキストには使用しないでください。 + タイトル + 카드 제목 + 本文 + 本文フォントスタイルは、段落や説明などの通常のテキストコンテンツに最適です。可読性と判読性が重要な長いテキストと短いテキストの両方に適しています。 + アクション + アクションフォントスタイルは、ボタン、入力フィールド、リンクなど、インタラクティブまたは実行可能なアイテムを表すテキスト要素に適しています。通常のテキストとインタラクティブ要素を区別するために使用してください。 + キャプション + キャプションフォントスタイルは、アイコン、画像、タグなどの視覚要素を補完し、コンテキストを提供する補助テキストに使用されます。長いテキストには使用しないでください。 + スペーシング + スペーシングトークンは、要素間の一貫した間隔と配置を保証し、より良い可読性、明確性、バランスを提供します。 + 使用方法 + スペーシングトークンを使用して、正しい階層でコンテンツを整理してください。可読性を高めるには、関連する要素をグループ化することが重要です。関連項目には小さい間隔値を使用し、無関係な項目間では間隔を広げることをお勧めします。 + 컴포넌트를 쌓을 때는 그룹 내 모든 요소 사이에 동일한 간격을 사용하세요. + ボーダー半径 + ボーダー半径トークンは、ボタン、カード、入力フィールド、コンテナなどの角が丸い要素に適用され、エッジを柔らかくし、より美しい外観を作成します。 + トークン + エクストラスモール + エクストラスモールは、非常に微妙な丸い角に使用してください。 + スモール + スモールは、最小の要素やネストされたコンポーネントに使用してください。 + ミディアム + ミディアムは、ほとんどのコンポーネントで使用されます。小型から中型のコンポーネントとコンテナに使用してください。 + ラージ + ラージは、中型から大型のコンポーネントとコンテナに使用してください。 + エクストララージ + エクストララージは、特にタブレット画面で最も大きな要素に使用してください。 + ピル + ピルは、両側が完全に丸いコンポーネントに使用してください。 + アイコン + アイコンは、アクションやコンポーネントに追加の意味を提供することで使いやすさを向上させ、視覚的により魅力的で理解しやすくするグラフィック資産です。 + アイコンの作成 + 新しいアイコンを作成する場合は、24pxのベースから始め、1.5pxのストロークを使用してアイコンを描いてください。一貫したアイコンをデザインするために、アイコングリッド(下記)を使用してください。 + 言語を選択 + diff --git a/sample/common/src/main/res/values-ko/strings.xml b/sample/common/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..ea42b91 --- /dev/null +++ b/sample/common/src/main/res/values-ko/strings.xml @@ -0,0 +1,57 @@ + + + + LanguageStudy + + 디자인 시스템 + + 컴포넌트와 스타일의 종합 카탈로그 + + 디자인 토큰 + + 디자인 토큰 + + 컴포넌트 + + 컴포넌트 + Winter + Designed & Development by Winter + \@ 2025 All rights reserved. + 옵션 + 컬러 시스템 + 디스플레이 + 디스플레이 폰트 스타일은 큰 제목이나 강조가 필요한 주요 텍스트에 사용하세요. 사용자의 시선을 끌고 강렬한 인상을 주기에 이상적입니다. 긴 텍스트에는 사용하지 마세요. + 타이틀 + 타이틀 폰트 스타일은 레이아웃을 압도하지 않으면서 명확한 시각적 계층이 필요할 때 사용하세요. 섹션 제목 + 본문 + 본문 폰트 스타일은 문단이나 설명과 같은 일반 텍스트 콘텐츠에 이상적입니다. 가독성과 판독성이 중요한 긴 텍스트와 짧은 텍스트 모두에 적합합니다. + 액션 + 액션 폰트 스타일은 버튼, 입력 필드, 링크와 같이 상호작용하거나 실행 가능한 항목을 나타내는 텍스트 요소에 적합합니다. 일반 텍스트와 상호작용 요소를 구분하는 데 사용하세요. + 캡션 + 캡션 폰트 스타일은 아이콘, 이미지, 태그 등 시각적 요소를 보완하고 맥락을 제공하는 보조 텍스트에 사용됩니다. 긴 텍스트에는 사용하지 마세요. + 간격 + 간격 토큰은 요소 간 일관된 간격과 정렬을 보장하며, 더 나은 가독성, 명확성, 균형을 제공합니다. + 사용법 + 간격 토큰을 사용하여 올바른 계층 구조로 콘텐츠를 구성하세요. 가독성을 높이려면 관련 요소를 그룹화하는 것이 중요합니다. 관련된 항목은 작은 간격 값을 사용하고, 관련 없는 항목 사이에는 간격을 늘리는 것이 좋습니다. + use the same spacing between all elements in the group. + 모서리 반경 + 모서리 반경 토큰은 버튼, 카드, 입력 필드, 컨테이너 등 둥근 모서리를 가진 요소에 적용되어 가장자리를 부드럽게 하고 더 보기 좋은 외관을 만듭니다. + 토큰 + 엑스트라 스몰 + 엑스트라 스몰은 매우 미묘한 둥근 모서리에 사용하세요. + 스몰 + 스몰은 가장 작은 요소나 중첩된 컴포넌트에 사용하세요. + 미디엄 + 미디엄은 대부분의 컴포넌트에 사용됩니다. 소형에서 중형 컴포넌트 및 컨테이너에 사용하세요. + 라지 + 라지는 중형에서 대형 컴포넌트 및 컨테이너에 사용하세요. + 엑스트라 라지 + 엑스트라 라지는 가장 큰 요소, 특히 태블릿 화면에서 사용하세요. + + 필은 양쪽이 완전히 둥근 컴포넌트에 사용하세요. + 아이콘 + 아이콘은 액션과 컴포넌트에 추가적인 의미를 제공하여 사용성을 향상시키고, 시각적으로 더 매력적이며 이해하기 쉽게 만드는 그래픽 자산입니다. + 아이콘 만들기 + 새 아이콘을 만들 때는 24px 베이스로 시작하고 1.5px 스트로크를 사용하여 아이콘을 그리세요. 일관된 아이콘을 디자인하는 데 도움이 되도록 아이콘 그리드(아래 표시)를 사용하세요. + 언어 선택 + diff --git a/sample/common/src/main/res/values-night/themes.xml b/sample/common/src/main/res/values-night/themes.xml index dd7aa0d..05a473b 100644 --- a/sample/common/src/main/res/values-night/themes.xml +++ b/sample/common/src/main/res/values-night/themes.xml @@ -1,6 +1,6 @@ -