From 8e4fe7a534ee899a726ee242ebbd0bf46bef2369 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 31 Mar 2026 17:37:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20I18n=20?= =?UTF-8?q?=ED=82=A4=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Resources/Localizable.xcstrings | 301 +++++++++--------- SodaLive/Sources/I18n/I18n.swift | 292 +++++++++++++++++ .../List/PushNotificationListItemView.swift | 2 +- .../List/PushNotificationListView.swift | 4 +- .../Report/CheersReportDialogView.swift | 16 +- .../Report/ProfileReportDialogView.swift | 8 +- .../Report/ProfileReportMenuView.swift | 6 +- .../Sources/Report/UserReportDialogView.swift | 17 +- .../SearchChannel/SearchChannelView.swift | 4 +- .../User/FindPassword/FindPasswordView.swift | 12 +- .../FindPassword/FindPasswordViewModel.swift | 8 +- SodaLive/Sources/User/Login/LoginView.swift | 14 +- .../Sources/User/Login/LoginViewModel.swift | 16 +- SodaLive/Sources/User/SignUp/SignUpView.swift | 18 +- .../Sources/User/SignUp/SignUpViewModel.swift | 12 +- SodaLive/Sources/User/UserTextField.swift | 6 +- SodaLive/Sources/User/UserViewModel.swift | 22 +- docs/20260331_하드코딩텍스트_I18n통일계획.md | 49 +-- 18 files changed, 551 insertions(+), 256 deletions(-) diff --git a/SodaLive/Resources/Localizable.xcstrings b/SodaLive/Resources/Localizable.xcstrings index 90ea4b2..e6779f9 100644 --- a/SodaLive/Resources/Localizable.xcstrings +++ b/SodaLive/Resources/Localizable.xcstrings @@ -16,9 +16,6 @@ } } } - }, - " · %@" : { - }, " (" : { "localizations" : { @@ -828,6 +825,16 @@ } } }, + "%@%@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@" + } + } + } + }, "%@님을 차단하시겠습니까?" : { "localizations" : { "en" : { @@ -4136,22 +4143,6 @@ } } }, - "목" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "木" - } - } - } - }, "모든 기기에서 로그아웃" : { "localizations" : { "en" : { @@ -4203,6 +4194,22 @@ } } }, + "목" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + } + } + }, "무료" : { "localizations" : { "en" : { @@ -6933,134 +6940,6 @@ } } }, - "이용약관" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terms of service" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "利用規約" - } - } - } - }, - "이전화" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Previous episode" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "前の話" - } - } - } - }, - "인기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Popular" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "人気" - } - } - } - }, - "인기 캐릭터 채팅" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Top character" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "人気キャラチャット" - } - } - } - }, - "인기 콘텐츠" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Popular content" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "人気コンテンツ" - } - } - } - }, - "인기순" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "By popularity" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "人気順" - } - } - } - }, - "인증완료" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verification completed" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "認証完了" - } - } - } - }, - "일" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sun" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日" - } - } - } - }, "이메일을 입력하세요" : { "localizations" : { "en" : { @@ -7141,6 +7020,54 @@ } } }, + "이용약관" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terms of service" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用規約" + } + } + } + }, + "이전화" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous episode" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前の話" + } + } + } + }, + "인기" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Popular" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人気" + } + } + } + }, "인기 시리즈" : { "extractionState" : "stale", "localizations" : { @@ -7174,6 +7101,38 @@ } } }, + "인기 캐릭터 채팅" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Top character" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人気キャラチャット" + } + } + } + }, + "인기 콘텐츠" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Popular content" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人気コンテンツ" + } + } + } + }, "인기 크리에이터" : { "localizations" : { "en" : { @@ -7190,6 +7149,54 @@ } } }, + "인기순" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By popularity" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人気順" + } + } + } + }, + "인증완료" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verification completed" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認証完了" + } + } + } + }, + "일" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sun" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + } + } + }, "일간 랭킹" : { "extractionState" : "stale", "localizations" : { diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 6eb925a..f1eb64b 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -355,6 +355,23 @@ enum I18n { pick(ko: "더보기 >", en: "More >", ja: "もっと見る >") } } + + enum SearchChannel { + static var title: String { + pick(ko: "채널 탐색", en: "Search channels", ja: "チャンネル探索") + } + } + + enum NotificationList { + static var emptyMessage: String { + pick(ko: "알림이 없습니다.", en: "No notifications.", ja: "通知がありません。") + } + + static var timestampSeparator: String { + pick(ko: " · ", en: " · ", ja: " · ") + } + } + enum ContentDetail { static var creatorOtherContents: String { pick( @@ -835,6 +852,10 @@ enum I18n { } enum Report { + static var cheersReportTitle: String { + pick(ko: "응원글 신고", en: "Report cheer post", ja: "応援投稿を通報") + } + static var postReportTitle: String { pick(ko: "게시물 신고", en: "Report post", ja: "投稿通報") } @@ -843,6 +864,135 @@ enum I18n { pick(ko: "신고", en: "Report", ja: "報告する") } + static var cheersReasons: [String] { + [ + cheersReasonSpam, + cheersReasonChildAbuse, + cheersReasonHateOrViolence, + cheersReasonTerrorism, + cheersReasonHarassment, + cheersReasonSelfHarm, + cheersReasonMisinformation + ] + } + + static var userReasons: [String] { + [ + userReasonHarassment, + userReasonPrivacy, + userReasonImpersonation, + userReasonThreat, + userReasonChildAbuse, + userReasonHate, + userReasonSpamFraud, + userReasonNone + ] + } + + static var cheersReasonSpam: String { + pick( + ko: "원치 않는 상업성 콘텐츠 또는 스팸", + en: "Unwanted commercial content or spam", + ja: "望まない商業コンテンツまたはスパム" + ) + } + + static var cheersReasonChildAbuse: String { + pick(ko: "아동 학대", en: "Child abuse", ja: "児童虐待") + } + + static var cheersReasonHateOrViolence: String { + pick( + ko: "증오심 표현 또는 노골적인 폭력", + en: "Hate speech or graphic violence", + ja: "憎悪表現または過度な暴力表現" + ) + } + + static var cheersReasonTerrorism: String { + pick(ko: "테러 조장", en: "Promotion of terrorism", ja: "テロ助長") + } + + static var cheersReasonHarassment: String { + pick(ko: "희롱 또는 괴롭힘", en: "Harassment or bullying", ja: "嫌がらせまたはいじめ") + } + + static var cheersReasonSelfHarm: String { + pick(ko: "자살 또는 자해", en: "Suicide or self-harm", ja: "自殺または自傷行為") + } + + static var cheersReasonMisinformation: String { + pick(ko: "잘못된 정보", en: "Misinformation", ja: "誤情報") + } + + static var userReasonHarassment: String { + pick(ko: "괴롭힘 및 사이버 폭력", en: "Harassment and cyberbullying", ja: "嫌がらせとサイバー暴力") + } + + static var userReasonPrivacy: String { + pick(ko: "개인정보 침해", en: "Privacy violation", ja: "プライバシー侵害") + } + + static var userReasonImpersonation: String { + pick(ko: "명의 도용", en: "Impersonation", ja: "なりすまし") + } + + static var userReasonThreat: String { + pick(ko: "폭력적 위협", en: "Violent threats", ja: "暴力的な脅迫") + } + + static var userReasonChildAbuse: String { + pick(ko: "아동 학대", en: "Child abuse", ja: "児童虐待") + } + + static var userReasonHate: String { + pick(ko: "보호 대상 집단에 대한 증오심 표현", en: "Hate speech toward protected groups", ja: "保護対象グループへの憎悪表現") + } + + static var userReasonSpamFraud: String { + pick(ko: "스팸 및 사기", en: "Spam and fraud", ja: "スパムと詐欺") + } + + static var userReasonNone: String { + pick(ko: "나에게 해당하는 문제 없음", en: "None of these apply", ja: "該当する問題なし") + } + + static var profilePhotoReportTitle: String { + pick(ko: "프로필 사진 신고", en: "Report profile photo", ja: "プロフィール写真を通報") + } + + static var profilePhotoReportDescription: String { + pick( + ko: "신고제도를 남용할 경우, 계정에 제약이 있을 수 있습니다.\n프로필 사진을 신고하시겠습니까?", + en: "Abusing the reporting system may restrict your account.\nDo you want to report this profile photo?", + ja: "通報制度を乱用した場合、アカウントに制約がかかる可能性があります。\nプロフィール写真を通報しますか?" + ) + } + + static var userReportTitle: String { + pick(ko: "사용자 신고", en: "Report user", ja: "ユーザーを通報") + } + + static var userReportAction: String { + pick(ko: "사용자 신고하기", en: "Report user", ja: "ユーザーを通報") + } + + static var profileReportAction: String { + pick(ko: "프로필 신고하기", en: "Report profile", ja: "プロフィールを通報") + } + + static var blockUserAction: String { + pick(ko: "사용자 차단하기", en: "Block user", ja: "ユーザーをブロック") + } + + static var unblockUserAction: String { + pick(ko: "사용자 차단해제", en: "Unblock user", ja: "ユーザーのブロックを解除") + } + + static var profileReportReason: String { + pick(ko: "프로필 신고", en: "Report profile", ja: "プロフィールを通報") + } + static var reasons: [String] { [ reasonSpam, @@ -2080,7 +2230,149 @@ If you block this user, the following features will be restricted. } } + enum User { + static var emailTitle: String { + pick(ko: "이메일", en: "Email", ja: "メール") + } + + static var emailPlaceholder: String { + pick(ko: "이메일", en: "Email", ja: "メール") + } + + static var passwordTitle: String { + pick(ko: "비밀번호", en: "Password", ja: "パスワード") + } + + static var passwordPlaceholder: String { + pick(ko: "비밀번호", en: "Password", ja: "パスワード") + } + + static var showPassword: String { + pick(ko: "비밀번호 표시", en: "Show password", ja: "パスワードを表示") + } + + static var emailRequired: String { + pick(ko: "이메일을 입력해 주세요.", en: "Please enter your email.", ja: "メールアドレスを入力してください。") + } + + static var emailInvalid: String { + pick(ko: "올바른 이메일을 입력하세요", en: "Enter a valid email address.", ja: "正しいメールアドレスを入力してください。") + } + + static var passwordRequired: String { + pick(ko: "비밀번호를 입력해 주세요.", en: "Please enter your password.", ja: "パスワードを入力してください。") + } + + static var blockUserAction: String { + pick(ko: "사용자 차단하기", en: "Block user", ja: "ユーザーをブロック") + } + + static var unblockUserAction: String { + pick(ko: "사용자 차단해제", en: "Unblock user", ja: "ユーザーのブロックを解除") + } + + static var reportUserAction: String { + pick(ko: "사용자 신고하기", en: "Report user", ja: "ユーザーを通報") + } + + static var reportProfileAction: String { + pick(ko: "프로필 신고하기", en: "Report profile", ja: "プロフィールを通報") + } + } + + enum SignUp { + static var title: String { + pick(ko: "회원가입", en: "Sign up", ja: "会員登録") + } + + static var terms: String { + pick(ko: "이용약관", en: "Terms of service", ja: "利用規約") + } + + static var privacyPolicy: String { + pick(ko: "개인정보수집 및 이용동의", en: "Consent to collect and use personal information", ja: "個人情報の収集および利用への同意") + } + + static var required: String { + pick(ko: "(필수)", en: "(Required)", ja: "(必須)") + } + + static var submit: String { + pick(ko: "회원가입", en: "Sign up", ja: "会員登録") + } + + static var agreementRequired: String { + pick(ko: "약관에 동의하셔야 회원가입이 가능합니다.", en: "You must agree to the terms to sign up.", ja: "利用規約に同意する必要があります。") + } + } + + enum FindPassword { + static var title: String { + pick(ko: "비밀번호 재설정", en: "Reset password", ja: "パスワード再設定") + } + + static var description1: String { + pick(ko: "회원가입한 이메일 주소로\n임시 비밀번호를 보내드립니다.", en: "We will send a temporary password to the email address you used to sign up.", ja: "登録したメールアドレスに仮パスワードを送信します。") + } + + static var description2: String { + pick(ko: "임시 비밀번호로 로그인 후\n마이페이지 > 프로필 설정에서\n비밀번호를 변경하고 이용하세요.", en: "Log in with the temporary password, then change it in My Page > Profile Settings.", ja: "仮パスワードでログイン後、マイページ > プロフィール設定でパスワードを変更してください。") + } + + static var emailPlaceholder: String { + pick(ko: "이메일을 입력하세요", en: "Enter your email", ja: "メールアドレスを入力してください") + } + + static var submit: String { + pick(ko: "임시 비밀번호 받기", en: "Get temporary password", ja: "仮パスワードを受け取る") + } + + static var contactSupport: String { + pick(ko: "고객센터로 문의하기", en: "Contact support", ja: "カスタマーセンターに問い合わせる") + } + + static var emailRequired: String { + pick(ko: "이메일을 입력하세요.", en: "Please enter your email.", ja: "メールアドレスを入力してください。") + } + + static var successMessage: String { + pick(ko: "임시 비밀번호가 입력하신 이메일로 발송되었습니다.\n이메일을 확인해 주세요.", en: "A temporary password has been sent to your email.\nPlease check your inbox.", ja: "仮パスワードを入力したメールアドレスに送信しました。\nメールをご確認ください。") + } + } + enum Login { + static var title: String { + pick(ko: "로그인", en: "Log in", ja: "ログイン") + } + + static var login: String { + pick(ko: "로그인", en: "Log in", ja: "ログイン") + } + + static var forgotPassword: String { + pick(ko: "비밀번호를 잊으셨나요?", en: "Forgot your password?", ja: "パスワードを忘れましたか?") + } + + static var signUpPrompt: String { + pick(ko: "보이스온 회원이 아닌가요? 지금 가입하세요.", en: "Not a VoiceOn member? Sign up now.", ja: "VoiceOnの会員ではありませんか?今すぐ登録してください。") + } + + static var appleAuthorizationFailed: String { + pick(ko: "애플 로그인 정보를 가져오지 못했습니다.", en: "Failed to retrieve Apple sign-in information.", ja: "Appleログイン情報を取得できませんでした。") + } + + static var appleTokenMissing: String { + pick(ko: "애플 인증 토큰을 가져오지 못했습니다.", en: "Failed to retrieve Apple identity token.", ja: "Apple認証トークンを取得できませんでした。") + } + + static var appleRetry: String { + pick(ko: "다시 시도해 주세요.", en: "Please try again.", ja: "もう一度お試しください。") + } + + static var appleSignInFailed: String { + pick(ko: "애플 로그인에 실패했습니다.\n다시 시도해 주세요.", en: "Apple sign-in failed.\nPlease try again.", ja: "Appleログインに失敗しました。\nもう一度お試しください。") + } + enum Google { static var openFailed: String { pick( diff --git a/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift b/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift index ba782fc..97c8d08 100644 --- a/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift +++ b/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift @@ -25,7 +25,7 @@ struct PushNotificationListItemView: View { .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "bbbbbb")) - Text(" · \(item.relativeSentAtText())") + Text("\(I18n.NotificationList.timestampSeparator)\(item.relativeSentAtText())") .appFont(size: 10, weight: .medium) .foregroundColor(Color(hex: "909090")) } diff --git a/SodaLive/Sources/Notification/List/PushNotificationListView.swift b/SodaLive/Sources/Notification/List/PushNotificationListView.swift index c57f778..5ef7b83 100644 --- a/SodaLive/Sources/Notification/List/PushNotificationListView.swift +++ b/SodaLive/Sources/Notification/List/PushNotificationListView.swift @@ -39,7 +39,7 @@ struct PushNotificationListView: View { .resizable() .frame(width: 60, height: 60) - Text("알림이 없습니다.") + Text(I18n.NotificationList.emptyMessage) .appFont(size: 10.7, weight: .medium) .foregroundColor(Color(hex: "bbbbbb")) } @@ -61,7 +61,7 @@ struct PushNotificationListView: View { private var titleBar: some View { ZStack { - DetailNavigationBar(title: "알림") + DetailNavigationBar(title: I18n.Common.alertTitle) HStack(spacing: 0) { Spacer() diff --git a/SodaLive/Sources/Report/CheersReportDialogView.swift b/SodaLive/Sources/Report/CheersReportDialogView.swift index dff6591..4bceb69 100644 --- a/SodaLive/Sources/Report/CheersReportDialogView.swift +++ b/SodaLive/Sources/Report/CheersReportDialogView.swift @@ -13,15 +13,7 @@ struct CheersReportDialogView: View { let confirmAction: (String) -> Void @State private var selectedIndex: Int? = nil - let reasons = [ - "원치 않는 상업성 콘텐츠 또는 스팸", - "아동 학대", - "증오심 표현 또는 노골적인 폭력", - "테러 조장", - "희롱 또는 괴롭힘", - "자살 또는 자해", - "잘못된 정보" - ] + let reasons = I18n.Report.cheersReasons var body: some View { ZStack { @@ -31,7 +23,7 @@ struct CheersReportDialogView: View { .onTapGesture { isShowing = false } VStack(spacing: 13.3) { - Text("응원글 신고") + Text(I18n.Report.cheersReportTitle) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: "eeeeee")) @@ -59,14 +51,14 @@ struct CheersReportDialogView: View { HStack(spacing: 26.7) { Spacer() - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { isShowing = false } - Text("신고") + Text(I18n.Report.reportAction) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { diff --git a/SodaLive/Sources/Report/ProfileReportDialogView.swift b/SodaLive/Sources/Report/ProfileReportDialogView.swift index f737aca..5396cb8 100644 --- a/SodaLive/Sources/Report/ProfileReportDialogView.swift +++ b/SodaLive/Sources/Report/ProfileReportDialogView.swift @@ -20,25 +20,25 @@ struct ProfileReportDialogView: View { .onTapGesture { isShowing = false } VStack(spacing: 13.3) { - Text("프로필 사진 신고") + Text(I18n.Report.profilePhotoReportTitle) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: "eeeeee")) - Text("신고제도를 남용할 경우, 계정에 제약이 있을 수 있습니다.\n프로필 사진을 신고하시겠습니까?") + Text(I18n.Report.profilePhotoReportDescription) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "909090")) HStack(spacing: 26.7) { Spacer() - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { isShowing = false } - Text("신고") + Text(I18n.Report.reportAction) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { diff --git a/SodaLive/Sources/Report/ProfileReportMenuView.swift b/SodaLive/Sources/Report/ProfileReportMenuView.swift index 4f290c8..0d415ac 100644 --- a/SodaLive/Sources/Report/ProfileReportMenuView.swift +++ b/SodaLive/Sources/Report/ProfileReportMenuView.swift @@ -29,7 +29,7 @@ struct ProfileReportMenuView: View { VStack(spacing: 13.3) { HStack(spacing: 0) { - Text(isBlockedUser ? "사용자 차단해제" : "사용자 차단하기") + Text(isBlockedUser ? I18n.User.unblockUserAction : I18n.User.blockUserAction) .appFont(size: 13.3, weight: .medium) .foregroundColor(.white) @@ -48,7 +48,7 @@ struct ProfileReportMenuView: View { } HStack(spacing: 0) { - Text("사용자 신고하기") + Text(I18n.User.reportUserAction) .appFont(size: 13.3, weight: .medium) .foregroundColor(.white) @@ -63,7 +63,7 @@ struct ProfileReportMenuView: View { } HStack(spacing: 0) { - Text("프로필 신고하기") + Text(I18n.User.reportProfileAction) .appFont(size: 13.3, weight: .medium) .foregroundColor(.white) diff --git a/SodaLive/Sources/Report/UserReportDialogView.swift b/SodaLive/Sources/Report/UserReportDialogView.swift index 4252a2a..e028ca4 100644 --- a/SodaLive/Sources/Report/UserReportDialogView.swift +++ b/SodaLive/Sources/Report/UserReportDialogView.swift @@ -13,16 +13,7 @@ struct UserReportDialogView: View { let confirmAction: (String) -> Void @State private var selectedIndex: Int? = nil - let reasons = [ - "괴롭힘 및 사이버 폭력", - "개인정보 침해", - "명의 도용", - "폭력적 위협", - "아동 학대", - "보호 대상 집단에 대한 증오심 표현", - "스팸 및 사기", - "나에게 해당하는 문제 없음" - ] + let reasons = I18n.Report.userReasons var body: some View { ZStack { @@ -32,7 +23,7 @@ struct UserReportDialogView: View { .onTapGesture { isShowing = false } VStack(spacing: 13.3) { - Text("사용자 신고") + Text(I18n.Report.userReportTitle) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: "eeeeee")) @@ -60,14 +51,14 @@ struct UserReportDialogView: View { HStack(spacing: 26.7) { Spacer() - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { isShowing = false } - Text("신고") + Text(I18n.Report.reportAction) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { diff --git a/SodaLive/Sources/SearchChannel/SearchChannelView.swift b/SodaLive/Sources/SearchChannel/SearchChannelView.swift index fdfd08a..6e0b158 100644 --- a/SodaLive/Sources/SearchChannel/SearchChannelView.swift +++ b/SodaLive/Sources/SearchChannel/SearchChannelView.swift @@ -16,7 +16,7 @@ struct SearchChannelView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { - DetailNavigationBar(title: "채널 탐색") + DetailNavigationBar(title: I18n.SearchChannel.title) HStack(spacing: 0) { Image("ic_title_search_black") @@ -77,7 +77,7 @@ struct SearchChannelView: View { } } } else { - Text("검색 결과가 없습니다.") + Text(I18n.Search.noResults) .appFont(size: 18.3, weight: .medium) .foregroundColor(.white) .padding(.top, 20) diff --git a/SodaLive/Sources/User/FindPassword/FindPasswordView.swift b/SodaLive/Sources/User/FindPassword/FindPasswordView.swift index 652ffdd..bb2992b 100644 --- a/SodaLive/Sources/User/FindPassword/FindPasswordView.swift +++ b/SodaLive/Sources/User/FindPassword/FindPasswordView.swift @@ -17,11 +17,11 @@ struct FindPasswordView: View { BaseView(isLoading: $viewModel.isLoading) { GeometryReader { proxy in VStack(spacing: 0) { - DetailNavigationBar(title: String(localized: "비밀번호 재설정")) + DetailNavigationBar(title: I18n.FindPassword.title) ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { - Text("회원가입한 이메일 주소로\n임시 비밀번호를 보내드립니다.") + Text(I18n.FindPassword.description1) .appFont(size: 16, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .multilineTextAlignment(.center) @@ -29,7 +29,7 @@ struct FindPasswordView: View { .padding(.top, 40) .padding(.horizontal, 26.7) - Text("임시 비밀번호로 로그인 후\n마이페이지 > 프로필 설정에서\n비밀번호를 변경하고 이용하세요.") + Text(I18n.FindPassword.description2) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: "909090")) .multilineTextAlignment(.center) @@ -37,7 +37,7 @@ struct FindPasswordView: View { .padding(.top, 40) .padding(.horizontal, 26.7) - TextField("이메일을 입력하세요", text: $viewModel.email) + TextField(I18n.FindPassword.emailPlaceholder, text: $viewModel.email) .focused($isFocused) .autocapitalization(.none) .disableAutocorrection(true) @@ -54,7 +54,7 @@ struct FindPasswordView: View { isFocused = true } - Text("임시 비밀번호 받기") + Text(I18n.FindPassword.submit) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.white) .frame(maxWidth: proxy.size.width - 26.7) @@ -67,7 +67,7 @@ struct FindPasswordView: View { HStack(spacing: 13.3) { Image("ic_headphones_blue") - Text("고객센터로 문의하기") + Text(I18n.FindPassword.contactSupport) .appFont(size: 13.3, weight: .medium) .foregroundColor(.button) } diff --git a/SodaLive/Sources/User/FindPassword/FindPasswordViewModel.swift b/SodaLive/Sources/User/FindPassword/FindPasswordViewModel.swift index 7fe761e..438b268 100644 --- a/SodaLive/Sources/User/FindPassword/FindPasswordViewModel.swift +++ b/SodaLive/Sources/User/FindPassword/FindPasswordViewModel.swift @@ -22,7 +22,7 @@ final class FindPasswordViewModel: ObservableObject { func findPassword() { if email.trimmingCharacters(in: .whitespaces).isEmpty { - errorMessage = "이메일을 입력하세요." + errorMessage = I18n.FindPassword.emailRequired isShowPopup = true return } @@ -45,7 +45,7 @@ final class FindPasswordViewModel: ObservableObject { if decoded.success { self.email = "" - self.errorMessage = "임시 비밀번호가 입력하신 이메일로 발송되었습니다.\n이메일을 확인해 주세요." + self.errorMessage = I18n.FindPassword.successMessage self.isShowPopup = true DispatchQueue.main.asyncAfter(deadline: .now() + 1) { AppState.shared.back() @@ -54,13 +54,13 @@ final class FindPasswordViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/SodaLive/Sources/User/Login/LoginView.swift b/SodaLive/Sources/User/Login/LoginView.swift index 4a8e500..b93992c 100644 --- a/SodaLive/Sources/User/Login/LoginView.swift +++ b/SodaLive/Sources/User/Login/LoginView.swift @@ -29,11 +29,11 @@ struct LoginView: View { } VStack(spacing: 0) { - DetailNavigationBar(title: "로그인") + DetailNavigationBar(title: I18n.Login.title) Spacer() - TextField("이메일", text: $viewModel.email) + TextField(I18n.User.emailPlaceholder, text: $viewModel.email) .submitLabel(.next) .focused($focusedField, equals: .email) .autocapitalization(.none) @@ -56,9 +56,9 @@ struct LoginView: View { HStack { Group { if isPasswordVisible { - TextField("비밀번호", text: $viewModel.password) + TextField(I18n.User.passwordPlaceholder, text: $viewModel.password) } else { - SecureField("비밀번호", text: $viewModel.password) + SecureField(I18n.User.passwordPlaceholder, text: $viewModel.password) } } .submitLabel(.done) @@ -92,7 +92,7 @@ struct LoginView: View { viewModel.login() } ) { - Text("로그인") + Text(I18n.Login.login) .appFont(size: 15, weight: .bold) .frame(width: screenSize().width - 26.6, height: 46.7) .foregroundColor(.white) @@ -101,7 +101,7 @@ struct LoginView: View { } .padding(.top, 40) - Text("비밀번호를 잊으셨나요?") + Text(I18n.Login.forgotPassword) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.graybb) .padding(.vertical, 10) @@ -111,7 +111,7 @@ struct LoginView: View { } .padding(.top, 30) - Text("보이스온 회원이 아닌가요? 지금 가입하세요.") + Text(I18n.Login.signUpPrompt) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.graybb) .padding(.vertical, 10) diff --git a/SodaLive/Sources/User/Login/LoginViewModel.swift b/SodaLive/Sources/User/Login/LoginViewModel.swift index fd4d867..ee88d31 100644 --- a/SodaLive/Sources/User/Login/LoginViewModel.swift +++ b/SodaLive/Sources/User/Login/LoginViewModel.swift @@ -33,13 +33,13 @@ final class LoginViewModel: NSObject, ObservableObject { func login() { if email.isEmpty { - self.errorMessage = "이메일을 입력해 주세요." + self.errorMessage = I18n.User.emailRequired self.isShowPopup = true return } if password.isEmpty { - self.errorMessage = "비밀번호를 입력해 주세요." + self.errorMessage = I18n.User.passwordRequired self.isShowPopup = true return } @@ -309,13 +309,13 @@ final class LoginViewModel: NSObject, ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -365,20 +365,20 @@ final class LoginViewModel: NSObject, ObservableObject { extension LoginViewModel: ASAuthorizationControllerDelegate { func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { - self.errorMessage = "애플 로그인 정보를 가져오지 못했습니다." + self.errorMessage = I18n.Login.appleAuthorizationFailed self.isShowPopup = true return } guard let identityTokenData = appleIDCredential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) else { - self.errorMessage = "애플 인증 토큰을 가져오지 못했습니다." + self.errorMessage = I18n.Login.appleTokenMissing self.isShowPopup = true return } guard let nonce = currentNonce else { - self.errorMessage = "다시 시도해 주세요." + self.errorMessage = I18n.Login.appleRetry self.isShowPopup = true return } @@ -389,7 +389,7 @@ extension LoginViewModel: ASAuthorizationControllerDelegate { func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { ERROR_LOG(error.localizedDescription) - self.errorMessage = "애플 로그인에 실패했습니다.\n다시 시도해 주세요." + self.errorMessage = I18n.Login.appleSignInFailed self.isShowPopup = true } } diff --git a/SodaLive/Sources/User/SignUp/SignUpView.swift b/SodaLive/Sources/User/SignUp/SignUpView.swift index e9c7703..cb635e2 100644 --- a/SodaLive/Sources/User/SignUp/SignUpView.swift +++ b/SodaLive/Sources/User/SignUp/SignUpView.swift @@ -23,11 +23,11 @@ struct SignUpView: View { BaseView(isLoading: $viewModel.isLoading) { GeometryReader { proxy in VStack(spacing: 0) { - DetailNavigationBar(title: "회원가입") + DetailNavigationBar(title: I18n.SignUp.title) ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { - TextField("이메일", text: $viewModel.email) + TextField(I18n.User.emailPlaceholder, text: $viewModel.email) .submitLabel(.next) .focused($focusedField, equals: .email) .autocapitalization(.none) @@ -51,9 +51,9 @@ struct SignUpView: View { HStack { Group { if isPasswordVisible { - TextField("비밀번호", text: $viewModel.password) + TextField(I18n.User.passwordPlaceholder, text: $viewModel.password) } else { - SecureField("비밀번호", text: $viewModel.password) + SecureField(I18n.User.passwordPlaceholder, text: $viewModel.password) } } .submitLabel(.done) @@ -96,11 +96,11 @@ struct SignUpView: View { } HStack(spacing: 5) { - Text("이용약관") + Text(I18n.SignUp.terms) .appFont(size: 12, weight: .medium) .foregroundColor(Color.grayee) - Text("(필수)") + Text(I18n.SignUp.required) .appFont(size: 12, weight: .medium) .foregroundColor(Color.button) } @@ -129,11 +129,11 @@ struct SignUpView: View { } HStack(spacing: 5) { - Text("개인정보수집 및 이용동의") + Text(I18n.SignUp.privacyPolicy) .appFont(size: 12, weight: .medium) .foregroundColor(Color.grayee) - Text("(필수)") + Text(I18n.SignUp.required) .appFont(size: 12, weight: .medium) .foregroundColor(Color.button) } @@ -152,7 +152,7 @@ struct SignUpView: View { .padding(.top, 20) .padding(.horizontal, 13.3) - Text("회원가입") + Text(I18n.SignUp.submit) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color.white) .padding(.vertical, 16) diff --git a/SodaLive/Sources/User/SignUp/SignUpViewModel.swift b/SodaLive/Sources/User/SignUp/SignUpViewModel.swift index 5933edf..12c2eda 100644 --- a/SodaLive/Sources/User/SignUp/SignUpViewModel.swift +++ b/SodaLive/Sources/User/SignUp/SignUpViewModel.swift @@ -67,13 +67,13 @@ final class SignUpViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -83,25 +83,25 @@ final class SignUpViewModel: ObservableObject { private func validate() -> Bool { if email.trimmingCharacters(in: .whitespaces).isEmpty || !validateEmail() { - errorMessage = "올바른 이메일을 입력하세요" + errorMessage = I18n.User.emailInvalid isShowPopup = true return false } if password.trimmingCharacters(in: .whitespaces).isEmpty { - errorMessage = "비밀번호를 입력하세요" + errorMessage = I18n.User.passwordRequired isShowPopup = true return false } if !validatePassword() { - errorMessage = "영문, 숫자 포함 8자 이상의 비밀번호를 입력해 주세요." + errorMessage = I18n.ProfileUpdate.passwordRuleHint isShowPopup = true return false } if !isAgreeTerms || !isAgreePrivacyPolicy { - errorMessage = "약관에 동의하셔야 회원가입이 가능합니다." + errorMessage = I18n.SignUp.agreementRequired isShowPopup = true return false } diff --git a/SodaLive/Sources/User/UserTextField.swift b/SodaLive/Sources/User/UserTextField.swift index 40ec763..ecea17a 100644 --- a/SodaLive/Sources/User/UserTextField.swift +++ b/SodaLive/Sources/User/UserTextField.swift @@ -59,7 +59,7 @@ struct UserTextField: View { Image("btn_select_normal") } - Text("비밀번호 표시") + Text(I18n.User.showPassword) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color(hex: "eeeeee")) } @@ -74,8 +74,8 @@ struct UserTextField: View { struct UserTextField_Previews: PreviewProvider { static var previews: some View { UserTextField( - title: "이메일", - hint: "user_id@email.com", + title: I18n.User.passwordTitle, + hint: I18n.User.passwordPlaceholder, isSecure: true, variable: .constant("test"), isPasswordVisibleButton: true diff --git a/SodaLive/Sources/User/UserViewModel.swift b/SodaLive/Sources/User/UserViewModel.swift index e944b2d..aa031ad 100644 --- a/SodaLive/Sources/User/UserViewModel.swift +++ b/SodaLive/Sources/User/UserViewModel.swift @@ -54,13 +54,13 @@ final class UserViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } @@ -88,7 +88,7 @@ final class UserViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "차단하였습니다." + self.errorMessage = I18n.MemberChannel.userBlocked self.dismissDialog = true self.memberId = 0 @@ -97,13 +97,13 @@ final class UserViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } } self.isShowPopup = true } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -129,7 +129,7 @@ final class UserViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "차단이 해제 되었습니다." + self.errorMessage = I18n.MemberChannel.userUnblocked self.dismissDialog = true self.memberId = 0 @@ -138,20 +138,20 @@ final class UserViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } } self.isShowPopup = true } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } .store(in: &subscription) } - func report(type: ReportType, reason: String = "프로필 신고") { + func report(type: ReportType, reason: String = I18n.Report.profileReportReason) { isLoading = true let request = ReportRequest(type: type, reason: reason, reportedMemberId: memberId, cheersId: nil, audioContentId: nil) @@ -178,12 +178,12 @@ final class UserViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/docs/20260331_하드코딩텍스트_I18n통일계획.md b/docs/20260331_하드코딩텍스트_I18n통일계획.md index 3ebea4f..a44e4e0 100644 --- a/docs/20260331_하드코딩텍스트_I18n통일계획.md +++ b/docs/20260331_하드코딩텍스트_I18n통일계획.md @@ -416,20 +416,17 @@ - [ ] `SodaLive/Sources/MyPage/ServiceCenter/ServiceCenterViewModel.swift` ### Notification (2) -- [ ] `SodaLive/Sources/Notification/List/PushNotificationListItemView.swift` -- [ ] `SodaLive/Sources/Notification/List/PushNotificationListView.swift` - -### Onboarding (1) -- [ ] `SodaLive/Sources/Onboarding/OnboardingView.swift` +- [x] `SodaLive/Sources/Notification/List/PushNotificationListItemView.swift` +- [x] `SodaLive/Sources/Notification/List/PushNotificationListView.swift` ### Report (4) -- [ ] `SodaLive/Sources/Report/CheersReportDialogView.swift` -- [ ] `SodaLive/Sources/Report/ProfileReportDialogView.swift` -- [ ] `SodaLive/Sources/Report/ProfileReportMenuView.swift` -- [ ] `SodaLive/Sources/Report/UserReportDialogView.swift` +- [x] `SodaLive/Sources/Report/CheersReportDialogView.swift` +- [x] `SodaLive/Sources/Report/ProfileReportDialogView.swift` +- [x] `SodaLive/Sources/Report/ProfileReportMenuView.swift` +- [x] `SodaLive/Sources/Report/UserReportDialogView.swift` ### SearchChannel (1) -- [ ] `SodaLive/Sources/SearchChannel/SearchChannelView.swift` +- [x] `SodaLive/Sources/SearchChannel/SearchChannelView.swift` ### Settings (15) - [ ] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift` @@ -457,14 +454,14 @@ - [ ] `SodaLive/Sources/UI/Component/SeriesListItemView.swift` ### User (8) -- [ ] `SodaLive/Sources/User/FindPassword/FindPasswordView.swift` -- [ ] `SodaLive/Sources/User/FindPassword/FindPasswordViewModel.swift` -- [ ] `SodaLive/Sources/User/Login/LoginView.swift` -- [ ] `SodaLive/Sources/User/Login/LoginViewModel.swift` -- [ ] `SodaLive/Sources/User/SignUp/SignUpView.swift` -- [ ] `SodaLive/Sources/User/SignUp/SignUpViewModel.swift` -- [ ] `SodaLive/Sources/User/UserTextField.swift` -- [ ] `SodaLive/Sources/User/UserViewModel.swift` +- [x] `SodaLive/Sources/User/FindPassword/FindPasswordView.swift` +- [x] `SodaLive/Sources/User/FindPassword/FindPasswordViewModel.swift` +- [x] `SodaLive/Sources/User/Login/LoginView.swift` +- [x] `SodaLive/Sources/User/Login/LoginViewModel.swift` +- [x] `SodaLive/Sources/User/SignUp/SignUpView.swift` +- [x] `SodaLive/Sources/User/SignUp/SignUpViewModel.swift` +- [x] `SodaLive/Sources/User/UserTextField.swift` +- [x] `SodaLive/Sources/User/UserViewModel.swift` ## 검증 기록 ### 1차 계획 수립 (2026-03-31) @@ -612,3 +609,19 @@ - 모듈 재검증 결과, 남은 한글 문자열은 Preview 샘플/`DEBUG_LOG`/SDK 입력값(비노출)만 존재. - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약). + +### 9차 구현 (User/SearchChannel/Report/Notification 15개 파일 i18n 전환, 2026-03-31) +- 무엇/왜/어떻게: + - 무엇: 변경 대상 목록 중 `User`, `SearchChannel`, `Report`, `Notification` 모듈의 15개 파일을 처리해 화면 문자열과 사용자 노출 에러 메시지를 `I18n.*`로 통일했다. + - 왜: 로그인/회원가입/비밀번호 재설정/채널 탐색/신고/알림 화면에 하드코딩 문구가 남아 있어 다국어 접근이 일관되지 않았기 때문이다. + - 어떻게: 관련 뷰와 뷰모델의 문자열을 교체하고, `I18n.swift`에 `User`, `SignUp`, `FindPassword`, `SearchChannel`, `NotificationList`, `Report` 키를 보강했다. +- 실행 명령/도구: + - `rg -n 'Text\\(\"|SecureField\\(\"|TextField\\(\"|DetailNavigationBar\\(title: \\\"|String\\(localized: \\\"|errorMessage = \\\"|let reasons = \\[' SodaLive/Sources/User SodaLive/Sources/SearchChannel SodaLive/Sources/Report SodaLive/Sources/Notification -g '!**/generated/**'` + - `rg -n 'I18n\\.(User|SignUp|FindPassword|Login|SearchChannel|NotificationList|Report|Common)' SodaLive/Sources/User SodaLive/Sources/SearchChannel SodaLive/Sources/Report SodaLive/Sources/Notification -g '!**/generated/**'` + - `xcodebuild -project "SodaLive.xcodeproj" -scheme "SodaLive" -configuration Debug build` + - `HOME=/tmp/codexhome xcodebuild -project "SodaLive.xcodeproj" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveDerivedData -clonedSourcePackagesDirPath /tmp/SodaLiveSPM build` +- 결과: + - `User` 8개 파일, `SearchChannel` 1개 파일, `Report` 4개 파일, `Notification` 2개 파일 체크박스를 완료 처리했다. + - `PushNotificationListItemView.swift`의 시간 구분자도 `I18n.NotificationList.timestampSeparator`로 이관했다. + - `xcodebuild`는 샌드박스 내 캐시/시뮬레이터 접근 제약과 이후 네트워크 차단으로 실패했다. 첫 시도는 workspace 인식 문제와 CoreSimulator 환경 오류가 섞여 있었고, 프로젝트 빌드로 전환한 뒤에는 Swift Package 의존성(`objectbox-swift-spm`)을 GitHub에서 가져오지 못해 중단되었다. + - 따라서 이번 턴에서는 정적 치환과 문서 동기화까지 완료했고, 실제 컴파일 성공 여부는 네트워크가 허용되는 환경에서 추가 확인이 필요하다.