From 47085dc1cae5cc7b0118ff820a3555efe3222ceb Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 31 Mar 2026 16:30:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=EB=A5=BC=20I18n=20=ED=82=A4=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Character/CharacterItemView.swift | 2 +- .../Chat/Character/CharacterSectionView.swift | 4 +- .../Chat/Character/CharacterView.swift | 6 +- .../Chat/Character/CharacterViewModel.swift | 9 +- .../Detail/CharacterDetailView.swift | 42 +-- .../Gallery/CharacterDetailGalleryView.swift | 4 +- .../NewCharacterListViewModel.swift | 6 +- .../New/Views/NewCharacterListView.swift | 6 +- .../Recent/RecentCharacterSectionView.swift | 4 +- SodaLive/Sources/Chat/ChatTabView.swift | 11 +- .../Detail/OriginalWorkDetailHeaderView.swift | 2 +- .../Detail/OriginalWorkDetailView.swift | 12 +- .../Detail/OriginalWorkDetailViewModel.swift | 6 +- .../Chat/Original/OriginalWorkViewModel.swift | 6 +- .../Sources/Chat/Talk/Room/ChatRoomView.swift | 14 +- .../Chat/Talk/Room/ChatRoomViewModel.swift | 31 +- .../Talk/Room/Message/AiMessageItemView.swift | 2 +- .../Message/TypingIndicatorItemView.swift | 2 +- .../Room/Quota/ChatQuotaNoticeItemView.swift | 4 +- .../Room/Settings/ChatBgSelectionView.swift | 4 +- .../Settings/ChatBgSelectionViewModel.swift | 4 +- .../Talk/Room/Settings/ChatSettingsView.swift | 12 +- SodaLive/Sources/Chat/Talk/TalkView.swift | 2 +- .../Sources/Chat/Talk/TalkViewModel.swift | 7 +- SodaLive/Sources/I18n/I18n.swift | 265 ++++++++++++++++++ docs/20260331_Chat모듈_I18n전환계획.md | 41 +++ docs/20260331_하드코딩텍스트_I18n통일계획.md | 84 ++++-- 27 files changed, 464 insertions(+), 128 deletions(-) create mode 100644 docs/20260331_Chat모듈_I18n전환계획.md diff --git a/SodaLive/Sources/Chat/Character/CharacterItemView.swift b/SodaLive/Sources/Chat/Character/CharacterItemView.swift index 39f5412..3a44134 100644 --- a/SodaLive/Sources/Chat/Character/CharacterItemView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterItemView.swift @@ -33,7 +33,7 @@ struct CharacterItemView: View { HStack { Spacer() - Text("N") + Text(I18n.Chat.Character.newBadge) .appFont(size: 18, weight: .regular) .foregroundColor(.white) .frame(width: 30, height: 30) diff --git a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift index 9f0eea4..bc1428c 100644 --- a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift @@ -8,7 +8,7 @@ import SwiftUI struct CharacterSectionView: View { - let title: LocalizedStringResource + let title: String let items: [Character] let isShowRank: Bool var trailingTitle: String? = nil @@ -52,7 +52,7 @@ struct CharacterSectionView: View { #Preview { CharacterSectionView( - title: "신규 캐릭터", + title: I18n.Chat.Character.newSectionTitle, items: [ Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300", isNew: true), Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300", isNew: false) diff --git a/SodaLive/Sources/Chat/Character/CharacterView.swift b/SodaLive/Sources/Chat/Character/CharacterView.swift index 11f4cb6..6ff8ae7 100644 --- a/SodaLive/Sources/Chat/Character/CharacterView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterView.swift @@ -39,7 +39,7 @@ struct CharacterView: View { // 인기 캐릭터 섹션 if !viewModel.popularCharacters.isEmpty { CharacterSectionView( - title: "인기 캐릭터", + title: I18n.Chat.Character.popularSectionTitle, items: viewModel.popularCharacters, isShowRank: true, onTap: { ch in @@ -51,7 +51,7 @@ struct CharacterView: View { // 신규 캐릭터 섹션 if !viewModel.newCharacters.isEmpty { CharacterSectionView( - title: "신규 캐릭터", + title: I18n.Chat.Character.newSectionTitle, items: viewModel.newCharacters, isShowRank: false, trailingTitle: I18n.Common.viewAll, @@ -67,7 +67,7 @@ struct CharacterView: View { if !viewModel.recommendCharacters.isEmpty { VStack(alignment: .leading, spacing: 16) { HStack { - Text("추천 캐릭터") + Text(I18n.Chat.Character.recommendSectionTitle) .appFont(size: 24, weight: .bold) .foregroundColor(.white) diff --git a/SodaLive/Sources/Chat/Character/CharacterViewModel.swift b/SodaLive/Sources/Chat/Character/CharacterViewModel.swift index 8c3bbe1..af465ea 100644 --- a/SodaLive/Sources/Chat/Character/CharacterViewModel.swift +++ b/SodaLive/Sources/Chat/Character/CharacterViewModel.swift @@ -54,7 +54,7 @@ final class CharacterViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true @@ -62,7 +62,7 @@ final class CharacterViewModel: ObservableObject { self.isLoading = false } catch { self.isLoading = false - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -93,7 +93,7 @@ final class CharacterViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true @@ -101,11 +101,10 @@ final class CharacterViewModel: ObservableObject { self.isLoading = false } catch { self.isLoading = false - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } .store(in: &subscription) } } - diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift index 3144b5d..d6942dd 100644 --- a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift @@ -34,7 +34,7 @@ struct CharacterDetailView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { - DetailNavigationBar(title: String(localized: "캐릭터 정보")) { + DetailNavigationBar(title: I18n.Chat.Character.detailTitle) { if presentationMode.wrappedValue.isPresented { presentationMode.wrappedValue.dismiss() } else { @@ -77,7 +77,7 @@ struct CharacterDetailView: View { if let others = viewModel.characterDetail?.others, !others.isEmpty { VStack(spacing: 16) { HStack { - Text("장르의 다른 캐릭터") + Text(I18n.Chat.Character.detailOtherCharactersTitle) .appFont(size: 26, weight: .bold) .foregroundColor(.white) @@ -178,6 +178,13 @@ extension CharacterDetailView { // MARK: - Profile Section extension CharacterDetailView { + private func isMaleGender(_ gender: String) -> Bool { + let normalizedGender = gender + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalizedGender == "남성" || normalizedGender == "male" + } + private var profileSection: some View { VStack(alignment: .leading, spacing: 16) { if viewModel.characterDetail?.mbti != nil || @@ -189,7 +196,7 @@ extension CharacterDetailView { Text(viewModel.characterDetail?.translated?.gender ?? gender) .appFont(size: 14, weight: .regular) .foregroundColor( - gender == "남성" ? + isMaleGender(gender) ? Color.button : Color.mainRed ) @@ -201,7 +208,7 @@ extension CharacterDetailView { RoundedRectangle(cornerRadius: 4) .stroke(lineWidth: 1) .foregroundColor( - gender == "남성" ? + isMaleGender(gender) ? Color.button : Color.mainRed ) @@ -209,7 +216,7 @@ extension CharacterDetailView { } if let age = viewModel.characterDetail?.age { - Text("\(age)세") + Text(I18n.Chat.Character.age(age)) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "B0BEC5")) .padding(.horizontal, 7) @@ -252,7 +259,7 @@ extension CharacterDetailView { if let characterType = viewModel.characterDetail?.characterType { HStack(spacing: 8) { - Text(characterType.rawValue) + Text(characterType == .Clone ? I18n.Chat.Character.typeClone : I18n.Chat.Character.typeCharacter) .appFont(size: 12, weight: .regular) .foregroundColor(.white) .padding(.horizontal, 5) @@ -282,7 +289,7 @@ extension CharacterDetailView { private func worldViewSection(backgrounds: String) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text("[세계관 및 작품 소개]") + Text(I18n.Chat.Character.detailWorldViewTitle) .appFont(size: 18, weight: .bold) .foregroundColor(.white) @@ -300,7 +307,7 @@ extension CharacterDetailView { private func originalWorkSection(title: String, link: String) -> some View { VStack(spacing: 8) { HStack { - Text("원작") + Text(I18n.Chat.Character.detailOriginalTitle) .appFont(size: 16, weight: .bold) .fontWeight(.bold) .foregroundColor(.white) @@ -321,7 +328,7 @@ extension CharacterDetailView { UIApplication.shared.open(url) } }) { - Text("원작 보러가기") + Text(I18n.Chat.Character.detailOriginalLinkButton) .appFont(size: 16, weight: .bold) .fontWeight(.bold) .foregroundColor(Color(hex: "3BB9F1")) @@ -342,7 +349,7 @@ extension CharacterDetailView { private func personalitySection(personalities: String) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text("[성격 및 특징]") + Text(I18n.Chat.Character.detailPersonalityTitle) .appFont(size: 18, weight: .bold) .foregroundColor(.white) @@ -354,24 +361,19 @@ extension CharacterDetailView { // 캐릭터톡 대화 가이드 VStack(alignment: .leading, spacing: 16) { HStack { - Text("⚠️ 캐릭터톡 대화 가이드") + Text(I18n.Chat.Character.detailConversationGuideTitle) .appFont(size: 16, weight: .bold) .foregroundColor(Color(hex: "B0BEC5")) Spacer() } - Text(""" -보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다. -""") + Text(I18n.Chat.Character.detailConversationGuideDescription1) .appFont(size: 16, weight: .regular) .foregroundColor(Color(hex: "AEAEB2")) .multilineTextAlignment(.leading) - Text(""" -오픈월드 캐릭터톡은 캐릭터를 정교하게 설계하였지만, 대화가 어색하거나 불완전할 수도 있습니다. -대화 도중 캐릭터의 대화가 이상하거나 새로운 캐릭터로 대화를 나누고 싶다면 대화를 초기화 하고 새롭게 캐릭터와 대화를 나눠보세요. -""") + Text(I18n.Chat.Character.detailConversationGuideDescription2) .appFont(size: 16, weight: .regular) .foregroundColor(Color(hex: "AEAEB2")) .multilineTextAlignment(.leading) @@ -393,7 +395,7 @@ extension CharacterDetailView { // MARK: - Chat Button extension CharacterDetailView { private var chatButton: some View { - Text("대화하기") + Text(I18n.Chat.Character.detailChatButton) .appFont(size: 18, weight: .bold) .foregroundColor(.white) .frame(maxWidth: .infinity) @@ -450,7 +452,7 @@ struct CharacterExpandableTextView: View { .foregroundColor(Color(hex: "607D8B")) .rotationEffect(.degrees(isExpanded ? 180 : 0)) - Text(isExpanded ? "간략히" : "더보기") + Text(isExpanded ? I18n.Chat.Character.detailCollapse : I18n.Chat.Character.detailExpand) .appFont(size: 16, weight: .regular) .foregroundColor(Color(hex: "607D8B")) } diff --git a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift index 9aebc20..5298208 100644 --- a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift +++ b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift @@ -76,7 +76,7 @@ struct CharacterDetailGalleryView: View { VStack(spacing: 8) { // 상단 정보 (계산된 % 보유중, 정보 아이콘, 개수) HStack { - Text("\(viewModel.ownershipPercentage)% 보유중") + Text(I18n.Chat.Character.DetailGallery.ownership(viewModel.ownershipPercentage)) .appFont(size: 18, weight: .bold) .foregroundColor(.white) @@ -91,7 +91,7 @@ struct CharacterDetailGalleryView: View { .appFont(size: 16, weight: .regular) .foregroundColor(.white) - Text("\(viewModel.totalCount)개") + Text(I18n.Chat.Character.DetailGallery.totalCount(viewModel.totalCount)) .appFont(size: 16, weight: .regular) .foregroundColor(.white) } diff --git a/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift b/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift index 00b0005..194db8f 100644 --- a/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift +++ b/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift @@ -68,7 +68,7 @@ final class NewCharacterListViewModel: ObservableObject { } else { self?.isLoading = false } - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } receiveValue: { [weak self] response in @@ -93,7 +93,7 @@ final class NewCharacterListViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true if isLoadMore { @@ -108,7 +108,7 @@ final class NewCharacterListViewModel: ObservableObject { } else { self.isLoading = false } - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift b/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift index 0227a96..7857042 100644 --- a/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift +++ b/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift @@ -17,18 +17,18 @@ struct NewCharacterListView: View { Group { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 8) { // Toolbar - DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기")) + DetailNavigationBar(title: I18n.Chat.Character.NewList.title) VStack(alignment: .leading, spacing: 12) { // 전체 n개 HStack(spacing: 0) { - Text("전체") + Text(I18n.Chat.Character.NewList.totalPrefix) .appFont(size: 12, weight: .regular) .foregroundColor(Color(hex: "e2e2e2")) Text(" \(viewModel.totalCount)") .appFont(size: 12, weight: .regular) .foregroundColor(Color(hex: "ff5c49")) - Text("개") + Text(I18n.Chat.Character.NewList.countUnit) .appFont(size: 12, weight: .regular) .foregroundColor(Color(hex: "e2e2e2")) Spacer() diff --git a/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift b/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift index 6e31ef9..b14899c 100644 --- a/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift +++ b/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift @@ -14,8 +14,8 @@ struct RecentCharacterSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 0) { - Text("최근 대화한 캐릭터 ") + HStack(spacing: 4) { + Text(I18n.Chat.Character.recentSectionTitle) .appFont(size: 20, weight: .bold) .foregroundColor(.white) diff --git a/SodaLive/Sources/Chat/ChatTabView.swift b/SodaLive/Sources/Chat/ChatTabView.swift index c44de84..3f5ac4a 100644 --- a/SodaLive/Sources/Chat/ChatTabView.swift +++ b/SodaLive/Sources/Chat/ChatTabView.swift @@ -168,7 +168,7 @@ struct ChatTabView: View { isShowAuthView = false } .onError { _ in - AppState.shared.errorMessage = "본인인증 중 오류가 발생했습니다." + AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false } @@ -190,15 +190,14 @@ struct ChatTabView: View { if isShowAuthConfirmView { SodaDialog( - title: "본인인증", - desc: "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" + - "캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.", - confirmButtonTitle: "본인인증 하러가기", + title: I18n.Chat.Auth.dialogTitle, + desc: I18n.Chat.Auth.dialogDescription, + confirmButtonTitle: I18n.Chat.Auth.goToVerification, confirmButtonAction: { isShowAuthConfirmView = false isShowAuthView = true }, - cancelButtonTitle: "취소", + cancelButtonTitle: I18n.Common.cancel, cancelButtonAction: { isShowAuthConfirmView = false pendingAction = nil diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift index 57fd6b8..db3c4b2 100644 --- a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift @@ -58,7 +58,7 @@ struct OriginalWorkDetailHeaderView: View { } if item.isAdult { - Text("19+") + Text(I18n.Chat.Original.adultBadge) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "ff5c49")) .padding(.horizontal, 7) diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift index aaf6c4b..80f604c 100644 --- a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift @@ -151,7 +151,7 @@ struct OriginalWorkInfoView: View { ZStack { VStack(spacing: 16) { VStack(alignment: .leading, spacing: 8) { - Text("작품 소개") + Text(I18n.Chat.Original.workIntroductionTitle) .appFont(size: 16, weight: .bold) .foregroundColor(.white) @@ -170,7 +170,7 @@ struct OriginalWorkInfoView: View { .cornerRadius(16) VStack(alignment: .leading, spacing: 8) { - Text("원작 보러 가기") + Text(I18n.Chat.Original.viewOriginalLinksTitle) .appFont(size: 16, weight: .bold) .foregroundColor(Color(hex: "B0BEC5")) @@ -197,26 +197,26 @@ struct OriginalWorkInfoView: View { .cornerRadius(16) VStack(alignment: .leading, spacing: 8) { - Text("상세 정보") + Text(I18n.Chat.Original.detailInfoTitle) .appFont(size: 16, weight: .bold) .foregroundColor(.white) HStack(spacing: 16) { VStack(alignment: .leading, spacing: 8) { if let _ = response.writer { - Text("작가") + Text(I18n.Chat.Original.writerLabel) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "B0BEC5")) } if let _ = response.studio { - Text("제작사") + Text(I18n.Chat.Original.studioLabel) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "B0BEC5")) } if let _ = response.originalWork { - Text("원작") + Text(I18n.Chat.Original.originalLabel) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "B0BEC5")) } diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift index 2625a41..3f3e70e 100644 --- a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift @@ -45,7 +45,7 @@ final class OriginalWorkDetailViewModel: ObservableObject { case .failure(let error): ERROR_LOG(error.localizedDescription) self?.isLoading = false - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } receiveValue: { [weak self] response in @@ -61,14 +61,14 @@ final class OriginalWorkDetailViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true self.isLoading = false } } catch { self.isLoading = false - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift b/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift index 204dd09..4f83cc6 100644 --- a/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift +++ b/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift @@ -67,7 +67,7 @@ final class OriginalWorkViewModel: ObservableObject { } else { self?.isLoading = false } - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } receiveValue: { [weak self] response in @@ -92,7 +92,7 @@ final class OriginalWorkViewModel: ObservableObject { if let message = decoded.message { self.errorMessage = message } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError } self.isShowPopup = true if isLoadMore { @@ -107,7 +107,7 @@ final class OriginalWorkViewModel: ObservableObject { } else { self.isLoading = false } - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift index b8cb6f9..570bc3b 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift @@ -51,7 +51,11 @@ struct ChatRoomView: View { .lineLimit(1) .truncationMode(.tail) - Text(viewModel.characterType.rawValue) + Text( + viewModel.characterType == .Clone + ? I18n.Chat.Character.typeClone + : I18n.Chat.Character.typeCharacter + ) .appFont(size: 10, weight: .bold) .foregroundColor(.white) .padding(.horizontal, 4) @@ -100,8 +104,8 @@ struct ChatRoomView: View { Text( viewModel.characterType == .Character - ? "보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다." - : "AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다." + ? I18n.Chat.Room.noticeForCharacter + : I18n.Chat.Room.noticeForClone ) .appFont(size: 12, weight: .regular) .foregroundColor(.white) @@ -186,7 +190,7 @@ struct ChatRoomView: View { HStack(spacing: 0) { ZStack(alignment: .leading) { if viewModel.messageText.isEmpty { - Text("메시지를 입력하세요.") + Text(I18n.Chat.Room.messagePlaceholder) .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "78909C")) } @@ -289,7 +293,7 @@ struct ChatRoomView: View { ActivityIndicatorView() .frame(width: 100, height: 100) - Text("대화 초기화 중...") + Text(I18n.Chat.Room.resettingMessage) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift index 4db4a36..20a0297 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift @@ -19,7 +19,7 @@ final class ChatRoomViewModel: ObservableObject { @Published var chatRoomBgImageId: Int = 0 @Published private(set) var characterId: Int64 = 0 @Published private(set) var characterProfileUrl: String = "" - @Published private(set) var characterName: String = "Character Name" + @Published private(set) var characterName: String = I18n.Chat.Room.defaultCharacterName @Published private(set) var characterType: CharacterType = .Character @Published private(set) var chatRoomBgImageUrl: String? = nil @Published private(set) var roomId: Int = 0 { @@ -113,7 +113,7 @@ final class ChatRoomViewModel: ObservableObject { DEBUG_LOG("finish") case .failure(let error): self.showSendingMessage = false // 실패 시 복구 - self.errorMessage = error.localizedDescription + self.errorMessage = I18n.Common.commonError self.isShowPopup = true ERROR_LOG(error.localizedDescription) } @@ -127,14 +127,13 @@ final class ChatRoomViewModel: ObservableObject { self.messages.append(contentsOf: data.messages) self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) } else { - self.errorMessage = decoded.message ?? - "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = decoded.message ?? I18n.Common.commonError self.isShowPopup = true } self.showSendingMessage = false // 성공 시 종료 } catch { self.showSendingMessage = false - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } @@ -183,7 +182,7 @@ final class ChatRoomViewModel: ObservableObject { if let message = decoded.message { self?.errorMessage = message } else { - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError } self?.isShowPopup = true @@ -192,7 +191,7 @@ final class ChatRoomViewModel: ObservableObject { self?.isLoading = false } catch { self?.isLoading = false - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } @@ -260,7 +259,7 @@ final class ChatRoomViewModel: ObservableObject { if let message = decoded.message { self?.errorMessage = message } else { - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError } self?.isShowPopup = true @@ -269,7 +268,7 @@ final class ChatRoomViewModel: ObservableObject { self?.isLoading = false } catch { self?.isLoading = false - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } @@ -304,7 +303,7 @@ final class ChatRoomViewModel: ObservableObject { if let message = decoded.message { self?.errorMessage = message } else { - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError } self?.isShowPopup = true @@ -313,7 +312,7 @@ final class ChatRoomViewModel: ObservableObject { self?.isLoading = false } catch { self?.isLoading = false - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } @@ -348,14 +347,14 @@ final class ChatRoomViewModel: ObservableObject { if let message = decoded.message { self?.errorMessage = message } else { - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError } self?.isShowPopup = true } } catch { ERROR_LOG(String(describing: error)) - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } @@ -381,7 +380,7 @@ final class ChatRoomViewModel: ObservableObject { private func resetData() { characterProfileUrl = "" - characterName = "Character Name" + characterName = I18n.Chat.Room.defaultCharacterName characterType = .Character chatRoomBgImageUrl = nil roomId = 0 @@ -427,7 +426,7 @@ final class ChatRoomViewModel: ObservableObject { if let message = decoded.message { self?.errorMessage = message } else { - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError } self?.isShowPopup = true @@ -436,7 +435,7 @@ final class ChatRoomViewModel: ObservableObject { self?.isLoading = false } catch { self?.isLoading = false - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } diff --git a/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift index 199edbf..26b91c8 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift @@ -130,7 +130,7 @@ struct AiMessageItemView: View { .foregroundColor(.button) } - Text("눌러서 잠금해제") + Text(I18n.Chat.Room.unlockImagePrompt) .appFont(size: 18, weight: .bold) .foregroundColor(.white) } diff --git a/SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift index 19f9552..8c6eedd 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift @@ -59,7 +59,7 @@ struct TypingIndicatorItemView: View { } } } - .accessibilityLabel(Text("입력 중")) + .accessibilityLabel(Text(I18n.Chat.Room.typingAccessibilityLabel)) } .padding(.horizontal, 10) .padding(.vertical, 8) diff --git a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift index 4953d28..5353589 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift @@ -23,7 +23,7 @@ struct ChatQuotaNoticeItemView: View { .appFont(size: 18, weight: .bold) .foregroundColor(.white) - Text("기다리면 무료 이용이 가능합니다.") + Text(I18n.Chat.Room.quotaWaitForFreeNotice) .appFont(size: 18, weight: .bold) .foregroundColor(.white) } @@ -39,7 +39,7 @@ struct ChatQuotaNoticeItemView: View { .appFont(size: 24, weight: .bold) .foregroundColor(Color(hex: "263238")) - Text("(채팅 12개) 바로 대화 시작") + Text(I18n.Chat.Room.quotaPurchaseAction(chatCount: 12)) .appFont(size: 24, weight: .bold) .foregroundColor(Color(hex: "263238")) .padding(.leading, 4) diff --git a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift index c003461..2db2ba0 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift @@ -26,7 +26,7 @@ struct ChatBgSelectionView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { - DetailNavigationBar(title: String(localized: "배경 이미지 선택")) { + DetailNavigationBar(title: I18n.Chat.Room.backgroundSelectionTitle) { isShowing = false } // 갤러리 그리드 @@ -79,7 +79,7 @@ struct ChatBgSelectionView: View { } if selectedBgImageId == item.id { - Text("현재 배경") + Text(I18n.Chat.Room.currentBackground) .appFont(size: 12, weight: .regular) .foregroundColor(.white) .padding(.horizontal, 6) diff --git a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift index a80b2c4..a4f5080 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift @@ -75,14 +75,14 @@ final class ChatBgSelectionViewModel: ObservableObject { if let message = decoded.message { self?.errorMessage = message } else { - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError } self?.isShowPopup = true } } catch { ERROR_LOG(String(describing: error)) - self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.errorMessage = I18n.Common.commonError self?.isShowPopup = true } } diff --git a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift index ac63d85..4a9af15 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift @@ -17,7 +17,7 @@ struct ChatSettingsView: View { var body: some View { VStack(spacing: 0) { - DetailNavigationBar(title: String(localized: "대화 설정")) { + DetailNavigationBar(title: I18n.Chat.Room.settingsTitle) { isShowing = false } @@ -25,7 +25,7 @@ struct ChatSettingsView: View { VStack(spacing: 0) { VStack(spacing: 0) { Toggle(isOn: $isHideBg) { - Text("배경 이미지 끄기") + Text(I18n.Chat.Room.hideBackgroundImage) .appFont(size: 18, weight: .bold) .foregroundColor(Color(hex: "B0BEC5")) } @@ -42,7 +42,7 @@ struct ChatSettingsView: View { VStack(spacing: 0) { HStack { - Text("배경 이미지 변경") + Text(I18n.Chat.Room.changeBackgroundImage) .appFont(size: 18, weight: .bold) .foregroundColor(Color(hex: "B0BEC5")) .padding(.horizontal, 24) @@ -61,16 +61,16 @@ struct ChatSettingsView: View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 6) { - Text("대화 초기화") + Text(I18n.Chat.Room.resetConversationTitle) .appFont(size: 18, weight: .bold) .foregroundColor(Color(hex: "B0BEC5")) HStack(alignment: .top, spacing: 0) { - Text("⚠️ ") + Text(I18n.Chat.Room.resetWarningPrefix) .appFont(size: 16, weight: .regular) .foregroundColor(.white.opacity(0.7)) - Text("지금까지의 대화가 모두 초기화 되고, 이용자가 새로운 캐릭터가 되어 새롭게 대화를 시작합니다.") + Text(I18n.Chat.Room.resetWarningDescription) .appFont(size: 16, weight: .regular) .foregroundColor(.white.opacity(0.7)) .fixedSize(horizontal: false, vertical: true) diff --git a/SodaLive/Sources/Chat/Talk/TalkView.swift b/SodaLive/Sources/Chat/Talk/TalkView.swift index c4ac8df..d44c2e5 100644 --- a/SodaLive/Sources/Chat/Talk/TalkView.swift +++ b/SodaLive/Sources/Chat/Talk/TalkView.swift @@ -14,7 +14,7 @@ struct TalkView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { if viewModel.talkRooms.isEmpty { - Text("대화 중인 톡이 없습니다") + Text(I18n.Chat.Talk.emptyMessage) .appFont(size: 20, weight: .regular) .foregroundColor(.white) } else { diff --git a/SodaLive/Sources/Chat/Talk/TalkViewModel.swift b/SodaLive/Sources/Chat/Talk/TalkViewModel.swift index 0211ae6..2289450 100644 --- a/SodaLive/Sources/Chat/Talk/TalkViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/TalkViewModel.swift @@ -61,7 +61,7 @@ final class TalkViewModel: ObservableObject { if case let .failure(error) = completion { ERROR_LOG(error.localizedDescription) DispatchQueue.main.async { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } else { @@ -90,16 +90,15 @@ final class TalkViewModel: 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) } } - diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index ef59f96..ef3463a 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -51,6 +51,271 @@ enum I18n { ) } } + + enum Chat { + enum Auth { + static var authenticationError: String { + pick( + ko: "본인인증 중 오류가 발생했습니다.", + en: "An error occurred during identity verification.", + ja: "本人認証中にエラーが発生しました。" + ) + } + + static var dialogTitle: String { + pick(ko: "본인인증", en: "Identity verification", ja: "本人認証") + } + + static var dialogDescription: String { + pick( + ko: "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.", + en: "VoiceOn Open World Character Talk is available only to adults who complete identity verification for youth protection.\nPlease complete identity verification to use Character Talk.", + ja: "VoiceOnオープンワールドキャラクタートークは、青少年保護のため本人認証を完了した成人のみ利用できます。\nキャラクタートークを利用するには本人認証を完了してください。" + ) + } + + static var goToVerification: String { + pick(ko: "본인인증 하러가기", en: "Verify identity", ja: "本人認証へ") + } + } + + enum Character { + static var popularSectionTitle: String { + pick(ko: "인기 캐릭터", en: "Popular characters", ja: "人気キャラクター") + } + + static var newSectionTitle: String { + pick(ko: "신규 캐릭터", en: "New characters", ja: "新着キャラクター") + } + + static var recommendSectionTitle: String { + pick(ko: "추천 캐릭터", en: "Recommended characters", ja: "おすすめキャラクター") + } + + static var recentSectionTitle: String { + pick(ko: "최근 대화한 캐릭터", en: "Recently chatted characters", ja: "最近会話したキャラクター") + } + + static var newBadge: String { + pick(ko: "N", en: "N", ja: "N") + } + + static var typeCharacter: String { + pick(ko: "캐릭터", en: "Character", ja: "キャラクター") + } + + static var typeClone: String { + pick(ko: "클론", en: "Clone", ja: "クローン") + } + + static var detailTitle: String { + pick(ko: "캐릭터 정보", en: "Character info", ja: "キャラクター情報") + } + + static var detailOtherCharactersTitle: String { + pick(ko: "장르의 다른 캐릭터", en: "Other characters in this genre", ja: "同ジャンルの他キャラクター") + } + + static func age(_ age: Int) -> String { + pick(ko: "\(age)세", en: "\(age)y", ja: "\(age)歳") + } + + static var detailWorldViewTitle: String { + pick(ko: "[세계관 및 작품 소개]", en: "[Worldview & work introduction]", ja: "[世界観と作品紹介]") + } + + static var detailOriginalTitle: String { + pick(ko: "원작", en: "Original work", ja: "原作") + } + + static var detailOriginalLinkButton: String { + pick(ko: "원작 보러가기", en: "View original work", ja: "原作を見る") + } + + static var detailPersonalityTitle: String { + pick(ko: "[성격 및 특징]", en: "[Personality & traits]", ja: "[性格と特徴]") + } + + static var detailConversationGuideTitle: String { + pick(ko: "⚠️ 캐릭터톡 대화 가이드", en: "⚠️ Character Talk guide", ja: "⚠️ キャラクタートークガイド") + } + + static var detailConversationGuideDescription1: String { + pick( + ko: "보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다.", + en: "VoiceOn Open World Character Talk gives you high conversational freedom. You can become a related character in the world or a completely new persona and build your own story with the character.", + ja: "VoiceOnオープンワールドキャラクタートークは会話の自由度が高く、世界観の関連キャラクターとして会話したり、まったく新しい人物になってキャラクターとあなただけの物語を作れます。" + ) + } + + static var detailConversationGuideDescription2: String { + pick( + ko: "오픈월드 캐릭터톡은 캐릭터를 정교하게 설계하였지만, 대화가 어색하거나 불완전할 수도 있습니다.\n대화 도중 캐릭터의 대화가 이상하거나 새로운 캐릭터로 대화를 나누고 싶다면 대화를 초기화 하고 새롭게 캐릭터와 대화를 나눠보세요.", + en: "Open World Character Talk is designed carefully, but conversations may still feel awkward or incomplete.\nIf dialogue becomes unnatural or you want to chat as a new character, reset the conversation and start again.", + ja: "オープンワールドキャラクタートークは精密に設計されていますが、会話が不自然または不完全になる場合があります。\n会話中に不自然さを感じた場合や新しいキャラクターで会話したい場合は、会話を初期化して新しく始めてください。" + ) + } + + static var detailChatButton: String { + pick(ko: "대화하기", en: "Start chat", ja: "会話する") + } + + static var detailCollapse: String { + pick(ko: "간략히", en: "Collapse", ja: "簡略表示") + } + + static var detailExpand: String { + pick(ko: "더보기", en: "More", ja: "もっと見る") + } + + enum NewList { + static var title: String { + pick(ko: "신규 캐릭터 전체보기", en: "All new characters", ja: "新着キャラクター一覧") + } + + static var totalPrefix: String { + pick(ko: "전체", en: "Total", ja: "全体") + } + + static var countUnit: String { + pick(ko: "개", en: "", ja: "件") + } + } + + enum DetailGallery { + static func ownership(_ percentage: Int) -> String { + pick(ko: "\(percentage)% 보유중", en: "\(percentage)% owned", ja: "\(percentage)%保有中") + } + + static func totalCount(_ count: Int) -> String { + pick(ko: "\(count)개", en: "\(count)", ja: "\(count)件") + } + } + } + + enum Original { + static var adultBadge: String { + pick(ko: "19+", en: "19+", ja: "19+") + } + + static var workIntroductionTitle: String { + pick(ko: "작품 소개", en: "Work introduction", ja: "作品紹介") + } + + static var viewOriginalLinksTitle: String { + pick(ko: "원작 보러 가기", en: "View original links", ja: "原作リンクを見る") + } + + static var detailInfoTitle: String { + pick(ko: "상세 정보", en: "Details", ja: "詳細情報") + } + + static var writerLabel: String { + pick(ko: "작가", en: "Writer", ja: "作家") + } + + static var studioLabel: String { + pick(ko: "제작사", en: "Studio", ja: "制作会社") + } + + static var originalLabel: String { + pick(ko: "원작", en: "Original work", ja: "原作") + } + } + + enum Talk { + static var emptyMessage: String { + pick(ko: "대화 중인 톡이 없습니다", en: "No active chats", ja: "進行中のトークがありません") + } + } + + enum Room { + static var noticeForCharacter: String { + pick( + ko: "보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다.", + en: "VoiceOn AI Character Talk offers a high degree of conversational freedom.\nTalk as a character in the world or as a new persona and create your own story with the character.\n※ AI Character Talk is in open beta, so responses may be awkward or incomplete.", + ja: "VoiceOn AIキャラクタートークは会話の自由度が高く、会話に参加するあなたは誰にでもなれます。\n世界観のキャラクターとして会話したり、新しい人物としてキャラクターとあなただけの物語を作ってみてください。\n※ AIキャラクタートークはオープンベータ中のため、会話が不自然または不完全な場合があります。" + ) + } + + static var noticeForClone: String { + pick( + ko: "AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다.", + en: "AI Clone chats based on creator information, but may not perfectly reflect all details or match real conversations.", + ja: "AI Cloneはクリエイター情報をもとに会話しますが、すべての情報を完全に反映したり実際の会話と一致しない場合があります。" + ) + } + + static var messagePlaceholder: String { + pick(ko: "메시지를 입력하세요.", en: "Enter a message.", ja: "メッセージを入力してください。") + } + + static var resettingMessage: String { + pick(ko: "대화 초기화 중...", en: "Resetting conversation...", ja: "会話を初期化中...") + } + + static var unlockImagePrompt: String { + pick(ko: "눌러서 잠금해제", en: "Tap to unlock", ja: "タップして解除") + } + + static var typingAccessibilityLabel: String { + pick(ko: "입력 중", en: "Typing", ja: "入力中") + } + + static var quotaWaitForFreeNotice: String { + pick(ko: "기다리면 무료 이용이 가능합니다.", en: "Wait for free usage.", ja: "待てば無料で利用できます。") + } + + static func quotaPurchaseAction(chatCount: Int) -> String { + pick( + ko: "(채팅 \(chatCount)개) 바로 대화 시작", + en: "(\(chatCount) chats) Start now", + ja: "(チャット\(chatCount)件) すぐに会話開始" + ) + } + + static var backgroundSelectionTitle: String { + pick(ko: "배경 이미지 선택", en: "Select background image", ja: "背景画像を選択") + } + + static var currentBackground: String { + pick(ko: "현재 배경", en: "Current background", ja: "現在の背景") + } + + static var settingsTitle: String { + pick(ko: "대화 설정", en: "Chat settings", ja: "会話設定") + } + + static var hideBackgroundImage: String { + pick(ko: "배경 이미지 끄기", en: "Hide background image", ja: "背景画像をオフ") + } + + static var changeBackgroundImage: String { + pick(ko: "배경 이미지 변경", en: "Change background image", ja: "背景画像を変更") + } + + static var resetConversationTitle: String { + pick(ko: "대화 초기화", en: "Reset conversation", ja: "会話を初期化") + } + + static var resetWarningPrefix: String { + pick(ko: "⚠️ ", en: "⚠️ ", ja: "⚠️ ") + } + + static var resetWarningDescription: String { + pick( + ko: "지금까지의 대화가 모두 초기화 되고, 이용자가 새로운 캐릭터가 되어 새롭게 대화를 시작합니다.", + en: "All previous messages are reset, and you start a new conversation as a new character.", + ja: "これまでの会話はすべて初期化され、利用者が新しいキャラクターとなって新しく会話を始めます。" + ) + } + + static var defaultCharacterName: String { + pick(ko: "캐릭터", en: "Character", ja: "キャラクター") + } + } + } + // 검색 관련 문자열 enum Search { // 검색 입력 플레이스홀더: 2글자 이상 안내 diff --git a/docs/20260331_Chat모듈_I18n전환계획.md b/docs/20260331_Chat모듈_I18n전환계획.md new file mode 100644 index 0000000..a774aca --- /dev/null +++ b/docs/20260331_Chat모듈_I18n전환계획.md @@ -0,0 +1,41 @@ +# 20260331 Chat 모듈 I18n 전환 계획 + +## 작업 체크리스트 +- [x] Chat 모듈 28개 파일의 사용자 노출 하드코딩 문자열 전수 스캔 +- [x] `I18n.swift`에 `I18n.Chat` 네임스페이스 키 보강 +- [x] Chat 모듈 28개 파일 호출부를 `I18n.Chat.*`로 치환 +- [x] 문서(`20260331_하드코딩텍스트_I18n통일계획.md`) Chat 섹션 체크박스 반영 +- [x] 검증 수행: LSP 진단, `SodaLive`/`SodaLive-dev` Debug 빌드, 테스트 액션 확인 + +## 수용 기준 +- [x] Chat 모듈 대상 파일에서 사용자 노출 하드코딩 문자열이 `I18n.*` 참조로 전환된다. +- [x] 신규 키는 역할 중심 네이밍을 따르고 `I18n.Chat` 계층에 배치된다. +- [x] `SodaLive` 및 `SodaLive-dev` Debug 빌드가 성공한다. +- [x] 테스트 스킴 제약 여부를 포함해 실행 결과가 문서 하단 검증 기록에 남는다. + +## 검증 기록 +### Chat 모듈 구현/검증 (2026-03-31) +- 무엇/왜/어떻게: + - 무엇: Chat 대상 28개 파일을 기준으로 사용자 노출 하드코딩 문구를 `I18n.Chat.*` 및 `I18n.Common.commonError`로 전환. + - 왜: Chat 영역에서 `String(localized:)`/직접 리터럴/반복 오류 문구가 혼재되어 언어 일관성과 유지보수성이 저하되어 있었음. + - 어떻게: explore/librarian/oracle 병렬 조사 + `grep`/`ast_grep_search`/`rg`(미설치 확인) 직접 검증을 병행해 런타임 문구만 치환하고 Preview 샘플은 예외로 유지. +- 실행 명령/도구: + - `task(subagent_type="explore", ...)` x2 (`bg_c33457a5`, `bg_e543550a`) + - `task(subagent_type="librarian", ...)` x2 (`bg_47a108d5`, `bg_91c00954`) + - `task(subagent_type="oracle", ...)` x1 (`bg_a6465165`) + - `grep("\"[^\"]*[가-힣][^\"]*\"", include=*.swift, path=SodaLive/Sources/Chat)` + - `ast_grep_search(pattern="Text(\"$TEXT\")", lang=swift, paths=[SodaLive/Sources/Chat])` + - `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" -configuration Debug build` (Oracle 피드백 반영 후 재검증) + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` (Oracle 피드백 반영 후 재검증) + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: + - `I18n.swift`에 `I18n.Chat`(Auth/Character/Original/Talk/Room) 키셋 추가. + - Chat 호출부 24개 파일 실치환 + Preview/비노출(샘플 데이터 등) 4개 파일 예외 유지로 28개 전수 처리 완료. + - Chat 모듈의 `String(localized:)` 직접 참조 제거 확인. + - Oracle 후속 보정: Bootpay 입력값(`payload.pg`/`payload.method`/`payload.orderName`)을 고정값으로 복원, `characterType.rawValue` 직접 출력 제거, 전송 실패 시 `error.localizedDescription` 사용자 노출 제거(`I18n.Common.commonError`), 최근 대화 헤더 trailing space 제거. + - 하드코딩 한글 재검증 결과, 남은 문자열은 Preview 샘플/SDK 입력값/비노출 분기 로직만 존재. + - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 `** BUILD SUCCEEDED **`. + - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.` (스킴 제약, 코드 실패 아님). diff --git a/docs/20260331_하드코딩텍스트_I18n통일계획.md b/docs/20260331_하드코딩텍스트_I18n통일계획.md index b3f3486..6283107 100644 --- a/docs/20260331_하드코딩텍스트_I18n통일계획.md +++ b/docs/20260331_하드코딩텍스트_I18n통일계획.md @@ -110,34 +110,34 @@ - [x] `SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift` ### Chat (28) -- [ ] `SodaLive/Sources/Chat/Character/CharacterItemView.swift` -- [ ] `SodaLive/Sources/Chat/Character/CharacterSectionView.swift` -- [ ] `SodaLive/Sources/Chat/Character/CharacterView.swift` -- [ ] `SodaLive/Sources/Chat/Character/CharacterViewModel.swift` -- [ ] `SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift` -- [ ] `SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift` -- [ ] `SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift` -- [ ] `SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift` -- [ ] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift` -- [ ] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift` -- [ ] `SodaLive/Sources/Chat/ChatTabView.swift` -- [ ] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift` -- [ ] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift` -- [ ] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift` -- [ ] `SodaLive/Sources/Chat/Original/OriginalTabItemView.swift` -- [ ] `SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Message/UserMessageItemView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift` -- [ ] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/TalkItemView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/TalkView.swift` -- [ ] `SodaLive/Sources/Chat/Talk/TalkViewModel.swift` +- [x] `SodaLive/Sources/Chat/Character/CharacterItemView.swift` +- [x] `SodaLive/Sources/Chat/Character/CharacterSectionView.swift` +- [x] `SodaLive/Sources/Chat/Character/CharacterView.swift` +- [x] `SodaLive/Sources/Chat/Character/CharacterViewModel.swift` +- [x] `SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift` +- [x] `SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift` +- [x] `SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift` +- [x] `SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift` +- [x] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift` +- [x] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift` +- [x] `SodaLive/Sources/Chat/ChatTabView.swift` +- [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift` +- [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift` +- [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift` +- [x] `SodaLive/Sources/Chat/Original/OriginalTabItemView.swift` +- [x] `SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Message/UserMessageItemView.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift` +- [x] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift` +- [x] `SodaLive/Sources/Chat/Talk/TalkItemView.swift` +- [x] `SodaLive/Sources/Chat/Talk/TalkView.swift` +- [x] `SodaLive/Sources/Chat/Talk/TalkViewModel.swift` ### Content (78) - [ ] `SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift` @@ -559,3 +559,31 @@ - Audition 모듈 하드코딩 한글 재검증 결과, 남은 문자열은 Preview 샘플/DEBUG_LOG/서버 메시지 분기 비교(비노출 로직)만 존재. - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약). + +### 7차 구현 (Chat 모듈 28개 i18n 전환, 2026-03-31) +- 무엇/왜/어떻게: + - 무엇: 변경 대상 목록의 `Chat` 모듈 28개 파일을 전수 처리해 사용자 노출 하드코딩 문구를 `I18n.*` 참조로 교체. + - 왜: Chat 영역에 `String(localized:)` 직접 참조, 뷰 리터럴 문구, ViewModel 반복 오류 문구가 혼재되어 다국어 일관성이 깨져 있었기 때문. + - 어떻게: explore/librarian/oracle + `grep`/`ast_grep_search`/`rg`(미설치 확인) 병렬 탐색으로 런타임 노출 문자열을 추출하고, `I18n.swift`에 `I18n.Chat` 네임스페이스를 추가한 뒤 호출부를 치환. +- 실행 명령/도구: + - `task(subagent_type="explore", ...)` x2 (`bg_c33457a5`, `bg_e543550a`) + - `task(subagent_type="librarian", ...)` x2 (`bg_47a108d5`, `bg_91c00954`) + - `task(subagent_type="oracle", ...)` x1 (`bg_a6465165`) + - `grep("\"[^\"]*[가-힣][^\"]*\"", include=*.swift, path=SodaLive/Sources/Chat)` + - `grep("String\\(localized:|LocalizedStringKey\\(|NSLocalizedString\\(", include=*.swift, path=SodaLive/Sources/Chat)` + - `ast_grep_search(pattern="Text(\"$TEXT\")", lang=swift, paths=[SodaLive/Sources/Chat])` + - `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" -configuration Debug build` (Oracle 후속 보정 후 재검증) + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` (Oracle 후속 보정 후 재검증) + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: + - `I18n.swift`에 `I18n.Chat`(Auth/Character/Original/Talk/Room) 키셋 추가. + - Chat 섹션 28개 파일 체크박스 전체 완료 처리. + - 실치환 24개 파일 + Preview/비노출 예외 4개 파일(샘플 데이터 등)로 전수 처리 완료. + - Chat 모듈의 `String(localized:)` 직접 참조 제거 확인. + - Oracle 후속 보정: Bootpay 입력값(`payload.pg`/`payload.method`/`payload.orderName`) 고정값 복원, `characterType.rawValue` 직접 출력 제거, 전송 실패 시 `error.localizedDescription` 사용자 노출 제거(`I18n.Common.commonError`), 최근 대화 헤더 trailing space 제거. + - Chat 모듈 하드코딩 한글 재검증 결과, 남은 문자열은 Preview 샘플/SDK 입력값/비노출 분기 로직만 존재. + - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). + - 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약).