From 9369a52ba2fd804a884f74d1401a2f190a56d12d Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 31 Mar 2026 22:01:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=EB=A5=BC=20I18n=20=ED=82=A4=EB=A1=9C=20?= =?UTF-8?q?=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/Sources/I18n/I18n.swift | 146 ++++++++++++++++++ .../Message/MessageFilterTabView.swift | 6 +- SodaLive/Sources/Message/MessageView.swift | 8 +- .../Text/Detail/TextMessageDetailView.swift | 20 +-- .../Detail/TextMessageDetailViewModel.swift | 16 +- .../SelectRecipient/SelectRecipientView.swift | 4 +- .../SelectRecipientViewModel.swift | 8 +- .../Message/Text/TextMessageView.swift | 2 +- .../Text/Write/TextMessageWriteView.swift | 8 +- .../Sources/Message/Voice/SoundManager.swift | 10 +- docs/20260331_하드코딩텍스트_I18n통일계획.md | 49 ++++-- 11 files changed, 226 insertions(+), 51 deletions(-) diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 19289b7..4c32144 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -2274,6 +2274,152 @@ If you block this user, the following features will be restricted. } } + enum Message { + static var title: String { + pick(ko: "메시지", en: "Messages", ja: "メッセージ") + } + + static var autoDeleteNotice: String { + pick( + ko: "※ 보관하지 않은 받은 메시지는 3일 후, 자동 삭제됩니다.", + en: "※ Unarchived received messages are automatically deleted after 3 days.", + ja: "※ 保管していない受信メッセージは3日後に自動削除されます。" + ) + } + + enum Tab { + static var text: String { + pick(ko: "문자", en: "Text", ja: "テキスト") + } + + static var voice: String { + pick(ko: "음성", en: "Voice", ja: "音声") + } + } + + enum FilterTab { + static var received: String { + pick(ko: "받은 메시지", en: "Received", ja: "受信") + } + + static var sent: String { + pick(ko: "보낸 메시지", en: "Sent", ja: "送信") + } + + static var archive: String { + pick(ko: "보관함", en: "Archived", ja: "保管済み") + } + } + + enum Text { + static var emptyState: String { + pick( + ko: "메시지가 없습니다.\n친구들과 소통해보세요!", + en: "No messages.\nStart chatting with friends!", + ja: "メッセージがありません。\n友だちとコミュニケーションしてみましょう!" + ) + } + + enum SelectRecipient { + static var title: String { + pick(ko: "받는 사람 검색", en: "Search recipient", ja: "受信者を検索") + } + + static var nicknamePlaceholder: String { + pick(ko: "닉네임을 입력해주세요", en: "Enter a nickname", ja: "ニックネームを入力してください") + } + } + + enum Write { + static var title: String { + pick(ko: "새로운 메시지", en: "New message", ja: "新しいメッセージ") + } + + static var recipientLabel: String { + pick(ko: "받는 사람", en: "Recipient", ja: "受信者") + } + } + + enum Detail { + static var receivedTitle: String { + pick(ko: "받은 메시지 상세", en: "Received message details", ja: "受信メッセージ詳細") + } + + static var sentTitle: String { + pick(ko: "보낸 메시지 상세", en: "Sent message details", ja: "送信メッセージ詳細") + } + + static var keptTitle: String { + pick(ko: "저장한 메시지 상세", en: "Archived message details", ja: "保管メッセージ詳細") + } + + static var dateFormat: String { + pick( + ko: "yyyy년 MM월 dd일 E요일 HH:mm", + en: "yyyy-MM-dd E HH:mm", + ja: "yyyy年MM月dd日(E) HH:mm" + ) + } + + static var reply: String { + pick(ko: "답장", en: "Reply", ja: "返信") + } + + static var keep: String { + pick(ko: "보관", en: "Archive", ja: "保管") + } + + static var alreadyKept: String { + pick(ko: "이미 보관된 메시지 입니다", en: "This message is already archived.", ja: "このメッセージはすでに保管済みです。") + } + + static var deleteFailed: String { + pick( + ko: "메시지를 삭제하지 못했습니다\n잠시 후 다시 시도해 주세요.", + en: "Could not delete the message.\nPlease try again later.", + ja: "メッセージを削除できませんでした。\nしばらくしてからもう一度お試しください。" + ) + } + + static var deleteSuccess: String { + pick(ko: "삭제되었습니다.", en: "Deleted.", ja: "削除されました。") + } + + static var keepFailed: String { + pick( + ko: "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요.", + en: "Could not archive the message.\nPlease try again later.", + ja: "メッセージを保管できませんでした。\nしばらくしてからもう一度お試しください。" + ) + } + + static var keepSuccess: String { + pick(ko: "보관되었습니다.", en: "Archived.", ja: "保管されました。") + } + } + } + + enum Voice { + enum Sound { + static var permissionDenied: String { + pick( + ko: "권한을 허용하지 않으시면 음성메시지 서비스를 이용하실 수 없습니다.", + en: "You cannot use voice messages unless microphone permission is allowed.", + ja: "権限を許可しない場合、音声メッセージサービスを利用できません。" + ) + } + + static var commonError: String { + pick( + ko: "오류가 발생했습니다. 다시 시도해 주세요.", + en: "An error occurred. Please try again.", + ja: "エラーが発生しました。もう一度お試しください。" + ) + } + } + } + } + enum Series { static var new: String { pick(ko: "신작", en: "New", ja: "新作") } static var complete: String { pick(ko: "완결", en: "Completed", ja: "完結") } diff --git a/SodaLive/Sources/Message/MessageFilterTabView.swift b/SodaLive/Sources/Message/MessageFilterTabView.swift index b0985de..b39d496 100644 --- a/SodaLive/Sources/Message/MessageFilterTabView.swift +++ b/SodaLive/Sources/Message/MessageFilterTabView.swift @@ -13,7 +13,7 @@ struct MessageFilterTabView: View { var body: some View { HStack(spacing: 6.7) { - Text("받은 메시지") + Text(I18n.Message.FilterTab.received) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: currentFilterTab == .receive ? "3bb9f1" : "777777")) .padding(.horizontal, 25) @@ -31,7 +31,7 @@ struct MessageFilterTabView: View { } } - Text("보낸 메시지") + Text(I18n.Message.FilterTab.sent) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: currentFilterTab == .sent ? "3bb9f1" : "777777")) .padding(.horizontal, 25) @@ -49,7 +49,7 @@ struct MessageFilterTabView: View { } } - Text("보관함") + Text(I18n.Message.FilterTab.archive) .appFont(size: 12, weight: .medium) .foregroundColor(Color(hex: currentFilterTab == .keep ? "3bb9f1" : "777777")) .padding(.horizontal, 25) diff --git a/SodaLive/Sources/Message/MessageView.swift b/SodaLive/Sources/Message/MessageView.swift index 5f53987..d380c9b 100644 --- a/SodaLive/Sources/Message/MessageView.swift +++ b/SodaLive/Sources/Message/MessageView.swift @@ -17,11 +17,11 @@ struct MessageView: View { Color.black VStack { - DetailNavigationBar(title: String(localized: "메시지")) + DetailNavigationBar(title: I18n.Message.title) Tab() - Text("※ 보관하지 않은 받은 메시지는 3일 후, 자동 삭제됩니다.") + Text(I18n.Message.autoDeleteNotice) .appFont(size: 13.3, weight: .medium) .padding(.top, 20) @@ -50,7 +50,7 @@ struct MessageView: View { } }) { VStack(spacing: 0) { - Text("문자") + Text(I18n.Message.Tab.text) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: viewModel.currentTab == .text ? "eeeeee" : "777777")) .frame(width: tabWidth, height: 50) @@ -69,7 +69,7 @@ struct MessageView: View { } }) { VStack(spacing: 0) { - Text("음성") + Text(I18n.Message.Tab.voice) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: viewModel.currentTab == .voice ? "eeeeee" : "777777")) .frame(width: tabWidth, height: 50) diff --git a/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift index 4f4caea..9eb1f5b 100644 --- a/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift +++ b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift @@ -28,11 +28,11 @@ struct TextMessageDetailView: View { VStack(spacing: 0) { switch messageBox { case .receive: - DetailNavigationBar(title: "받은 메시지 상세") { back() } + DetailNavigationBar(title: I18n.Message.Text.Detail.receivedTitle) { back() } case .sent: - DetailNavigationBar(title: "보낸 메시지 상세") { back() } + DetailNavigationBar(title: I18n.Message.Text.Detail.sentTitle) { back() } case .keep: - DetailNavigationBar(title: "저장한 메시지 상세") { back() } + DetailNavigationBar(title: I18n.Message.Text.Detail.keptTitle) { back() } } HStack(spacing: 13.3) { @@ -70,7 +70,7 @@ struct TextMessageDetailView: View { Text(messageItem.date.convertDateFormat( from: "yyyy-MM-dd hh:mm:ss", - to: "yyyy년 MM월 dd일 E요일 HH:mm" + to: I18n.Message.Text.Detail.dateFormat )) .appFont(size: 15, weight: .medium) .foregroundColor(Color(hex: "bbbbbb")) @@ -93,7 +93,7 @@ struct TextMessageDetailView: View { if messageBox == .receive { HStack(spacing: 6.7) { - Text("답장") + Text(I18n.Message.Text.Detail.reply) .appFont(size: 14.7, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) .frame( @@ -106,7 +106,7 @@ struct TextMessageDetailView: View { AppState.shared.setAppStep(step: .writeTextMessage(userId: messageItem.senderId, nickname: messageItem.senderNickname)) } - Text("보관") + Text(I18n.Message.Text.Detail.keep) .appFont(size: 14.7, weight: .bold) .foregroundColor(Color(hex: "3bb9f1")) .frame( @@ -117,15 +117,15 @@ struct TextMessageDetailView: View { .cornerRadius(6.7) .onTapGesture { if messageItem.isKept { - viewModel.errorMessage = "이미 보관된 메시지 입니다" + viewModel.errorMessage = I18n.Message.Text.Detail.alreadyKept viewModel.isShowPopup = true return } else { viewModel.keepTextMessage() } } - - Text("삭제") + + Text(I18n.Common.delete) .appFont(size: 14.7, weight: .bold) .foregroundColor(Color(hex: "3bb9f1")) .frame( @@ -141,7 +141,7 @@ struct TextMessageDetailView: View { .frame(width: screenSize().width - 26.7) .padding(.vertical, 26.7) } else { - Text("삭제") + Text(I18n.Common.delete) .appFont(size: 14.7, weight: .bold) .foregroundColor(Color(hex: "3bb9f1")) .frame( diff --git a/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift index 70e5800..641aeb3 100644 --- a/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift +++ b/SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift @@ -24,7 +24,7 @@ final class TextMessageDetailViewModel: ObservableObject { func deleteMessage(onSuccess: @escaping () -> Void) { if messageId <= 0 { - errorMessage = "메시지를 삭제하지 못했습니다\n잠시 후 다시 시도해 주세요." + errorMessage = I18n.Message.Text.Detail.deleteFailed isShowPopup = true return } @@ -48,7 +48,7 @@ final class TextMessageDetailViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "삭제되었습니다." + self.errorMessage = I18n.Message.Text.Detail.deleteSuccess self.isShowPopup = true DispatchQueue.main.asyncAfter(deadline: .now() + 1) { onSuccess() @@ -58,13 +58,13 @@ final class TextMessageDetailViewModel: ObservableObject { self.errorMessage = message self.isShowPopup = true } else { - self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.errorMessage = I18n.Message.Text.Detail.deleteFailed } self.isShowPopup = true } } catch { - self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.errorMessage = I18n.Message.Text.Detail.deleteFailed self.isShowPopup = true } } @@ -73,7 +73,7 @@ final class TextMessageDetailViewModel: ObservableObject { func keepTextMessage() { if messageId <= 0 { - errorMessage = "메시지를 저장하지 못했습니다\n잠시 후 다시 시도해 주세요." + errorMessage = I18n.Message.Text.Detail.keepFailed isShowPopup = true return } @@ -96,20 +96,20 @@ final class TextMessageDetailViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) if decoded.success { - self.errorMessage = "보관되었습니다." + self.errorMessage = I18n.Message.Text.Detail.keepSuccess self.isShowPopup = true } else { if let message = decoded.message { self.errorMessage = message self.isShowPopup = true } else { - self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.errorMessage = I18n.Message.Text.Detail.keepFailed } self.isShowPopup = true } } catch { - self.errorMessage = "메시지를 보관하지 못했습니다.\n잠시 후 다시 시도해 주세요." + self.errorMessage = I18n.Message.Text.Detail.keepFailed self.isShowPopup = true } } diff --git a/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientView.swift b/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientView.swift index e2c7398..06ab34b 100644 --- a/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientView.swift +++ b/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientView.swift @@ -18,11 +18,11 @@ struct SelectRecipientView: View { var body: some View { BaseView { VStack(spacing: 20) { - DetailNavigationBar(title: String(localized: "받는 사람 검색")) { + DetailNavigationBar(title: I18n.Message.Text.SelectRecipient.title) { isShowing = false } - TextField("닉네임을 입력해주세요", text: $viewModel.searchNickname) + TextField(I18n.Message.Text.SelectRecipient.nicknamePlaceholder, text: $viewModel.searchNickname) .autocapitalization(.none) .disableAutocorrection(true) .appFont(size: 13.3, weight: .medium) diff --git a/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientViewModel.swift b/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientViewModel.swift index 0fc712b..112899a 100644 --- a/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientViewModel.swift +++ b/SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientViewModel.swift @@ -51,13 +51,13 @@ final class SelectRecipientViewModel: ObservableObject { DEBUG_LOG("message: \(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 } } @@ -86,13 +86,13 @@ final class SelectRecipientViewModel: 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/Message/Text/TextMessageView.swift b/SodaLive/Sources/Message/Text/TextMessageView.swift index 14117a8..a1eff45 100644 --- a/SodaLive/Sources/Message/Text/TextMessageView.swift +++ b/SodaLive/Sources/Message/Text/TextMessageView.swift @@ -68,7 +68,7 @@ struct TextMessageView: View { .resizable() .frame(width: 60, height: 60) - Text("메시지가 없습니다.\n친구들과 소통해보세요!") + Text(I18n.Message.Text.emptyState) .multilineTextAlignment(.center) .appFont(size: 10.7, weight: .medium) .foregroundColor(Color(hex: "bbbbbb")) diff --git a/SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift b/SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift index 1c108fd..7ae20af 100644 --- a/SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift +++ b/SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift @@ -21,19 +21,19 @@ struct TextMessageWriteView: View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { HStack(spacing: 0) { - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: "3bb9f1").opacity(0)) Spacer() - Text("새로운 메시지") + Text(I18n.Message.Text.Write.title) .appFont(size: 18.3, weight: .bold) .foregroundColor(Color(hex: "eeeeee")) Spacer() - Text("취소") + Text(I18n.Common.cancel) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: "3bb9f1")) .onTapGesture { @@ -51,7 +51,7 @@ struct TextMessageWriteView: View { Spacer() HStack(spacing: 13.3) { - Text("받는 사람") + Text(I18n.Message.Text.Write.recipientLabel) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color(hex: "777777")) diff --git a/SodaLive/Sources/Message/Voice/SoundManager.swift b/SodaLive/Sources/Message/Voice/SoundManager.swift index 4e4cb8a..ed529d3 100644 --- a/SodaLive/Sources/Message/Voice/SoundManager.swift +++ b/SodaLive/Sources/Message/Voice/SoundManager.swift @@ -36,14 +36,14 @@ class SoundManager: NSObject, ObservableObject { audioSession.requestRecordPermission() { [weak self] allowed in DispatchQueue.main.async { if !allowed { - self?.errorMessage = "권한을 허용하지 않으시면 음성메시지 서비스를 이용하실 수 없습니다." + self?.errorMessage = I18n.Message.Voice.Sound.permissionDenied self?.isShowPopup = true self?.onClose = true } } } } catch { - errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + errorMessage = I18n.Message.Voice.Sound.commonError isShowPopup = true onClose = true } @@ -71,7 +71,7 @@ class SoundManager: NSObject, ObservableObject { } isRecording = true } catch { - errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + errorMessage = I18n.Message.Voice.Sound.commonError isShowPopup = true } } @@ -112,7 +112,7 @@ class SoundManager: NSObject, ObservableObject { self.duration = self.player.duration } catch { - self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + self.errorMessage = I18n.Message.Voice.Sound.commonError self.isShowPopup = true } @@ -147,7 +147,7 @@ class SoundManager: NSObject, ObservableObject { try FileManager.default.removeItem(at: getAudioFileURL()) duration = 0 } catch { - errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." + errorMessage = I18n.Message.Voice.Sound.commonError isShowPopup = true } } diff --git a/docs/20260331_하드코딩텍스트_I18n통일계획.md b/docs/20260331_하드코딩텍스트_I18n통일계획.md index 3d47c43..235d6b1 100644 --- a/docs/20260331_하드코딩텍스트_I18n통일계획.md +++ b/docs/20260331_하드코딩텍스트_I18n통일계획.md @@ -393,16 +393,16 @@ ### Message (13) #### Group 1 (1-10) -- [ ] `SodaLive/Sources/Message/MessageFilterTabView.swift` -- [ ] `SodaLive/Sources/Message/MessageView.swift` -- [ ] `SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift` -- [ ] `SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift` -- [ ] `SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientView.swift` -- [ ] `SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientViewModel.swift` -- [ ] `SodaLive/Sources/Message/Text/TextMessageView.swift` -- [ ] `SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift` -- [ ] `SodaLive/Sources/Message/Voice/SoundManager.swift` -- [ ] `SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift` +- [x] `SodaLive/Sources/Message/MessageFilterTabView.swift` +- [x] `SodaLive/Sources/Message/MessageView.swift` +- [x] `SodaLive/Sources/Message/Text/Detail/TextMessageDetailView.swift` +- [x] `SodaLive/Sources/Message/Text/Detail/TextMessageDetailViewModel.swift` +- [x] `SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientView.swift` +- [x] `SodaLive/Sources/Message/Text/SelectRecipient/SelectRecipientViewModel.swift` +- [x] `SodaLive/Sources/Message/Text/TextMessageView.swift` +- [x] `SodaLive/Sources/Message/Text/Write/TextMessageWriteView.swift` +- [x] `SodaLive/Sources/Message/Voice/SoundManager.swift` +- [x] `SodaLive/Sources/Message/Voice/VoiceMessageItemView.swift` #### Group 2 (11-13) - [ ] `SodaLive/Sources/Message/Voice/VoiceMessageView.swift` @@ -746,3 +746,32 @@ - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약). - LSP 진단: SourceKit 단독 해석 환경에서 외부 모듈/심볼(`Kingfisher`, `RichText`, 앱 내부 타입) 미해결 오류가 대량 보고되나, 동일 변경셋은 `xcodebuild` 실컴파일 통과로 검증 완료. + +### 13차 구현 (Message 모듈 Group 1, 10개 파일 처리, 2026-03-31) +- 무엇/왜/어떻게: + - 무엇: `변경 대상 파일 전체 목록`의 `Message` Group 1(10개 파일)에서 사용자 노출 하드코딩 문구를 `I18n.*` 참조로 전환하고 체크박스를 완료 처리. + - 왜: Message 탭/필터/상세/수신자 검색/텍스트 작성/녹음 오류 메시지에 하드코딩 문자열이 남아 있어 `I18n.swift` 단일 접근 원칙과 불일치했기 때문. + - 어떻게: explore/librarian 병렬 탐색 + `grep`/`ast_grep_search` 직접 점검으로 치환 범위를 확정하고, `I18n.swift`에 `I18n.Message` 네임스페이스를 추가한 뒤 Group 1 파일 호출부를 치환. +- 실행 명령/도구: + - `task(subagent_type="explore", ...)` x2 (`bg_21246137`, `bg_bc8d6ca7`) + - `task(subagent_type="librarian", ...)` x2 (`bg_fdbe065d`, `bg_ce19e89c`) + - `grep("\"[^\"]*[가-힣][^\"]*\"", include=*.swift, path=SodaLive/Sources/Message)` + - `grep("NSLocalizedString\\(|String\\(localized:|LocalizedStringKey\\(", include=*.swift, path=SodaLive/Sources/Message)` + - `ast_grep_search(pattern="Text(\"$TEXT\")", lang=swift, paths=[SodaLive/Sources/Message])` + - `lsp_diagnostics(filePath=변경 파일 전체)` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: + - `I18n.swift`에 `I18n.Message`(`Tab`, `FilterTab`, `Text.SelectRecipient`, `Text.Write`, `Text.Detail`, `Voice.Sound`) 키셋 추가. + - Oracle 후속 보정: `TextMessageDetailViewModel` 삭제 실패 fallback 키를 `keepFailed` → `deleteFailed`로 수정, `TextMessageWriteView` 수신자 라벨을 placeholder 키와 분리(`I18n.Message.Text.Write.recipientLabel`), Message 보관 관련 영문/일문 용어를 `Archive/Archived` 기준으로 통일. + - 실치환 파일: `MessageFilterTabView.swift`, `MessageView.swift`, `TextMessageDetailView.swift`, `TextMessageDetailViewModel.swift`, `SelectRecipientView.swift`, `SelectRecipientViewModel.swift`, `TextMessageView.swift`, `TextMessageWriteView.swift`, `SoundManager.swift`. + - 점검만 수행(실치환 없음): `VoiceMessageItemView.swift` (사용자 노출 한글 하드코딩 없음, 시간 표기 `00:00` 숫자 포맷만 존재). + - Message Group 1 체크박스 10개 `- [x]` 완료 반영. + - Group 1 재탐지 결과 한글 리터럴은 `TextMessageDetailView.swift` Preview 샘플 2건(`"누군가"`, `"테스터"`)만 잔존. + - `TextMessageDetailView` 날짜 표기는 기존 `convertDateFormat` 경로를 유지해 현재 기기 locale 기준으로 출력됨(앱 언어 설정과 다른 locale일 경우 혼합 표기 가능성은 후속 정리 체크포인트로 유지). + - Message 모듈 내 `String(localized:)`/`NSLocalizedString`/`LocalizedStringKey` 직접 참조 0건 확인. + - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). + - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약). + - LSP 진단: SourceKit 단독 해석 환경에서 외부 모듈/프로젝트 심볼 미해결 오류(`Kingfisher`, `MessageRepository` 등)가 보고되나, 동일 변경셋은 `xcodebuild` 실컴파일 통과로 검증 완료.