From 1ce7670237f7a3ef864f713967d9cc6e189dc081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Sun, 17 May 2026 16:30:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=96=B8=EC=96=B4=EC=84=A0=ED=83=9D=20UI?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=96=B8=EC=96=B4=20CSV=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20Gradle=20Task=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/language/LanguageSelectorScreen.kt | 15 +- app/src/main/res/values-en/strings.xml | 307 ++++++++++++++++ app/src/main/res/values-ja/strings.xml | 307 ++++++++++++++++ app/src/main/res/values-vi/strings.xml | 307 ++++++++++++++++ build.gradle.kts | 9 +- .../com/eatssu/common/enums/AppLanguage.kt | 6 +- .../component/EatSsuRadioCheckBox.kt | 94 +++++ .../com/eatssu/design_system/theme/Color.kt | 4 +- language.csv | 228 ++++++++++++ scripts/generate_android_strings.py | 336 ++++++++++++++++++ 10 files changed, 1600 insertions(+), 13 deletions(-) create mode 100644 app/src/main/res/values-en/strings.xml create mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-vi/strings.xml create mode 100644 core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuRadioCheckBox.kt create mode 100644 language.csv create mode 100644 scripts/generate_android_strings.py diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorScreen.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorScreen.kt index b02a68bce..432d9e400 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -15,7 +14,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.eatssu.android.R import com.eatssu.common.enums.AppLanguage -import com.eatssu.design_system.component.EatSsuRadioButtonGroup +import com.eatssu.design_system.component.EatSsuRadioCheckBoxGroup import com.eatssu.design_system.component.EatSsuTopBar import com.eatssu.design_system.theme.EatssuTheme @@ -63,13 +62,13 @@ fun LanguageSelectorContent( .fillMaxSize() .padding(horizontal = 24.dp) ) { - Text( - text = stringResource(R.string.language_select_description), - style = EatssuTheme.typography.body2, - modifier = Modifier.padding(vertical = 20.dp) - ) +// Text( +// text = stringResource(R.string.language_select_description), +// style = EatssuTheme.typography.body2, +// modifier = Modifier.padding(vertical = 20.dp) +// ) - EatSsuRadioButtonGroup( + EatSsuRadioCheckBoxGroup( options = languageOptions, selectedOption = selectedOption, onOptionSelected = { selected -> diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 000000000..a1296bd5e --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,307 @@ + + + + + + + + EAT-SSU + + + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + + + + + + Write a Review + Edit Review + Delete Account + Report + Widget Settings + Partner Map + Force Update + + + + + Cafeteria + Map + Lucky Game + My + Back + + + + + Done + Edit + Delete + Save + Report + Cancel + Confirm + Update + Check Availability -> Check + + + + + Log Out + Do you want to log out? + You need to install the latest version of the app. + Please allow location access so you can quickly check your location and find partner restaurants nearby. + Notification Permission Required + To receive notifications, you need to enable notification permission.\n Please allow notification access in Settings. + Go to Settings + Please enter a title + Please enter the content + No Network Connection + Please check your Wi-Fi or mobile data connection. + Notice + + + + + Your review has been posted. + Failed to post your review. + Your review has been updated. + Failed to update your review. + Your review has been deleted. + Failed to delete your review. + Could not load reviews. + + + + + Image uploaded successfully. + Failed to upload image. + Failed to compress image. + Could not find the image file. + + + + + Your report has been submitted successfully! + Failed to submit your report. + + + + + Failed to change your nickname. + Please select your college first. + Please select your department. + Your information has been updated. + Your nickname has been changed. + Your department information has been updated. + No changes were made. + + + + + Please enter %1$d to %2$d characters. + Whitespace characters other than spaces are not allowed. + Consecutive spaces are not allowed. + Only Korean, English letters, and numbers are allowed. + Consecutive special characters (--, __) are not allowed. + A nickname cannot contain only numbers. + A nickname cannot start or end with a special character. + Nicknames containing profanity or abusive language are not allowed. + This nickname is not valid. + + + + + An error occurred while initializing the app. + Please update the app. + No partner information found. + Login failed. + You have been logged out. + Please log in again. + Your session has expired. Please log in again. + Please set a nickname. + EAT-SSU notifications enabled (%1$s) + EAT-SSU notifications disabled (%1$s) + Could not load open-source libraries. + Your account has been deleted. + Failed to delete your account. + + + + + How was your meal today? + Any dishes you\'d recommend? + Write a detailed review of the menu + Preparing the screen. + Posting... + Updating... + Tap a photo to delete it. + Add Photo (%1$d/%2$d) + Review Settings + An error occurred. + Write a Review + + + + + Reviews + My Reviews + No reviews yet + Be the first to leave a review! + Be the first to review this menu item! + + + + + %1$d pts + + + + + Today\'s Menu + Price + Rating → ★ + Student Cafeteria + + + + + Select the cafeteria you want to check. + Select + Breakfast + Lunch + Dinner + No menu available today. + Loading + Please check your network connection. + Setup Required + Please select a cafeteria in Widget Settings. + + + + + My Page + My Info + Log Out + Delete Account + App Version + 알림 및 활동 + 서비스 정보 + 기타 + Push Notification Settings + We\'ll send you a notification every day at 11:00 AM. + + + Language Settings + Choose the language you want to use. + System Language (Default) + + + + + Kakao + Connected Account + + + + + Nickname + Please set a nickname + Please enter %1$d to %2$d characters. + This nickname is available! + + + + + Edit Nickname + Set Affiliation -> College & Department + + + + + Could not load the information. + Connection Error + Unable to communicate with the server.\n Please try again later. + + + + + Please choose why you are reporting this review. + You can report the same review only once within 24 hours. + Please write the reason for reporting this review + + + + + Up to 150 characters + + + + + + + + Eat at + Soongsil + + + + + Contact Us + Terms of Service + Privacy Policy + Privacy Policy + Terms of Service + 약관 및 정책 + Credits + Open-Source Libraries + + + + + Are you sure you want to delete your account? + Your reviews will not be deleted and will be displayed as (Unknown).\n For details, please check the Terms of Service and Privacy Policy. + Please enter your nickname + + + + + 🤔 What should I eat today? + Check today\'s cafeteria menu! + Before-Lunch Notifications + Sends push notifications before lunchtime. + Server Notifications + Shows notifications sent by the EAT-SSU server. + + + + + Enter your department + Enter your department\n and check your personalized partnerships! + College/Department information unavailable + + + + + Location + Notes + Hours + Favorite Partners + My Partners + 축제 + All + Department + + + + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 000000000..b897ca8fd --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,307 @@ + + + + + + + + EAT-SSU + + + + + + + + + + + + + + + + + + レビューを書く + レビューを編集 + アカウント削除 + 通報 + ウィジェット設定 + 提携マップ + 強制アップデート + + + + + 学食 + 地図 + ラッキーゲーム + マイページ + 戻る + + + + + 完了 + 編集 + 削除 + 保存 + 通報 + キャンセル + 確認 + アップデート + 重複確認 + + + + + ログアウト + ログアウトしますか? + アプリの最新バージョンをインストールする必要があります。 + 現在地をすばやく確認し、近くの提携店舗を探すために位置情報の利用を許可してください。 + 通知権限が必要です + 通知を受け取るには、通知権限を有効にする必要があります。\n 設定で通知を許可してください。 + 設定へ移動 + タイトルを入力してください + 内容を入力してください + ネットワーク接続なし + Wi-Fiまたはモバイルデータ接続を確認してください。 + お知らせ + + + + + レビューを投稿しました。 + レビューの投稿に失敗しました。 + レビューを更新しました。 + レビューの更新に失敗しました。 + レビューを削除しました。 + レビューの削除に失敗しました。 + レビューを読み込めませんでした。 + + + + + 画像をアップロードしました。 + 画像のアップロードに失敗しました。 + 画像の圧縮に失敗しました。 + 画像ファイルが見つかりませんでした。 + + + + + 通報が正常に送信されました! + 通報の送信に失敗しました。 + + + + + ニックネームの変更に失敗しました。 + 先に学部を選択してください。 + 学科を選択してください。 + 情報が更新されました。 + ニックネームが変更されました。 + 学科情報が更新されました。 + 変更内容はありません。 + + + + + %1$d〜%2$d文字で入力してください。 + スペース以外の空白文字は使用できません。 + 連続したスペースは使用できません。 + 韓国語、英字、数字のみ使用できます。 + 連続した特殊文字(--、__)は使用できません。 + 数字だけのニックネームは使用できません。 + ニックネームの先頭または末尾に特殊文字は使用できません。 + 悪口や不適切な表現を含むニックネームは使用できません。 + このニックネームは使用できません。 + + + + + アプリの初期化中にエラーが発生しました。 + アプリをアップデートしてください。 + 提携情報が見つかりません。 + ログインに失敗しました。 + ログアウトしました。 + もう一度ログインしてください。 + セッションの有効期限が切れました。もう一度ログインしてください。 + ニックネームを設定してください。 + EAT-SSU通知が有効になりました(%1$s) + EAT-SSU通知が無効になりました(%1$s) + オープンソースライブラリを読み込めませんでした。 + アカウントを削除しました。 + アカウントの削除に失敗しました。 + + + + + 今日の食事はいかがでしたか? + おすすめしたいメニューはありますか? + メニューについて詳しくレビューを書いてください + 画面を準備しています。 + 投稿中... + 更新中... + 写真をタップすると削除できます。 + 写真を追加(%1$d/%2$d) + レビュー設定 + エラーが発生しました。 + レビューを書く + + + + + レビュー + 自分のレビュー + まだレビューがありません + 最初のレビューを書いてみましょう! + このメニューに最初のレビューを書いてみましょう! + + + + + %1$d点 + + + + + 今日のメニュー + 価格 + 評価 + 学生食堂 + + + + + 確認したい食堂を選択してください。 + 選択 + 朝食 + 昼食 + 夕食 + 本日のメニューはありません。 + 読み込み中 + ネットワーク接続を確認してください。 + 設定が必要です + ウィジェット設定で食堂を選択してください。 + + + + + マイページ + 自分の情報 + ログアウト + アカウント削除 + アプリバージョン + 알림 및 활동 + 서비스 정보 + 기타 + プッシュ通知設定 + 毎日午前11時に通知をお送りします。 + + + 言語設定 + 使用する言語を選択してください。 + システム言語(デフォルト) + + + + + Kakao + 連携済みアカウント + + + + + ニックネーム + ニックネームを設定してください + %1$d〜%2$d文字で入力してください。 + このニックネームは使用できます! + + + + + ニックネーム編集 + 所属設定 + + + + + 情報を読み込めませんでした。 + 接続エラー + サーバーと通信できません。\n しばらくしてからもう一度お試しください。 + + + + + このレビューを通報する理由を選択してください。 + 同じレビューは24時間以内に1回のみ通報できます。 + 通報理由を入力してください + + + + + 最大150文字 + + + + + + + + 숭실대에서 + 숭실대에서 먹자 + + + + + お問い合わせ + 利用規約 + プライバシーポリシー + プライバシーポリシー + 利用規約 + 약관 및 정책 + クレジット + オープンソースライブラリ + + + + + 本当にアカウントを削除しますか? + レビューは削除されず、(不明)として表示されます。\n 詳細は利用規約とプライバシーポリシーをご確認ください。 + ニックネームを入力してください + + + + + 🤔 今日なに食べる? + 今日の学食メニューを確認しよう! + 昼食前通知 + 昼食時間前にプッシュ通知を送信します。 + サーバー通知 + EAT-SSUサーバーから送信された通知を表示します。 + + + + + 学科を入力 + 学科を入力して\n 自分に合った提携情報を確認しましょう! + 学部/学科情報なし + + + + + 場所 + 備考 + 営業時間 + お気に入り提携先 + 自分の提携先 + 축제 + すべて + 学科 + + + + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 000000000..7ea7f3f49 --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,307 @@ + + + + + + + + EAT-SSU + + + + + + + + + + + + + + + + + + 리뷰 작성하기 + 리뷰 수정하기 + 탈퇴하기 + 신고하기 + 위젯 설정 + 제휴 지도 + 강제 업데이트 + + + + + 학식 + 지도 + 나만아니면돼~ + 마이 + back + + + + + 완료하기 + 수정하기 + 삭제하기 + 저장하기 + 신고하기 + 취소 + 확인 + 업데이트 + 중복확인 + + + + + 로그아웃 + 로그아웃 하시겠습니까? + 새 버전의 앱을 설치해야 합니다. + 내 위치를 바로 확인하며 제휴 식당을 찾아볼 수 있도록 위치 권한을 허용해 주세요. + 알림 권한 필요 + 알림을 받으려면 알림 권한을 활성화해야 합니다.\n설정에서 알림 권한을 허용해주세요. + 설정으로 이동 + 제목을 입력해주세요 + 본문을 입력해주세요 + 네트워크 연결 안 됨 + Wi-Fi, 모바일 데이터를 확인해주세요 + 공지 + + + + + 리뷰가 등록되었어요. + 리뷰 작성에 실패하였습니다. + 리뷰를 수정했습니다. + 리뷰 수정이 실패했습니다. + 리뷰가 삭제되었어요. + 리뷰 삭제에 실패했습니다. + 리뷰를 불러오지 못했습니다. + + + + + 이미지가 업로드되었습니다. + 이미지 업로드에 실패하였습니다. + 이미지 압축에 실패하였습니다. + 이미지 파일을 찾을 수 없습니다. + + + + + 신고가 성공적으로 접수되었어요! + 신고가 실패하였습니다. + + + + + 닉네임 변경에 실패했어요. + 단과대를 먼저 선택해주세요. + 학과를 선택해주세요. + 정보가 업데이트되었습니다. + 닉네임이 변경되었습니다. + 학과 정보가 업데이트되었습니다. + 변경사항이 없습니다. + + + + + %1$d~%2$d글자를 입력해 주세요. + 띄어쓰기를 제외한 공백 문자는 사용할 수 없어요. + 연속된 띄어쓰기는 사용할 수 없어요. + 허용 문자(한글/영문/숫자)만 사용할 수 있어요. + 연속된 특수문자(--, __)는 사용할 수 없어요. + 숫자만으로 된 닉네임은 사용할 수 없어요. + 특수문자로 시작/끝나는 닉네임은 사용할 수 없어요. + 욕설, 비속어 등의 표현이 포함된 닉네임은 사용할 수 없어요. + 올바르지 않은 닉네임이에요. + + + + + 앱 초기화 중 오류가 발생했습니다 + 앱을 업데이트해주세요 + 제휴 정보가 없습니다. + 로그인에 실패했어요. + 로그아웃했어요. + 다시 로그인해주세요. + 세션이 만료되어 다시 로그인해주세요. + 닉네임을 설정해주세요. + EAT-SSU 수신 동의 (%1$s) + EAT-SSU 수신 거절 (%1$s) + 오픈소스 라이브러리를 불러올 수 없어요. + 회원탈퇴되었어요. + 회원탈퇴에 실패했어요. + + + + + 오늘의 식사는 어땠나요? + 추천하고 싶은 메뉴가 있나요? + 메뉴에 대한 상세한 리뷰를 작성해주세요 + 화면을 준비하는 중입니다. + 작성 중... + 수정 중... + 사진 클릭 시, 삭제됩니다. + 사진 추가(%1$d/%2$d) + 리뷰 설정 + 에러가 발생했습니다. + 리뷰 작성하기 + + + + + 리뷰 + 내 리뷰 + 아직 작성한 리뷰가 없어요 + 첫 리뷰를 남겨 주세요! + 메뉴에 가장 먼저 리뷰를 남겨주세요! + + + + + %1$d점 + + + + + 오늘의 메뉴 + 가격 + 평점 + 학생식당 + + + + + 확인하고 싶은 식당을 선택하세요. + 선택하기 + 아침 + 점심 + 저녁 + 오늘의 메뉴가 없습니다. + 로딩 중 + 네트워크 연결 상태를 확인해주세요. + 설정 필요 + 위젯 설정에서 식당을 선택해주세요. + + + + + 마이페이지 + 내 정보 + 로그아웃 + 회원탈퇴 + 앱 버전 + 알림 및 활동 + 서비스 정보 + 기타 + 푸시 알림 설정 + 매일 오전 11시에 알림을 보내드려요 + + + 언어 설정 + 사용할 언어를 선택하세요. + 시스템 언어 (기본값) + + + + + 카카오 + 연결된 계정 + + + + + 닉네임 + 닉네임을 설정해주세요 + %1$d~%2$d글자를 입력해주세요. + 사용 가능한 닉네임입니다! + + + + + 닉네임 설정 + 소속 설정 + + + + + 정보를 불러올 수 없어요. + 통신 오류 + 서버와 통신할 수 없습니다.\n잠시 후 다시 시도해주세요. + + + + + 리뷰를 신고하는 이유를 선택해주세요. + 하나의 리뷰에 대해 24시간 내 한 번만 신고 가능합니다. + 리뷰 신고 사유를 작성해 주세요 + + + + + 최대 150자 + + + + + + + + 숭실대에서 + 먹자 + + + + + 문의하기 + 서비스 이용약관 + 개인정보 처리방침 + 개인정보 처리방침 + 서비스 이용약관 + 약관 및 정책 + 만든 사람들 + 오픈소스 라이브러리 + + + + + 정말 탈퇴하시겠습니까? + 작성한 리뷰는 삭제되지 않으며, (알수없음)으로 표시됩니다.\n자세한 내용은 서비스 이용약관 및 개인정보처리방침을 확인해 주세요. + 닉네임을 입력해주세요 + + + + + 🤔 오늘 밥 뭐 먹지 + 오늘의 학식을 확인해보세요! + 점심시간 전 알림 + 점심시간 전, 푸시알림을 발송합니다. + 서버가 보낸 알림 + 잇슈 서버가 보낸 알림을 표시합니다. + + + + + 학과 입력하기 + 학과를 입력하고\n나만의 제휴를 확인해보세요! + 단과대/학과 정보를 알 수 없음 + + + + + 식당 위치 + 비고 + 영업 시간 + 찜한 제휴 + 내 제휴 + 축제 + 전체 + 학과 + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 17153a001..a22ffe1f9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,4 +15,11 @@ buildscript { dependencies { classpath(libs.oss.licenses.plugin) } -} \ No newline at end of file +} + +tasks.register("generateLocalizedStrings") { + group = "localization" + description = + "language.csv 파일을 통해 res/values-* 위치에 양식에 맞게 strings.xml 파일을 생성합니다. csv에 기존 strings.xml에 매칭되는 값이 없는 경우 한글 값이 들어갑니다." + commandLine("python3", "scripts/generate_android_strings.py") +} diff --git a/core/common/src/main/java/com/eatssu/common/enums/AppLanguage.kt b/core/common/src/main/java/com/eatssu/common/enums/AppLanguage.kt index e297cfacb..071dc8430 100644 --- a/core/common/src/main/java/com/eatssu/common/enums/AppLanguage.kt +++ b/core/common/src/main/java/com/eatssu/common/enums/AppLanguage.kt @@ -11,9 +11,9 @@ enum class AppLanguage( val nativeDisplayName: String ) { // SYSTEM("", "System Default", "시스템 언어"), // 다국어 재활성화 시 주석 해제 - KOREAN("ko", "Korean", "한국어"); - // ENGLISH("en", "English", "English"), // 다국어 재활성화 시 주석 해제 - // JAPANESE("ja", "Japanese", "日本語"), // 다국어 재활성화 시 주석 해제 + KOREAN("ko", "Korean", "한국어"), + ENGLISH("en", "English", "English"), // 다국어 재활성화 시 주석 해제 + JAPANESE("ja", "Japanese", "日本語"); // 다국어 재활성화 시 주석 해제 // CHINESE("zh", "Chinese", "中文"), // 다국어 재활성화 시 주석 해제 // VIETNAMESE("vi", "Vietnamese", "Tiếng Việt"); // 다국어 재활성화 시 주석 해제 diff --git a/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuRadioCheckBox.kt b/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuRadioCheckBox.kt new file mode 100644 index 000000000..6c32be627 --- /dev/null +++ b/core/design-system/src/main/java/com/eatssu/design_system/component/EatSsuRadioCheckBox.kt @@ -0,0 +1,94 @@ +package com.eatssu.design_system.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.eatssu.design_system.theme.CheckedColor +import com.eatssu.design_system.theme.Gray400 + +@Composable +fun EatSsuRadioCheckBox( + text: String, + isSelected: Boolean, + onSelect: () -> Unit +) { + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() } + .padding(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + style = com.eatssu.design_system.theme.EatssuTheme.typography.body2 + ) + + Spacer(Modifier.weight(1f)) + if (!isSelected) { + Box( + modifier = Modifier + .size(18.dp) + .border(width = 2.dp, shape = CircleShape, color = Gray400) + ) + } else { + Box( + modifier = Modifier + .size(18.dp) + .border(width = 5.dp, shape = CircleShape, color = CheckedColor) + ) + } + } +} + +@Composable +fun EatSsuRadioCheckBoxGroup( + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit +) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(options, key = { it }) { option -> + val isSelected = option == selectedOption + EatSsuRadioCheckBox( + text = option, + isSelected = isSelected, + onSelect = { onOptionSelected(option) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + + +@Composable +@Preview +fun LanguageSelectionPreview() { + val languages = listOf("한국어", "English", "JP") + + com.eatssu.design_system.theme.EatssuTheme { + EatSsuRadioCheckBoxGroup( + options = languages, + selectedOption = languages[0], + onOptionSelected = {} + ) + } +} \ No newline at end of file diff --git a/core/design-system/src/main/java/com/eatssu/design_system/theme/Color.kt b/core/design-system/src/main/java/com/eatssu/design_system/theme/Color.kt index 109019005..c5163faed 100644 --- a/core/design-system/src/main/java/com/eatssu/design_system/theme/Color.kt +++ b/core/design-system/src/main/java/com/eatssu/design_system/theme/Color.kt @@ -42,4 +42,6 @@ val WarningBr = Color(0xFFFFE0A3) val Danger = Color(0xFFDE3412) val DangerBg = Color(0xFFFDEFEC) -val DangerBr = Color(0xFFFCDFD9) \ No newline at end of file +val DangerBr = Color(0xFFFCDFD9) + +val CheckedColor = Color(0xFF66D4C2) \ No newline at end of file diff --git a/language.csv b/language.csv new file mode 100644 index 000000000..8c11ed1e0 --- /dev/null +++ b/language.csv @@ -0,0 +1,228 @@ +key,ko,en,ja,vi +app_name,EAT-SSU,EAT-SSU,EAT-SSU, +custom_weekdays,월 | 화 | 수 | 목 | 금 | 토 | 일,Mon | Tue | Wed | Thu | Fri | Sat | Sun,月 | 火 | 水 | 木 | 金 | 土 | 日, +title_review_write,리뷰 작성하기,Write a Review,レビューを書く, +title_review_modify,리뷰 수정하기,Edit Review,レビューを編集, +title_sign_out,탈퇴하기,Delete Account,アカウント削除, +title_report,신고하기,Report,通報, +title_widget_setting,위젯 설정,Widget Settings,ウィジェット設定, +title_partnership_map,제휴 지도,Partner Map,提携マップ, +title_force_update,강제 업데이트,Force Update,強制アップデート, +nav_cafeteria_menu,학식,Cafeteria,学食, +nav_map,지도,Map,地図, +nav_anyone_but_me,나만아니면돼~,Lucky Game,ラッキーゲーム, +nav_mypage,마이,My,マイページ, +nav_back,back,Back,戻る, +button_complete,완료하기,Done,完了, +button_modify,수정하기,Edit,編集, +button_delete,삭제하기,Delete,削除, +button_save,저장하기,Save,保存, +button_report,신고하기,Report,通報, +button_cancel,취소,Cancel,キャンセル, +button_confirm,확인,Confirm,確認, +button_update,업데이트,Update,アップデート, +button_check_duplicate,중복확인,Check Availability -> Check,重複確認, +dialog_logout_title,로그아웃,Log Out,ログアウト, +dialog_logout_message,로그아웃 하시겠습니까?,Do you want to log out?,ログアウトしますか?, +dialog_force_update_message,새 버전의 앱을 설치해야 합니다.,You need to install the latest version of the app.,アプリの最新バージョンをインストールする必要があります。, +dialog_location_permission_description,내 위치를 바로 확인하며 제휴 식당을 찾아볼 수 있도록 위치 권한을 허용해 주세요.,Please allow location access so you can quickly check your location and find partner restaurants nearby.,現在地をすばやく確認し、近くの提携店舗を探すために位置情報の利用を許可してください。, +dialog_notification_permission_title,알림 권한 필요,Notification Permission Required,通知権限が必要です, +dialog_notification_permission_description,알림을 받으려면 알림 권한을 활성화해야 합니다.\n설정에서 알림 권한을 허용해주세요.,"To receive notifications, you need to enable notification permission. + Please allow notification access in Settings.","通知を受け取るには、通知権限を有効にする必要があります。 + 設定で通知を許可してください。", +dialog_settings,설정으로 이동,Go to Settings,設定へ移動, +dialog_placeholder_title,제목을 입력해주세요,Please enter a title,タイトルを入力してください, +dialog_placeholder_body,본문을 입력해주세요,Please enter the content,内容を入力してください, +dialog_network_error_title,네트워크 연결 안 됨,No Network Connection,ネットワーク接続なし, +dialog_network_error_message,"Wi-Fi, 모바일 데이터를 확인해주세요",Please check your Wi-Fi or mobile data connection.,Wi-Fiまたはモバイルデータ接続を確認してください。, +dialog_notice_title,공지,Notice,お知らせ, +toast_review_write_success,리뷰가 등록되었어요.,Your review has been posted.,レビューを投稿しました。, +toast_review_write_failed,리뷰 작성에 실패하였습니다.,Failed to post your review.,レビューの投稿に失敗しました。, +toast_review_modify_success,리뷰를 수정했습니다.,Your review has been updated.,レビューを更新しました。, +toast_review_modify_failed,리뷰 수정이 실패했습니다.,Failed to update your review.,レビューの更新に失敗しました。, +toast_review_delete_success,리뷰가 삭제되었어요.,Your review has been deleted.,レビューを削除しました。, +toast_review_delete_failed,리뷰 삭제에 실패했습니다.,Failed to delete your review.,レビューの削除に失敗しました。, +toast_review_load_failed,리뷰를 불러오지 못했습니다.,Could not load reviews.,レビューを読み込めませんでした。, +toast_image_upload_success,이미지가 업로드되었습니다.,Image uploaded successfully.,画像をアップロードしました。, +toast_image_upload_failed,이미지 업로드에 실패하였습니다.,Failed to upload image.,画像のアップロードに失敗しました。, +toast_image_compress_failed,이미지 압축에 실패하였습니다.,Failed to compress image.,画像の圧縮に失敗しました。, +toast_image_not_found,이미지 파일을 찾을 수 없습니다.,Could not find the image file.,画像ファイルが見つかりませんでした。, +toast_report_success,신고가 성공적으로 접수되었어요!,Your report has been submitted successfully!,通報が正常に送信されました!, +toast_report_failed,신고가 실패하였습니다.,Failed to submit your report.,通報の送信に失敗しました。, +toast_nickname_change_failed,닉네임 변경에 실패했어요.,Failed to change your nickname.,ニックネームの変更に失敗しました。, +toast_college_required,단과대를 먼저 선택해주세요.,Please select your college first.,先に学部を選択してください。, +toast_department_required,학과를 선택해주세요.,Please select your department.,学科を選択してください。, +toast_info_updated,정보가 업데이트되었습니다.,Your information has been updated.,情報が更新されました。, +toast_nickname_changed,닉네임이 변경되었습니다.,Your nickname has been changed.,ニックネームが変更されました。, +toast_department_updated,학과 정보가 업데이트되었습니다.,Your department information has been updated.,学科情報が更新されました。, +toast_no_changes,변경사항이 없습니다.,No changes were made.,変更内容はありません。, +nickname_error_length,%1$d~%2$d글자를 입력해 주세요.,Please enter %1$d to %2$d characters.,%1$d〜%2$d文字で入力してください。, +nickname_error_whitespace,띄어쓰기를 제외한 공백 문자는 사용할 수 없어요.,Whitespace characters other than spaces are not allowed.,スペース以外の空白文字は使用できません。, +nickname_error_consecutive_space,연속된 띄어쓰기는 사용할 수 없어요.,Consecutive spaces are not allowed.,連続したスペースは使用できません。, +nickname_error_allowed_chars,허용 문자(한글/영문/숫자)만 사용할 수 있어요.,"Only Korean, English letters, and numbers are allowed.",韓国語、英字、数字のみ使用できます。, +nickname_error_consecutive_special,"연속된 특수문자(--, __)는 사용할 수 없어요.","Consecutive special characters (--, __) are not allowed.",連続した特殊文字(--、__)は使用できません。, +nickname_error_only_numbers,숫자만으로 된 닉네임은 사용할 수 없어요.,A nickname cannot contain only numbers.,数字だけのニックネームは使用できません。, +nickname_error_special_position,특수문자로 시작/끝나는 닉네임은 사용할 수 없어요.,A nickname cannot start or end with a special character.,ニックネームの先頭または末尾に特殊文字は使用できません。, +nickname_error_profanity,"욕설, 비속어 등의 표현이 포함된 닉네임은 사용할 수 없어요.",Nicknames containing profanity or abusive language are not allowed.,悪口や不適切な表現を含むニックネームは使用できません。, +nickname_error_invalid,올바르지 않은 닉네임이에요.,This nickname is not valid.,このニックネームは使用できません。, +toast_app_init_error,앱 초기화 중 오류가 발생했습니다,An error occurred while initializing the app.,アプリの初期化中にエラーが発生しました。, +toast_app_update_required,앱을 업데이트해주세요,Please update the app.,アプリをアップデートしてください。, +toast_partnership_info_not_found,제휴 정보가 없습니다.,No partner information found.,提携情報が見つかりません。, +toast_login_failed,로그인에 실패했어요.,Login failed.,ログインに失敗しました。, +toast_logout_success,로그아웃했어요.,You have been logged out.,ログアウトしました。, +toast_token_invalid,다시 로그인해주세요.,Please log in again.,もう一度ログインしてください。, +toast_token_expired,세션이 만료되어 다시 로그인해주세요.,Your session has expired. Please log in again.,セッションの有効期限が切れました。もう一度ログインしてください。, +toast_require_nickname,닉네임을 설정해주세요.,Please set a nickname.,ニックネームを設定してください。, +toast_notification_enable,EAT-SSU 수신 동의 (%1$s),EAT-SSU notifications enabled (%1$s),EAT-SSU通知が有効になりました(%1$s), +toast_notification_disable,EAT-SSU 수신 거절 (%1$s),EAT-SSU notifications disabled (%1$s),EAT-SSU通知が無効になりました(%1$s), +toast_oss_load_fail,오픈소스 라이브러리를 불러올 수 없어요.,Could not load open-source libraries.,オープンソースライブラリを読み込めませんでした。, +toast_sign_out_success,회원탈퇴되었어요.,Your account has been deleted.,アカウントを削除しました。, +toast_sign_out_fail,회원탈퇴에 실패했어요.,Failed to delete your account.,アカウントの削除に失敗しました。, +review_how_was_meal,오늘의 식사는 어땠나요?,How was your meal today?,今日の食事はいかがでしたか?, +review_recommend_menu,추천하고 싶은 메뉴가 있나요?,Any dishes you'd recommend?,おすすめしたいメニューはありますか?, +review_placeholder,메뉴에 대한 상세한 리뷰를 작성해주세요,Write a detailed review of the menu,メニューについて詳しくレビューを書いてください, +review_preparing,화면을 준비하는 중입니다.,Preparing the screen.,画面を準備しています。, +review_posting,작성 중...,Posting...,投稿中..., +review_modifying,수정 중...,Updating...,更新中..., +review_photo_delete_hint,"사진 클릭 시, 삭제됩니다.",Tap a photo to delete it.,写真をタップすると削除できます。, +review_photo_count,사진 추가(%1$d/%2$d),Add Photo (%1$d/%2$d),写真を追加(%1$d/%2$d), +review_settings,리뷰 설정,Review Settings,レビュー設定, +review_error_occurred,에러가 발생했습니다.,An error occurred.,エラーが発生しました。, +review_write,리뷰 작성하기,Write a Review,レビューを書く, +review,리뷰,Reviews,レビュー, +my_review,내 리뷰,My Reviews,自分のレビュー, +none_review,아직 작성한 리뷰가 없어요,No reviews yet,まだレビューがありません, +none_review_my,첫 리뷰를 남겨 주세요!,Be the first to leave a review!,最初のレビューを書いてみましょう!, +none_review_list_detail,메뉴에 가장 먼저 리뷰를 남겨주세요!,Be the first to review this menu item!,このメニューに最初のレビューを書いてみましょう!, +rate_n,%1$d점,%1$d pts,%1$d点, +today_menu,오늘의 메뉴,Today's Menu,今日のメニュー, +price,가격,Price,価格, +rate,평점,Rating → ★,評価, +student_cafeteria,학생식당,Student Cafeteria,学生食堂, +widget_select_restaurant,확인하고 싶은 식당을 선택하세요.,Select the cafeteria you want to check.,確認したい食堂を選択してください。, +widget_select,선택하기,Select,選択, +widget_morning,아침,Breakfast,朝食, +widget_lunch,점심,Lunch,昼食, +widget_dinner,저녁,Dinner,夕食, +widget_no_menu,오늘의 메뉴가 없습니다.,No menu available today.,本日のメニューはありません。, +widget_loading,로딩 중,Loading,読み込み中, +widget_network_error,네트워크 연결 상태를 확인해주세요.,Please check your network connection.,ネットワーク接続を確認してください。, +widget_setup_required,설정 필요,Setup Required,設定が必要です, +widget_select_prompt,위젯 설정에서 식당을 선택해주세요.,Please select a cafeteria in Widget Settings.,ウィジェット設定で食堂を選択してください。, +mypage,마이페이지,My Page,マイページ, +my_info,내 정보,My Info,自分の情報, +logout,로그아웃,Log Out,ログアウト, +signout,회원탈퇴,Delete Account,アカウント削除, +app_version,앱 버전,App Version,アプリバージョン, +mypage_push_notification_title,푸시 알림 설정,Push Notification Settings,プッシュ通知設定, +mypage_push_notification_description,매일 오전 11시에 알림을 보내드려요,We'll send you a notification every day at 11:00 AM.,毎日午前11時に通知をお送りします。, +language_setting,언어 설정,Language Settings,言語設定, +language_select_description,사용할 언어를 선택하세요.,Choose the language you want to use.,使用する言語を選択してください。, +language_system_default,시스템 언어 (기본값),System Language (Default),システム言語(デフォルト), +kakao,카카오,Kakao,Kakao, +connect_account,연결된 계정,Connected Account,連携済みアカウント, +nickname,닉네임,Nickname,ニックネーム, +set_nickname,닉네임을 설정해주세요,Please set a nickname,ニックネームを設定してください, +set_nickname_length,%1$d~%2$d글자를 입력해주세요.,Please enter %1$d to %2$d characters.,%1$d〜%2$d文字で入力してください。, +set_nickname_able,사용 가능한 닉네임입니다!,This nickname is available!,このニックネームは使用できます!, +userinfo_nickname_setting,닉네임 설정,Edit Nickname,ニックネーム編集, +userinfo_affiliation_setting,소속 설정,Set Affiliation -> College & Department,所属設定, +not_found,정보를 불러올 수 없어요.,Could not load the information.,情報を読み込めませんでした。, +server_error_title,통신 오류,Connection Error,接続エラー, +server_error_message,서버와 통신할 수 없습니다.\n잠시 후 다시 시도해주세요.,"Unable to communicate with the server. + Please try again later.","サーバーと通信できません。 + しばらくしてからもう一度お試しください。", +report_title,리뷰를 신고하는 이유를 선택해주세요.,Please choose why you are reporting this review.,このレビューを通報する理由を選択してください。, +report_sub,하나의 리뷰에 대해 24시간 내 한 번만 신고 가능합니다.,You can report the same review only once within 24 hours.,同じレビューは24時間以内に1回のみ通報できます。, +report_write_hint,리뷰 신고 사유를 작성해 주세요,Please write the reason for reporting this review,通報理由を入力してください, +max_150,최대 150자,Up to 150 characters,最大150文字, +app_slogan_part1,숭실대에서,Eat at,, +app_slogan_part2,먹자,Soongsil,숭실대에서 먹자, +inquire,문의하기,Contact Us,お問い合わせ, +service_rule,서비스 이용약관,Terms of Service,利用規約, +private_information,개인정보 처리방침,Privacy Policy,プライバシーポリシー, +policy,개인정보 처리방침,Privacy Policy,プライバシーポリシー, +terms,서비스 이용약관,Terms of Service,利用規約, +developer,만든 사람들,Credits,クレジット, +oss_licenses,오픈소스 라이브러리,Open-Source Libraries,オープンソースライブラリ, +signout_question,정말 탈퇴하시겠습니까?,Are you sure you want to delete your account?,本当にアカウントを削除しますか?, +signout_description,"작성한 리뷰는 삭제되지 않으며, (알수없음)으로 표시됩니다.\n자세한 내용은 서비스 이용약관 및 개인정보처리방침을 확인해 주세요.","Your reviews will not be deleted and will be displayed as (Unknown). + For details, please check the Terms of Service and Privacy Policy.","レビューは削除されず、(不明)として表示されます。 + 詳細は利用規約とプライバシーポリシーをご確認ください。", +signout_nickname,닉네임을 입력해주세요,Please enter your nickname,ニックネームを入力してください, +notification_context_title,🤔 오늘 밥 뭐 먹지,🤔 What should I eat today?,🤔 今日なに食べる?, +notification_context_text,오늘의 학식을 확인해보세요!,Check today's cafeteria menu!,今日の学食メニューを確認しよう!, +notification_channel_lunch_name,점심시간 전 알림,Before-Lunch Notifications,昼食前通知, +notification_channel_lunch_description,"점심시간 전, 푸시알림을 발송합니다.",Sends push notifications before lunchtime.,昼食時間前にプッシュ通知を送信します。, +notification_channel_server_name,서버가 보낸 알림,Server Notifications,サーバー通知, +notification_channel_server_description,잇슈 서버가 보낸 알림을 표시합니다.,Shows notifications sent by the EAT-SSU server.,EAT-SSUサーバーから送信された通知を表示します。, +input_department,학과 입력하기,Enter your department,学科を入力, +input_string_description,학과를 입력하고\n나만의 제휴를 확인해보세요!,"Enter your department + and check your personalized partnerships!","学科を入力して + 自分に合った提携情報を確認しましょう!", +map_unknown_college_department,단과대/학과 정보를 알 수 없음,College/Department information unavailable,学部/学科情報なし, +location,식당 위치,Location,場所, +etc,비고,Notes,備考, +time,영업 시간,Hours,営業時間, +favorite_partnership,찜한 제휴,Favorite Partners,お気に入り提携先, +partnership_filter_mine,내 제휴,My Partners,自分の提携先, +partnership_filter_all,전체,All,すべて, +partnership_filter_department_placeholder,학과,Department,学科, +policy_url,https://github.com/EAT-SSU/Docs/wiki/EAT%E2%80%90SSU-%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EB%B0%A9%EC%B9%A8,https://github.com/EAT-SSU/Docs/wiki/EAT%E2%80%90SSU-%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EB%B0%A9%EC%B9%A8,https://github.com/EAT-SSU/Docs/wiki/EAT%E2%80%90SSU-%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EB%B0%A9%EC%B9%A8, +terms_url,https://github.com/EAT-SSU/Docs/wiki/EAT%E2%80%90SSU-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B4%EC%9A%A9%EC%95%BD%EA%B4%80,https://github.com/EAT-SSU/Docs/wiki/EAT%E2%80%90SSU-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B4%EC%9A%A9%EC%95%BD%EA%B4%80,https://github.com/EAT-SSU/Docs/wiki/EAT%E2%80%90SSU-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%9D%B4%EC%9A%A9%EC%95%BD%EA%B4%80, +anyone_but_me_url,https://eatssu-coffee.figma.site/,https://eatssu-coffee.figma.site/,https://eatssu-coffee.figma.site/, +eatssu_instagram,eatssu.official,eatssu.official,eatssu.official, +eatssu_instagram_url,https://www.instagram.com/eatssu.official/,https://www.instagram.com/eatssu.official/,https://www.instagram.com/eatssu.official/, +eatssu_event_instagram_url,https://www.instagram.com/p/DVu1n6SEs5b/,https://www.instagram.com/p/DVu1n6SEs5b/,https://www.instagram.com/p/DVu1n6SEs5b/, +recruiting_url,https://eat-ssu.notion.site/1d2eeef75a1681ae800cf6ffa6faa37d?pvs=74,https://eat-ssu.notion.site/1d2eeef75a1681ae800cf6ffa6faa37d?pvs=74,https://eat-ssu.notion.site/1d2eeef75a1681ae800cf6ffa6faa37d?pvs=74, +((학생식당)) 식당 위치,학생회관 3층,"Student Union, 3F",, +((학생식당)) 영업 시간,"08:00~09:00(천원의아침밥) +11:20~14:00(점심) +14:00~17:00(공간 개방)","08:00 – 09:00(1,000-Won Breakfast) +11:20 – 14:00(Lunch) +14:00 – 17:00(Space Open)",, +((학생식당)) 비고,"3개 코너 운영 +뚝배기, 덮밥, 양식 +주말 휴무","3 Food Stations +Hot Pot(Ttukbaegi), Rice Bowls, Western food +Closed on weekends",, +((도담식당)) 식당 위치,신양관 2층,"Shinyang Hall, 2F",, +((도담식당)) 영업 시간,"평일 +11:20~14:00(점심) +17:00~18:30(저녁) + +주말 +11:20~13:30(점심)","Weekdays +11:20 – 14:00(Lunch) +17:00 – 18:30(Dinner) + +Weekends +11:20 –13:30(Lunch)",, +((도담식당)) 비고,"2개 코너 운영 +일반식, 웰빙코너","2 Food Stations +Standard Meal, Healthy Meal Corner",, +((기숙사 식당)) 식당 위치,레지던스홀 지하 1층,"Residence Hall, B1",, +((기숙사 식당)) 영업 시간,"평일 +11:20~13:50(점심) +17:00~18:30(저녁) + +주말 +11:20~13:30(점심) +17:00~18:20(저녁)","Weekdays +11:20 – 13:50(Lunch) +17:00 – 18:30(Dinner) + +Weekends +11:20~13:30(Lunch) +17:00 – 18:20(Dinner)",, +((기숙사 식당)) 비고,조식 미운영,No Breakfast Service,, +((FACULTY(교직원전용))) 식당 위치,전산관 지하 1층,"Jeonsan Hall, B1",, +((FACULTY(교직원전용))) 영업 시간,"11:30~14:00(점심) +14:00~17:00(공간개방)","11:20 – 14:00(Lunch) +14:00 – 17:00(Space Open)",, +((FACULTY(교직원전용))) 비고,주말 휴무,Closed on weekends,, +(스낵코너)) 식당 위치,학생회관 3층,"Student Union, 3F",, +(스낵코너)) 영업 시간,11:00~15:30(점심),11:00 – 15:30(Lunch),, +(스낵코너)) 비고,"분식류, 옛날도시락, 컵밥 등 +주말 휴무","Bunsik(Korean snack food), Old School Korean Lunch Box, Cup-bab, etc. +Closed on weekends",, \ No newline at end of file diff --git a/scripts/generate_android_strings.py b/scripts/generate_android_strings.py new file mode 100644 index 000000000..3454fd9c4 --- /dev/null +++ b/scripts/generate_android_strings.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Generate localized Android strings.xml files from a translation CSV.""" + +from __future__ import annotations + +import argparse +import csv +import re +import sys +from collections import OrderedDict +from pathlib import Path +from typing import Iterable +from xml.etree import ElementTree +from xml.sax.saxutils import escape, quoteattr + + +DEFAULT_SOURCE = Path("app/src/main/res/values/strings.xml") +DEFAULT_RES_DIR = Path("app/src/main/res") +DEFAULT_CSV_PATTERN = "language.csv" +KEY_COLUMN = "key" +BASE_LANGUAGE_COLUMN = "ko" +ARRAY_SEPARATOR = "|" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Generate app/src/main/res/values-*/strings.xml from a CSV. " + "The default values/strings.xml is never modified." + ), + ) + parser.add_argument( + "csv_path", + nargs="?", + type=Path, + help=f"Translation CSV path. Defaults to the single root {DEFAULT_CSV_PATTERN} file.", + ) + parser.add_argument( + "--source", + type=Path, + default=DEFAULT_SOURCE, + help=f"Default Korean strings.xml. Defaults to {DEFAULT_SOURCE}.", + ) + parser.add_argument( + "--res-dir", + type=Path, + default=DEFAULT_RES_DIR, + help=f"Android res directory. Defaults to {DEFAULT_RES_DIR}.", + ) + parser.add_argument( + "--languages", + nargs="+", + help=( + "CSV language columns to generate. " + f"Defaults to every column except {KEY_COLUMN} and {BASE_LANGUAGE_COLUMN}." + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be generated without writing files.", + ) + parser.add_argument( + "--include-untranslatable", + action="store_true", + help=( + "Also write resources marked translatable=\"false\" to locale files. " + "By default they are omitted so Android falls back to the default values file." + ), + ) + return parser.parse_args() + + +def find_default_csv() -> Path: + candidates = sorted(Path.cwd().glob(DEFAULT_CSV_PATTERN)) + if not candidates: + raise FileNotFoundError(f"No {DEFAULT_CSV_PATTERN} file found in {Path.cwd()}") + if len(candidates) > 1: + formatted = "\n".join(f" - {candidate}" for candidate in candidates) + raise ValueError(f"Multiple {DEFAULT_CSV_PATTERN} files found:\n{formatted}") + return candidates[0] + + +def parse_source(source_path: Path) -> ElementTree.Element: + parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder(insert_comments=True)) + return ElementTree.parse(source_path, parser=parser).getroot() + + +def read_csv(csv_path: Path) -> tuple[list[str], dict[str, dict[str, str]]]: + with csv_path.open(encoding="utf-8-sig", newline="") as csv_file: + reader = csv.DictReader(csv_file) + if reader.fieldnames is None: + raise ValueError(f"{csv_path} has no header row") + + fieldnames = [field.strip() for field in reader.fieldnames] + if KEY_COLUMN not in fieldnames: + raise ValueError(f"{csv_path} must contain a '{KEY_COLUMN}' column") + + rows: dict[str, dict[str, str]] = OrderedDict() + for line_number, row in enumerate(reader, start=2): + key = (row.get(KEY_COLUMN) or "").strip() + if not key: + continue + if key in rows: + raise ValueError(f"Duplicate key '{key}' in {csv_path}:{line_number}") + rows[key] = {column: row.get(column, "") for column in fieldnames} + + return fieldnames, rows + + +def source_resource_names(root: ElementTree.Element) -> set[str]: + return { + child.attrib["name"] + for child in root + if isinstance(child.tag, str) and "name" in child.attrib + } + + +def generated_languages(fieldnames: Iterable[str], requested: list[str] | None) -> list[str]: + if requested is not None: + return requested + return [ + field + for field in fieldnames + if field not in {KEY_COLUMN, BASE_LANGUAGE_COLUMN} and field.strip() + ] + + +def values_dir_name(language_tag: str) -> str: + parts = re.split(r"[-_]", language_tag.strip()) + if len(parts) == 1: + return f"values-{parts[0]}" + if len(parts) == 2 and len(parts[1]) == 2: + return f"values-{parts[0]}-r{parts[1].upper()}" + return "values-b+" + "+".join(parts) + + +def has_csv_value(rows: dict[str, dict[str, str]], key: str, language: str) -> bool: + value = rows.get(key, {}).get(language, "") + return bool(value and value.strip()) + + +def csv_value(rows: dict[str, dict[str, str]], key: str, language: str) -> str: + return rows[key][language] + + +def source_string_value(element: ElementTree.Element) -> str: + return element.text or "" + + +def source_array_values(element: ElementTree.Element) -> list[str]: + return [item.text or "" for item in element if item.tag == "item"] + + +def array_values_from_csv(value: str) -> list[str]: + return [item.strip() for item in value.split(ARRAY_SEPARATOR)] + + +def should_translate(element: ElementTree.Element) -> bool: + return element.attrib.get("translatable") != "false" + + +def android_text(value: str) -> str: + normalized = value.replace("\r\n", "\n").replace("\r", "\n").replace("\n", r"\n") + normalized = re.sub(r"(? str: + return "".join(f" {name}={quoteattr(value)}" for name, value in attributes.items()) + + +def render_comment(comment: ElementTree.Element) -> list[str]: + return [f" "] + + +def render_string( + element: ElementTree.Element, + rows: dict[str, dict[str, str]], + language: str, +) -> tuple[list[str], bool]: + key = element.attrib["name"] + translated = should_translate(element) and has_csv_value(rows, key, language) + value = csv_value(rows, key, language) if translated else source_string_value(element) + return [f" {android_text(value)}"], translated + + +def render_string_array( + element: ElementTree.Element, + rows: dict[str, dict[str, str]], + language: str, +) -> tuple[list[str], bool]: + key = element.attrib["name"] + translated = should_translate(element) and has_csv_value(rows, key, language) + values = ( + array_values_from_csv(csv_value(rows, key, language)) + if translated + else source_array_values(element) + ) + + lines = [f" "] + lines.extend(f" {android_text(item)}" for item in values) + lines.append(" ") + return lines, translated + + +def render_fallback_element(element: ElementTree.Element) -> list[str]: + text = ElementTree.tostring(element, encoding="unicode", short_empty_elements=False) + return [f" {line}" if line else line for line in text.splitlines()] + + +def render_file( + root: ElementTree.Element, + rows: dict[str, dict[str, str]], + language: str, + include_untranslatable: bool, +) -> tuple[str, int, int, int]: + lines = [ + "", + f" ", + " ", + ] + translated_count = 0 + fallback_count = 0 + skipped_untranslatable_count = 0 + previous_kind = "header" + + for child in root: + if child.tag is ElementTree.Comment: + if previous_kind in {"header", "resource"}: + lines.append("") + lines.extend(render_comment(child)) + previous_kind = "comment" + continue + + if not isinstance(child.tag, str): + continue + + if ( + child.tag in {"string", "string-array"} + and "name" in child.attrib + and not should_translate(child) + and not include_untranslatable + ): + skipped_untranslatable_count += 1 + continue + + if child.tag == "string" and "name" in child.attrib: + rendered, translated = render_string(child, rows, language) + elif child.tag == "string-array" and "name" in child.attrib: + rendered, translated = render_string_array(child, rows, language) + else: + rendered = render_fallback_element(child) + translated = False + + lines.extend(rendered) + previous_kind = "resource" + if "name" in child.attrib: + if translated: + translated_count += 1 + else: + fallback_count += 1 + + lines.append("") + return ( + "\n".join(lines) + "\n", + translated_count, + fallback_count, + skipped_untranslatable_count, + ) + + +def write_generated_file( + res_dir: Path, + language: str, + content: str, + dry_run: bool, +) -> Path: + output_dir = res_dir / values_dir_name(language) + output_path = output_dir / "strings.xml" + if dry_run: + return output_path + + output_dir.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + return output_path + + +def main() -> int: + args = parse_args() + csv_path = args.csv_path or find_default_csv() + root = parse_source(args.source) + fieldnames, rows = read_csv(csv_path) + languages = generated_languages(fieldnames, args.languages) + source_names = source_resource_names(root) + extra_csv_keys = sorted(set(rows) - source_names) + + if not languages: + raise ValueError("No language columns to generate") + + print(f"Source: {args.source}") + print(f"CSV: {csv_path}") + if extra_csv_keys: + print( + f"Skipping {len(extra_csv_keys)} CSV key(s) that are not in the source strings.xml: " + + ", ".join(extra_csv_keys[:10]) + + (" ..." if len(extra_csv_keys) > 10 else "") + ) + + for language in languages: + if language not in fieldnames: + raise ValueError(f"CSV column '{language}' does not exist") + + content, translated_count, fallback_count, skipped_untranslatable_count = render_file( + root, + rows, + language, + args.include_untranslatable, + ) + output_path = write_generated_file(args.res_dir, language, content, args.dry_run) + action = "Would generate" if args.dry_run else "Generated" + print( + f"{action} {output_path} " + f"({translated_count} translated, {fallback_count} fallback, " + f"{skipped_untranslatable_count} default-only)" + ) + + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) From d260536cbb0979b6c2c52f913a6932e4f2268a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Sun, 17 May 2026 16:51:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt b/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt index b9e196be3..14cdd5db0 100644 --- a/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt +++ b/core/common/src/test/java/com/eatssu/common/enums/EnumsBehaviorSpec.kt @@ -12,12 +12,14 @@ class EnumsBehaviorSpec : BehaviorSpec({ `when`("코드가 매칭되면") { then("해당 언어를 반환한다") { AppLanguage.fromCode("ko") shouldBe AppLanguage.KOREAN + AppLanguage.fromCode("en") shouldBe AppLanguage.ENGLISH + AppLanguage.fromCode("ja") shouldBe AppLanguage.JAPANESE } } `when`("코드가 매칭되지 않으면") { then("KOREAN을 기본값으로 반환한다") { - AppLanguage.fromCode("en") shouldBe AppLanguage.KOREAN + AppLanguage.fromCode("unknown") shouldBe AppLanguage.KOREAN } } From bd9d50f75bb22864a6fb35fc13c0d0c293bed2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Fri, 22 May 2026 14:42:14 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[feat]=20=EC=95=B1=EA=B3=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B0=84=20=EC=96=B8=EC=96=B4=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=9E=91=EC=97=85=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20-=20=EC=95=B1=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote/dto/request/LanguageRequest.kt | 8 ++++ .../remote/dto/response/LanguageResponse.kt | 9 +++++ .../remote/repository/UserRepositoryImpl.kt | 9 +++++ .../data/remote/service/UserService.kt | 11 ++++++ .../domain/repository/UserRepository.kt | 6 +++ .../android/presentation/MainViewModel.kt | 20 +++++++++- .../presentation/mypage/MyPageFragment.kt | 10 ++++- .../language/LanguageSelectorActivity.kt | 38 +++++++++++++++++++ .../language/LanguageSelectorViewModel.kt | 15 +++++++- .../presentation/MainViewModelBehaviorSpec.kt | 13 +++++++ .../LanguageSelectorViewModelBehaviorSpec.kt | 22 ++++++++--- 11 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/eatssu/android/data/remote/dto/request/LanguageRequest.kt create mode 100644 app/src/main/java/com/eatssu/android/data/remote/dto/response/LanguageResponse.kt diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/request/LanguageRequest.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/request/LanguageRequest.kt new file mode 100644 index 000000000..afe0cec05 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/request/LanguageRequest.kt @@ -0,0 +1,8 @@ +package com.eatssu.android.data.remote.dto.request + +import kotlinx.serialization.Serializable + +@Serializable +data class LanguageRequest( + val language: String +) \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/LanguageResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/LanguageResponse.kt new file mode 100644 index 000000000..d508189f7 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/LanguageResponse.kt @@ -0,0 +1,9 @@ +package com.eatssu.android.data.remote.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LanguageResponse( + @SerialName("language") val language: String? = null, +) diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt index 2f5bb5686..112638c8f 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.eatssu.android.data.model.orElse import com.eatssu.android.data.model.orEmptyList import com.eatssu.android.data.model.orNull import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest +import com.eatssu.android.data.remote.dto.request.LanguageRequest import com.eatssu.android.data.remote.dto.request.UserDepartmentRequest import com.eatssu.android.data.remote.dto.response.toDomain import com.eatssu.android.data.remote.service.UserService @@ -67,4 +68,12 @@ class UserRepositoryImpl @Inject constructor( return userService.setUserDepartment(UserDepartmentRequest(departmentId)).isSuccess() } + override suspend fun getUserLanguage(): String { + return userService.getUserLanguage().map { it.language }.orNull() ?: "" + } + + override suspend fun patchUserLanguage(language: String): Boolean { + return userService.patchUserLanguage(LanguageRequest(language)).isSuccess() + } + } diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt index 509c697bc..509ccc069 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt @@ -2,9 +2,11 @@ package com.eatssu.android.data.remote.service import com.eatssu.android.data.model.ApiResult import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest +import com.eatssu.android.data.remote.dto.request.LanguageRequest import com.eatssu.android.data.remote.dto.request.UserDepartmentRequest import com.eatssu.android.data.remote.dto.response.CollegeResponse import com.eatssu.android.data.remote.dto.response.DepartmentResponse +import com.eatssu.android.data.remote.dto.response.LanguageResponse import com.eatssu.android.data.remote.dto.response.MyPageResponse import com.eatssu.android.data.remote.dto.response.PartnershipResponse import com.eatssu.android.data.remote.dto.response.UserCollegeDepartmentResponse @@ -52,4 +54,13 @@ interface UserService { @GET("users/department/partnerships") // 유저 학과의 제휴 조회 suspend fun getUserDepartmentPartnerships(): ApiResult> + @GET("users/language") // 언어 설정 조회 + suspend fun getUserLanguage(): ApiResult + + @PATCH("users/language") // 언어 설정 변경 + suspend fun patchUserLanguage( + @Body language: LanguageRequest, + ): ApiResult + + } diff --git a/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt index 881b7c150..b41b0714c 100644 --- a/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt @@ -34,4 +34,10 @@ interface UserRepository { suspend fun setUserDepartment( departmentId: Int, ): Boolean + + suspend fun getUserLanguage(): String + + suspend fun patchUserLanguage( + language: String, + ): Boolean } diff --git a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt index eace8c24b..d433c8fa1 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.R import com.eatssu.android.analytics.AnalyticsIdentityManager +import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.domain.usecase.auth.LogoutUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import java.time.LocalDate @@ -36,6 +38,7 @@ class MainViewModel @Inject constructor( private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, private val getUserEmailUseCase: GetUserEmailUseCase, private val analyticsIdentityManager: AnalyticsIdentityManager, + private val settingDataStore: SettingDataStore, ) : ViewModel() { private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) @@ -47,7 +50,8 @@ class MainViewModel @Inject constructor( init { viewModelScope.launch { loadStoredUserDepartment() - getUserDepartment() + syncLanguageState() + loadUserDepartmentFromServer() fetchAndCheckNickname() } } @@ -63,6 +67,12 @@ class MainViewModel @Inject constructor( } } + fun refreshUserDepartmentFromServer() { + viewModelScope.launch { + loadUserDepartmentFromServer() + } + } + private suspend fun fetchAndCheckNickname() { val nickname = getUserNickNameUseCase() @@ -110,7 +120,7 @@ class MainViewModel @Inject constructor( ) } - private suspend fun getUserDepartment() { + private suspend fun loadUserDepartmentFromServer() { val (college, department) = userRepository.getUserCollegeDepartment() ?: run { _uiEvent.emit( UiEvent.ShowToast( @@ -145,6 +155,12 @@ class MainViewModel @Inject constructor( department = userInfo.userDepartment, ) } + + private suspend fun syncLanguageState() { + // 어떤 이유로 앱과 서버의 언어가 다를 수 있기 때문에, 앱 언어 설정을 서버에 전송 + val language = settingDataStore.appLanguage.first() + userRepository.patchUserLanguage(language.code.uppercase()) + } } diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt index ebd8f0fd1..ad6d2564e 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt @@ -1,5 +1,6 @@ package com.eatssu.android.presentation.mypage +import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -9,6 +10,7 @@ import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.getValue @@ -65,6 +67,12 @@ class MyPageFragment : Fragment() { private val myPageViewModel: MyPageViewModel by viewModels() private val mainViewModel: MainViewModel by activityViewModels() private var lastNotificationPermissionState: Boolean? = null + private val languageSelectorLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + mainViewModel.refreshUserDepartmentFromServer() + } + } override fun onCreateView( inflater: LayoutInflater, @@ -123,7 +131,7 @@ class MyPageFragment : Fragment() { ) }, onLanguageSettingClick = { - startActivity( + languageSelectorLauncher.launch( Intent( requireContext(), LanguageSelectorActivity::class.java diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt index 3b94f8101..e0b7c4a78 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt @@ -3,28 +3,66 @@ package com.eatssu.android.presentation.mypage.language import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.eatssu.android.analytics.ProvideAnalyticsTracker import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint class LanguageSelectorActivity : ComponentActivity() { + companion object { + private const val KEY_LANGUAGE_CHANGED = "KEY_LANGUAGE_CHANGED" + } + @Inject lateinit var analyticsTracker: AnalyticsTracker + private val viewModel: LanguageSelectorViewModel by viewModels() + private var hasLanguageChanged = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + hasLanguageChanged = savedInstanceState?.getBoolean(KEY_LANGUAGE_CHANGED) ?: false + updateResultIfNeeded() + collectLanguageChanged() setContent { ProvideAnalyticsTracker(analyticsTracker) { EatssuTheme { LanguageSelectorScreen( + viewModel = viewModel, onBack = { finish() } ) } } } } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_LANGUAGE_CHANGED, hasLanguageChanged) + super.onSaveInstanceState(outState) + } + + private fun collectLanguageChanged() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.languageChanged.collect { + hasLanguageChanged = true + updateResultIfNeeded() + } + } + } + } + + private fun updateResultIfNeeded() { + if (hasLanguageChanged) { + setResult(RESULT_OK) + } + } } diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModel.kt index a6b76649c..661e04556 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModel.kt @@ -5,22 +5,30 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.data.local.SettingDataStore +import com.eatssu.android.domain.repository.UserRepository import com.eatssu.common.enums.AppLanguage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LanguageSelectorViewModel @Inject constructor( - private val settingDataStore: SettingDataStore + private val settingDataStore: SettingDataStore, + private val userRepository: UserRepository, ) : ViewModel() { private val _selectedLanguage = MutableStateFlow(AppLanguage.KOREAN) val selectedLanguage: StateFlow = _selectedLanguage.asStateFlow() + private val _languageChanged = MutableSharedFlow() + val languageChanged: SharedFlow = _languageChanged.asSharedFlow() + init { viewModelScope.launch { settingDataStore.appLanguage.collect { language -> @@ -31,8 +39,13 @@ class LanguageSelectorViewModel @Inject constructor( fun selectLanguage(language: AppLanguage) { viewModelScope.launch { + val previousLanguage = selectedLanguage.value settingDataStore.setAppLanguage(language) + val isPatched = userRepository.patchUserLanguage(language.code.uppercase()) _selectedLanguage.value = language + if (isPatched && previousLanguage != language) { + _languageChanged.emit(Unit) + } applyLanguage(language) } } diff --git a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt index 9744c84a2..ec74b70b6 100644 --- a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt @@ -3,6 +3,7 @@ package com.eatssu.android.presentation import app.cash.turbine.test import com.eatssu.android.R import com.eatssu.android.analytics.AnalyticsIdentityManager +import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import com.eatssu.android.domain.repository.UserRepository @@ -15,14 +16,17 @@ import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.expectToast import com.eatssu.android.test.sampleUserInfo import com.eatssu.common.UiState +import com.eatssu.common.enums.AppLanguage import com.eatssu.common.enums.ToastType import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.time.Duration.Companion.seconds @@ -38,6 +42,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ val getUserCollegeDepartmentUseCase = mockk() val getUserEmailUseCase = mockk() val analyticsIdentityManager = mockk(relaxed = true) + val settingDataStore = mockk() + val appLanguageFlow = MutableStateFlow(AppLanguage.KOREAN) val college = College(collegeId = 1, collegeName = "IT") val department = Department(departmentId = 11, departmentName = "컴퓨터학부") @@ -52,7 +58,9 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { getUserEmailUseCase() } returns "test@soongsil.ac.kr" coEvery { getUserCollegeDepartmentUseCase() } returns userInfo coEvery { userRepository.getUserCollegeDepartment() } returns (college to department) + coEvery { userRepository.patchUserLanguage(any()) } returns true coEvery { setUserCollegeDepartmentUseCase(college, department) } returns Unit + every { settingDataStore.appLanguage } returns appLanguageFlow `when`("학과 정보를 새로고침하면") { val viewModel = MainViewModel( @@ -63,6 +71,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + settingDataStore = settingDataStore, ) then("부서명이 반영된 DepartmentState로 전이된다") { @@ -96,6 +105,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + settingDataStore = settingDataStore, ) advanceUntilIdle() @@ -137,6 +147,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + settingDataStore = settingDataStore, ) viewModel.uiEvent.test { @@ -166,6 +177,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + settingDataStore = settingDataStore, ) viewModel.uiEvent.test { @@ -186,6 +198,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + settingDataStore = settingDataStore, ) then("로그아웃 유즈케이스 호출 후 성공 토스트와 LoggedOut 상태를 반영한다") { diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt index f4e3478dd..f6c9c5147 100644 --- a/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorViewModelBehaviorSpec.kt @@ -3,6 +3,7 @@ package com.eatssu.android.presentation.mypage.language import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import com.eatssu.android.data.local.SettingDataStore +import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.common.enums.AppLanguage import io.kotest.matchers.shouldBe @@ -24,15 +25,17 @@ class LanguageSelectorViewModelBehaviorSpec : AppBehaviorSpec({ given("언어 선택") { val settingDataStore = mockk() + val userRepository = mockk() val languageFlow = MutableStateFlow(AppLanguage.KOREAN) every { settingDataStore.appLanguage } returns languageFlow coEvery { settingDataStore.setAppLanguage(any()) } returns Unit + coEvery { userRepository.patchUserLanguage(any()) } returns true mockkStatic(AppCompatDelegate::class) every { AppCompatDelegate.setApplicationLocales(any()) } just runs `when`("초기화되면") { - val viewModel = LanguageSelectorViewModel(settingDataStore) + val viewModel = LanguageSelectorViewModel(settingDataStore, userRepository) then("DataStore 언어를 selectedLanguage에 반영한다") { runTest { @@ -43,16 +46,23 @@ class LanguageSelectorViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("언어를 선택하면") { - val viewModel = LanguageSelectorViewModel(settingDataStore) + val viewModel = LanguageSelectorViewModel(settingDataStore, userRepository) then("DataStore 저장과 AppCompat locale 적용을 수행한다") { runTest { - viewModel.selectLanguage(AppLanguage.KOREAN) + viewModel.selectLanguage(AppLanguage.ENGLISH) advanceUntilIdle() - coVerify { settingDataStore.setAppLanguage(AppLanguage.KOREAN) } - verify { AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(AppLanguage.KOREAN.code)) } - viewModel.selectedLanguage.value shouldBe AppLanguage.KOREAN + coVerify { settingDataStore.setAppLanguage(AppLanguage.ENGLISH) } + coVerify { userRepository.patchUserLanguage(AppLanguage.ENGLISH.code.uppercase()) } + verify { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags( + AppLanguage.ENGLISH.code + ) + ) + } + viewModel.selectedLanguage.value shouldBe AppLanguage.ENGLISH } } }