feat(chat): 채팅 모듈 하드코딩 문구를 I18n 키로 통일한다

This commit is contained in:
Yu Sung
2026-03-31 16:30:48 +09:00
parent 222520d5e9
commit 47085dc1ca
27 changed files with 464 additions and 128 deletions

View File

@@ -33,7 +33,7 @@ struct CharacterItemView: View {
HStack { HStack {
Spacer() Spacer()
Text("N") Text(I18n.Chat.Character.newBadge)
.appFont(size: 18, weight: .regular) .appFont(size: 18, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: 30, height: 30) .frame(width: 30, height: 30)

View File

@@ -8,7 +8,7 @@
import SwiftUI import SwiftUI
struct CharacterSectionView: View { struct CharacterSectionView: View {
let title: LocalizedStringResource let title: String
let items: [Character] let items: [Character]
let isShowRank: Bool let isShowRank: Bool
var trailingTitle: String? = nil var trailingTitle: String? = nil
@@ -52,7 +52,7 @@ struct CharacterSectionView: View {
#Preview { #Preview {
CharacterSectionView( CharacterSectionView(
title: "신규 캐릭터", title: I18n.Chat.Character.newSectionTitle,
items: [ items: [
Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300", isNew: true), Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300", isNew: true),
Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300", isNew: false) Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300", isNew: false)

View File

@@ -39,7 +39,7 @@ struct CharacterView: View {
// //
if !viewModel.popularCharacters.isEmpty { if !viewModel.popularCharacters.isEmpty {
CharacterSectionView( CharacterSectionView(
title: "인기 캐릭터", title: I18n.Chat.Character.popularSectionTitle,
items: viewModel.popularCharacters, items: viewModel.popularCharacters,
isShowRank: true, isShowRank: true,
onTap: { ch in onTap: { ch in
@@ -51,7 +51,7 @@ struct CharacterView: View {
// //
if !viewModel.newCharacters.isEmpty { if !viewModel.newCharacters.isEmpty {
CharacterSectionView( CharacterSectionView(
title: "신규 캐릭터", title: I18n.Chat.Character.newSectionTitle,
items: viewModel.newCharacters, items: viewModel.newCharacters,
isShowRank: false, isShowRank: false,
trailingTitle: I18n.Common.viewAll, trailingTitle: I18n.Common.viewAll,
@@ -67,7 +67,7 @@ struct CharacterView: View {
if !viewModel.recommendCharacters.isEmpty { if !viewModel.recommendCharacters.isEmpty {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("추천 캐릭터") Text(I18n.Chat.Character.recommendSectionTitle)
.appFont(size: 24, weight: .bold) .appFont(size: 24, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)

View File

@@ -54,7 +54,7 @@ final class CharacterViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
@@ -62,7 +62,7 @@ final class CharacterViewModel: ObservableObject {
self.isLoading = false self.isLoading = false
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -93,7 +93,7 @@ final class CharacterViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
@@ -101,11 +101,10 @@ final class CharacterViewModel: ObservableObject {
self.isLoading = false self.isLoading = false
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} }
} }

View File

@@ -34,7 +34,7 @@ struct CharacterDetailView: View {
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "캐릭터 정보")) { DetailNavigationBar(title: I18n.Chat.Character.detailTitle) {
if presentationMode.wrappedValue.isPresented { if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
} else { } else {
@@ -77,7 +77,7 @@ struct CharacterDetailView: View {
if let others = viewModel.characterDetail?.others, !others.isEmpty { if let others = viewModel.characterDetail?.others, !others.isEmpty {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack { HStack {
Text("장르의 다른 캐릭터") Text(I18n.Chat.Character.detailOtherCharactersTitle)
.appFont(size: 26, weight: .bold) .appFont(size: 26, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -178,6 +178,13 @@ extension CharacterDetailView {
// MARK: - Profile Section // MARK: - Profile Section
extension CharacterDetailView { extension CharacterDetailView {
private func isMaleGender(_ gender: String) -> Bool {
let normalizedGender = gender
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalizedGender == "남성" || normalizedGender == "male"
}
private var profileSection: some View { private var profileSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if viewModel.characterDetail?.mbti != nil || if viewModel.characterDetail?.mbti != nil ||
@@ -189,7 +196,7 @@ extension CharacterDetailView {
Text(viewModel.characterDetail?.translated?.gender ?? gender) Text(viewModel.characterDetail?.translated?.gender ?? gender)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor( .foregroundColor(
gender == "남성" ? isMaleGender(gender) ?
Color.button : Color.button :
Color.mainRed Color.mainRed
) )
@@ -201,7 +208,7 @@ extension CharacterDetailView {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.stroke(lineWidth: 1) .stroke(lineWidth: 1)
.foregroundColor( .foregroundColor(
gender == "남성" ? isMaleGender(gender) ?
Color.button : Color.button :
Color.mainRed Color.mainRed
) )
@@ -209,7 +216,7 @@ extension CharacterDetailView {
} }
if let age = viewModel.characterDetail?.age { if let age = viewModel.characterDetail?.age {
Text("\(age)") Text(I18n.Chat.Character.age(age))
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
.padding(.horizontal, 7) .padding(.horizontal, 7)
@@ -252,7 +259,7 @@ extension CharacterDetailView {
if let characterType = viewModel.characterDetail?.characterType { if let characterType = viewModel.characterDetail?.characterType {
HStack(spacing: 8) { HStack(spacing: 8) {
Text(characterType.rawValue) Text(characterType == .Clone ? I18n.Chat.Character.typeClone : I18n.Chat.Character.typeCharacter)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 5) .padding(.horizontal, 5)
@@ -282,7 +289,7 @@ extension CharacterDetailView {
private func worldViewSection(backgrounds: String) -> some View { private func worldViewSection(backgrounds: String) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("[세계관 및 작품 소개]") Text(I18n.Chat.Character.detailWorldViewTitle)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -300,7 +307,7 @@ extension CharacterDetailView {
private func originalWorkSection(title: String, link: String) -> some View { private func originalWorkSection(title: String, link: String) -> some View {
VStack(spacing: 8) { VStack(spacing: 8) {
HStack { HStack {
Text("원작") Text(I18n.Chat.Character.detailOriginalTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -321,7 +328,7 @@ extension CharacterDetailView {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
}) { }) {
Text("원작 보러가기") Text(I18n.Chat.Character.detailOriginalLinkButton)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(Color(hex: "3BB9F1")) .foregroundColor(Color(hex: "3BB9F1"))
@@ -342,7 +349,7 @@ extension CharacterDetailView {
private func personalitySection(personalities: String) -> some View { private func personalitySection(personalities: String) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("[성격 및 특징]") Text(I18n.Chat.Character.detailPersonalityTitle)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -354,24 +361,19 @@ extension CharacterDetailView {
// //
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("⚠️ 캐릭터톡 대화 가이드") Text(I18n.Chat.Character.detailConversationGuideTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
Spacer() Spacer()
} }
Text(""" Text(I18n.Chat.Character.detailConversationGuideDescription1)
보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다.
""")
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "AEAEB2")) .foregroundColor(Color(hex: "AEAEB2"))
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
Text(""" Text(I18n.Chat.Character.detailConversationGuideDescription2)
오픈월드 캐릭터톡은 캐릭터를 정교하게 설계하였지만, 대화가 어색하거나 불완전할 수도 있습니다.
대화 도중 캐릭터의 대화가 이상하거나 새로운 캐릭터로 대화를 나누고 싶다면 대화를 초기화 하고 새롭게 캐릭터와 대화를 나눠보세요.
""")
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "AEAEB2")) .foregroundColor(Color(hex: "AEAEB2"))
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@@ -393,7 +395,7 @@ extension CharacterDetailView {
// MARK: - Chat Button // MARK: - Chat Button
extension CharacterDetailView { extension CharacterDetailView {
private var chatButton: some View { private var chatButton: some View {
Text("대화하기") Text(I18n.Chat.Character.detailChatButton)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -450,7 +452,7 @@ struct CharacterExpandableTextView: View {
.foregroundColor(Color(hex: "607D8B")) .foregroundColor(Color(hex: "607D8B"))
.rotationEffect(.degrees(isExpanded ? 180 : 0)) .rotationEffect(.degrees(isExpanded ? 180 : 0))
Text(isExpanded ? "간략히" : "더보기") Text(isExpanded ? I18n.Chat.Character.detailCollapse : I18n.Chat.Character.detailExpand)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "607D8B")) .foregroundColor(Color(hex: "607D8B"))
} }

View File

@@ -76,7 +76,7 @@ struct CharacterDetailGalleryView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
// ( % , , ) // ( % , , )
HStack { HStack {
Text("\(viewModel.ownershipPercentage)% 보유중") Text(I18n.Chat.Character.DetailGallery.ownership(viewModel.ownershipPercentage))
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -91,7 +91,7 @@ struct CharacterDetailGalleryView: View {
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
Text("\(viewModel.totalCount)") Text(I18n.Chat.Character.DetailGallery.totalCount(viewModel.totalCount))
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
} }

View File

@@ -68,7 +68,7 @@ final class NewCharacterListViewModel: ObservableObject {
} else { } else {
self?.isLoading = false self?.isLoading = false
} }
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
@@ -93,7 +93,7 @@ final class NewCharacterListViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
if isLoadMore { if isLoadMore {
@@ -108,7 +108,7 @@ final class NewCharacterListViewModel: ObservableObject {
} else { } else {
self.isLoading = false self.isLoading = false
} }
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -17,18 +17,18 @@ struct NewCharacterListView: View {
Group { BaseView(isLoading: $viewModel.isLoading) { Group { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 8) { VStack(spacing: 8) {
// Toolbar // Toolbar
DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기")) DetailNavigationBar(title: I18n.Chat.Character.NewList.title)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
// n // n
HStack(spacing: 0) { HStack(spacing: 0) {
Text("전체") Text(I18n.Chat.Character.NewList.totalPrefix)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
Text(" \(viewModel.totalCount)") Text(" \(viewModel.totalCount)")
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
Text("") Text(I18n.Chat.Character.NewList.countUnit)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
Spacer() Spacer()

View File

@@ -14,8 +14,8 @@ struct RecentCharacterSectionView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) { HStack(spacing: 4) {
Text("최근 대화한 캐릭터 ") Text(I18n.Chat.Character.recentSectionTitle)
.appFont(size: 20, weight: .bold) .appFont(size: 20, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)

View File

@@ -168,7 +168,7 @@ struct ChatTabView: View {
isShowAuthView = false isShowAuthView = false
} }
.onError { _ in .onError { _ in
AppState.shared.errorMessage = "본인인증 중 오류가 발생했습니다." AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError
AppState.shared.isShowErrorPopup = true AppState.shared.isShowErrorPopup = true
isShowAuthView = false isShowAuthView = false
} }
@@ -190,15 +190,14 @@ struct ChatTabView: View {
if isShowAuthConfirmView { if isShowAuthConfirmView {
SodaDialog( SodaDialog(
title: "본인인증", title: I18n.Chat.Auth.dialogTitle,
desc: "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" + desc: I18n.Chat.Auth.dialogDescription,
"캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.", confirmButtonTitle: I18n.Chat.Auth.goToVerification,
confirmButtonTitle: "본인인증 하러가기",
confirmButtonAction: { confirmButtonAction: {
isShowAuthConfirmView = false isShowAuthConfirmView = false
isShowAuthView = true isShowAuthView = true
}, },
cancelButtonTitle: "취소", cancelButtonTitle: I18n.Common.cancel,
cancelButtonAction: { cancelButtonAction: {
isShowAuthConfirmView = false isShowAuthConfirmView = false
pendingAction = nil pendingAction = nil

View File

@@ -58,7 +58,7 @@ struct OriginalWorkDetailHeaderView: View {
} }
if item.isAdult { if item.isAdult {
Text("19+") Text(I18n.Chat.Original.adultBadge)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
.padding(.horizontal, 7) .padding(.horizontal, 7)

View File

@@ -151,7 +151,7 @@ struct OriginalWorkInfoView: View {
ZStack { ZStack {
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("작품 소개") Text(I18n.Chat.Original.workIntroductionTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -170,7 +170,7 @@ struct OriginalWorkInfoView: View {
.cornerRadius(16) .cornerRadius(16)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("원작 보러 가기") Text(I18n.Chat.Original.viewOriginalLinksTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
@@ -197,26 +197,26 @@ struct OriginalWorkInfoView: View {
.cornerRadius(16) .cornerRadius(16)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("상세 정보") Text(I18n.Chat.Original.detailInfoTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
HStack(spacing: 16) { HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let _ = response.writer { if let _ = response.writer {
Text("작가") Text(I18n.Chat.Original.writerLabel)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }
if let _ = response.studio { if let _ = response.studio {
Text("제작사") Text(I18n.Chat.Original.studioLabel)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }
if let _ = response.originalWork { if let _ = response.originalWork {
Text("원작") Text(I18n.Chat.Original.originalLabel)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }

View File

@@ -45,7 +45,7 @@ final class OriginalWorkDetailViewModel: ObservableObject {
case .failure(let error): case .failure(let error):
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
@@ -61,14 +61,14 @@ final class OriginalWorkDetailViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
self.isLoading = false self.isLoading = false
} }
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -67,7 +67,7 @@ final class OriginalWorkViewModel: ObservableObject {
} else { } else {
self?.isLoading = false self?.isLoading = false
} }
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
@@ -92,7 +92,7 @@ final class OriginalWorkViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
if isLoadMore { if isLoadMore {
@@ -107,7 +107,7 @@ final class OriginalWorkViewModel: ObservableObject {
} else { } else {
self.isLoading = false self.isLoading = false
} }
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -51,7 +51,11 @@ struct ChatRoomView: View {
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
Text(viewModel.characterType.rawValue) Text(
viewModel.characterType == .Clone
? I18n.Chat.Character.typeClone
: I18n.Chat.Character.typeCharacter
)
.appFont(size: 10, weight: .bold) .appFont(size: 10, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 4) .padding(.horizontal, 4)
@@ -100,8 +104,8 @@ struct ChatRoomView: View {
Text( Text(
viewModel.characterType == .Character viewModel.characterType == .Character
? "보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다." ? I18n.Chat.Room.noticeForCharacter
: "AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다." : I18n.Chat.Room.noticeForClone
) )
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
@@ -186,7 +190,7 @@ struct ChatRoomView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
if viewModel.messageText.isEmpty { if viewModel.messageText.isEmpty {
Text("메시지를 입력하세요.") Text(I18n.Chat.Room.messagePlaceholder)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C")) .foregroundColor(Color(hex: "78909C"))
} }
@@ -289,7 +293,7 @@ struct ChatRoomView: View {
ActivityIndicatorView() ActivityIndicatorView()
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
Text("대화 초기화 중...") Text(I18n.Chat.Room.resettingMessage)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)

View File

@@ -19,7 +19,7 @@ final class ChatRoomViewModel: ObservableObject {
@Published var chatRoomBgImageId: Int = 0 @Published var chatRoomBgImageId: Int = 0
@Published private(set) var characterId: Int64 = 0 @Published private(set) var characterId: Int64 = 0
@Published private(set) var characterProfileUrl: String = "" @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 characterType: CharacterType = .Character
@Published private(set) var chatRoomBgImageUrl: String? = nil @Published private(set) var chatRoomBgImageUrl: String? = nil
@Published private(set) var roomId: Int = 0 { @Published private(set) var roomId: Int = 0 {
@@ -113,7 +113,7 @@ final class ChatRoomViewModel: ObservableObject {
DEBUG_LOG("finish") DEBUG_LOG("finish")
case .failure(let error): case .failure(let error):
self.showSendingMessage = false // self.showSendingMessage = false //
self.errorMessage = error.localizedDescription self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
} }
@@ -127,14 +127,13 @@ final class ChatRoomViewModel: ObservableObject {
self.messages.append(contentsOf: data.messages) self.messages.append(contentsOf: data.messages)
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
} else { } else {
self.errorMessage = decoded.message ?? self.errorMessage = decoded.message ?? I18n.Common.commonError
"다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true self.isShowPopup = true
} }
self.showSendingMessage = false // self.showSendingMessage = false //
} catch { } catch {
self.showSendingMessage = false self.showSendingMessage = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -183,7 +182,7 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -192,7 +191,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -260,7 +259,7 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -269,7 +268,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -304,7 +303,7 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -313,7 +312,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -348,14 +347,14 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
} }
} catch { } catch {
ERROR_LOG(String(describing: error)) ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -381,7 +380,7 @@ final class ChatRoomViewModel: ObservableObject {
private func resetData() { private func resetData() {
characterProfileUrl = "" characterProfileUrl = ""
characterName = "Character Name" characterName = I18n.Chat.Room.defaultCharacterName
characterType = .Character characterType = .Character
chatRoomBgImageUrl = nil chatRoomBgImageUrl = nil
roomId = 0 roomId = 0
@@ -427,7 +426,7 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -436,7 +435,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }

View File

@@ -130,7 +130,7 @@ struct AiMessageItemView: View {
.foregroundColor(.button) .foregroundColor(.button)
} }
Text("눌러서 잠금해제") Text(I18n.Chat.Room.unlockImagePrompt)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
} }

View File

@@ -59,7 +59,7 @@ struct TypingIndicatorItemView: View {
} }
} }
} }
.accessibilityLabel(Text("입력 중")) .accessibilityLabel(Text(I18n.Chat.Room.typingAccessibilityLabel))
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)

View File

@@ -23,7 +23,7 @@ struct ChatQuotaNoticeItemView: View {
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
Text("기다리면 무료 이용이 가능합니다.") Text(I18n.Chat.Room.quotaWaitForFreeNotice)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
} }
@@ -39,7 +39,7 @@ struct ChatQuotaNoticeItemView: View {
.appFont(size: 24, weight: .bold) .appFont(size: 24, weight: .bold)
.foregroundColor(Color(hex: "263238")) .foregroundColor(Color(hex: "263238"))
Text("(채팅 12개) 바로 대화 시작") Text(I18n.Chat.Room.quotaPurchaseAction(chatCount: 12))
.appFont(size: 24, weight: .bold) .appFont(size: 24, weight: .bold)
.foregroundColor(Color(hex: "263238")) .foregroundColor(Color(hex: "263238"))
.padding(.leading, 4) .padding(.leading, 4)

View File

@@ -26,7 +26,7 @@ struct ChatBgSelectionView: View {
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "배경 이미지 선택")) { DetailNavigationBar(title: I18n.Chat.Room.backgroundSelectionTitle) {
isShowing = false isShowing = false
} }
// //
@@ -79,7 +79,7 @@ struct ChatBgSelectionView: View {
} }
if selectedBgImageId == item.id { if selectedBgImageId == item.id {
Text("현재 배경") Text(I18n.Chat.Room.currentBackground)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 6) .padding(.horizontal, 6)

View File

@@ -75,14 +75,14 @@ final class ChatBgSelectionViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
} }
} catch { } catch {
ERROR_LOG(String(describing: error)) ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }

View File

@@ -17,7 +17,7 @@ struct ChatSettingsView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "대화 설정")) { DetailNavigationBar(title: I18n.Chat.Room.settingsTitle) {
isShowing = false isShowing = false
} }
@@ -25,7 +25,7 @@ struct ChatSettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
VStack(spacing: 0) { VStack(spacing: 0) {
Toggle(isOn: $isHideBg) { Toggle(isOn: $isHideBg) {
Text("배경 이미지 끄기") Text(I18n.Chat.Room.hideBackgroundImage)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }
@@ -42,7 +42,7 @@ struct ChatSettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text("배경 이미지 변경") Text(I18n.Chat.Room.changeBackgroundImage)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
.padding(.horizontal, 24) .padding(.horizontal, 24)
@@ -61,16 +61,16 @@ struct ChatSettingsView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("대화 초기화") Text(I18n.Chat.Room.resetConversationTitle)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
Text("⚠️ ") Text(I18n.Chat.Room.resetWarningPrefix)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
Text("지금까지의 대화가 모두 초기화 되고, 이용자가 새로운 캐릭터가 되어 새롭게 대화를 시작합니다.") Text(I18n.Chat.Room.resetWarningDescription)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

View File

@@ -14,7 +14,7 @@ struct TalkView: View {
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
if viewModel.talkRooms.isEmpty { if viewModel.talkRooms.isEmpty {
Text("대화 중인 톡이 없습니다") Text(I18n.Chat.Talk.emptyMessage)
.appFont(size: 20, weight: .regular) .appFont(size: 20, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
} else { } else {

View File

@@ -61,7 +61,7 @@ final class TalkViewModel: ObservableObject {
if case let .failure(error) = completion { if case let .failure(error) = completion {
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
DispatchQueue.main.async { DispatchQueue.main.async {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} else { } else {
@@ -90,16 +90,15 @@ final class TalkViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} }
} }

View File

@@ -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 { enum Search {
// : 2 // : 2

View File

@@ -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.` (스킴 제약, 코드 실패 아님).

View File

@@ -110,34 +110,34 @@
- [x] `SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift` - [x] `SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift`
### Chat (28) ### Chat (28)
- [ ] `SodaLive/Sources/Chat/Character/CharacterItemView.swift` - [x] `SodaLive/Sources/Chat/Character/CharacterItemView.swift`
- [ ] `SodaLive/Sources/Chat/Character/CharacterSectionView.swift` - [x] `SodaLive/Sources/Chat/Character/CharacterSectionView.swift`
- [ ] `SodaLive/Sources/Chat/Character/CharacterView.swift` - [x] `SodaLive/Sources/Chat/Character/CharacterView.swift`
- [ ] `SodaLive/Sources/Chat/Character/CharacterViewModel.swift` - [x] `SodaLive/Sources/Chat/Character/CharacterViewModel.swift`
- [ ] `SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift` - [x] `SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift`
- [ ] `SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift` - [x] `SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift`
- [ ] `SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift` - [x] `SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift`
- [ ] `SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift` - [x] `SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift`
- [ ] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift` - [x] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift`
- [ ] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift` - [x] `SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift`
- [ ] `SodaLive/Sources/Chat/ChatTabView.swift` - [x] `SodaLive/Sources/Chat/ChatTabView.swift`
- [ ] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift` - [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift`
- [ ] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift` - [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift`
- [ ] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift` - [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift`
- [ ] `SodaLive/Sources/Chat/Original/OriginalTabItemView.swift` - [x] `SodaLive/Sources/Chat/Original/OriginalTabItemView.swift`
- [ ] `SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift` - [x] `SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Message/TypingIndicatorItemView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Message/UserMessageItemView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Message/UserMessageItemView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift`
- [ ] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift` - [x] `SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/TalkItemView.swift` - [x] `SodaLive/Sources/Chat/Talk/TalkItemView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/TalkView.swift` - [x] `SodaLive/Sources/Chat/Talk/TalkView.swift`
- [ ] `SodaLive/Sources/Chat/Talk/TalkViewModel.swift` - [x] `SodaLive/Sources/Chat/Talk/TalkViewModel.swift`
### Content (78) ### Content (78)
- [ ] `SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift` - [ ] `SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift`
@@ -559,3 +559,31 @@
- Audition 모듈 하드코딩 한글 재검증 결과, 남은 문자열은 Preview 샘플/DEBUG_LOG/서버 메시지 분기 비교(비노출 로직)만 존재. - Audition 모듈 하드코딩 한글 재검증 결과, 남은 문자열은 Preview 샘플/DEBUG_LOG/서버 메시지 분기 비교(비노출 로직)만 존재.
- 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`). - 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`).
- 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약). - 테스트 검증: 두 스킴 모두 `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.`로 테스트 액션 미구성 확인(코드 실패 아님, 스킴 제약).