From 561e9910509fa47a48c2105f43d6c8a59dbee66b Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 29 Jun 2026 18:56:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EC=84=B9=EC=85=98=EC=9D=84=20=EC=97=B0=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 8 ++ SodaLive/Sources/I18n/I18n.swift | 53 ++++++++- .../V2/Component/Card/AiCharacterCard.swift | 107 +++++++++++++----- .../MainHomeAiCharacterSection.swift | 74 ++++++++++++ .../MainHomeRecentDebutCreatorSection.swift | 13 +-- .../MainHomeRecommendationView.swift | 5 + .../plan-task.md | 35 +++++- 7 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeAiCharacterSection.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index c7e92a85..1a02b5a7 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -225,6 +225,9 @@ 401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; }; 401A3FCF2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; }; 401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; }; + 401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; }; + 401A3FD22FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; }; + 401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; }; 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; }; 40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; }; 40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; }; @@ -1742,6 +1745,7 @@ 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = ""; }; 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = ""; }; 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecentDebutCreatorSection.swift; sourceTree = ""; }; + 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeAiCharacterSection.swift; sourceTree = ""; }; 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = ""; }; 40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = ""; }; @@ -2745,6 +2749,7 @@ 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */, 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */, 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */, + 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */, ); path = Components; sourceTree = ""; @@ -5346,6 +5351,7 @@ 401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */, 401A3FC92FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */, 401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */, + 401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, @@ -6096,6 +6102,7 @@ 401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */, 401A3FCA2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */, 401A3FCF2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */, + 401A3FD22FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, @@ -6873,6 +6880,7 @@ 401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */, 401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */, 401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */, + 401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */, diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 21f3b30e..d60c1d39 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3090,7 +3090,58 @@ If you block this user, the following features will be restricted. } static var aiCharacterSectionTitle: String { - pick(ko: "AI 캐릭터", en: "AI characters", ja: "AIキャラクター") + pick(ko: "크리에이터와 이야기를 나눠요!", en: "AI characters", ja: "AIキャラクター") + } + + static func compactChatCount(_ count: Int) -> String { + pick( + ko: compactEastAsianCount(count, tenMillionUnit: "억", tenThousandUnit: "만", thousandUnit: "천"), + en: compactEnglishCount(count), + ja: compactEastAsianCount(count, tenMillionUnit: "億", tenThousandUnit: "万", thousandUnit: "千") + ) + } + + private static func compactEastAsianCount( + _ count: Int, + tenMillionUnit: String, + tenThousandUnit: String, + thousandUnit: String + ) -> String { + if count >= 100_000_000 { + return compactCountValue(count, divisor: 100_000_000, unit: tenMillionUnit) + } + + if count >= 10_000 { + return compactCountValue(count, divisor: 10_000, unit: tenThousandUnit) + } + + if count >= 1_000 { + return compactCountValue(count, divisor: 1_000, unit: thousandUnit) + } + + return count.comma() + } + + private static func compactEnglishCount(_ count: Int) -> String { + if count >= 1_000_000_000 { + return compactCountValue(count, divisor: 1_000_000_000, unit: "B") + } + + if count >= 1_000_000 { + return compactCountValue(count, divisor: 1_000_000, unit: "M") + } + + if count >= 1_000 { + return compactCountValue(count, divisor: 1_000, unit: "K") + } + + return count.comma() + } + + private static func compactCountValue(_ count: Int, divisor: Int, unit: String) -> String { + let value = floor((Double(count) / Double(divisor)) * 10) / 10 + let format = value.truncatingRemainder(dividingBy: 1) == 0 ? "%.0f" : "%.1f" + return String(format: format, locale: Locale(identifier: "en_US_POSIX"), value) + unit } static var genreCreatorSectionTitle: String { diff --git a/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift b/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift index 5613d89f..fcab1acb 100644 --- a/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift +++ b/SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift @@ -1,6 +1,10 @@ import SwiftUI struct AiCharacterCard: View { + static let baseDeviceWidth: CGFloat = 402 + static let maxCardWidth: CGFloat = 185 + static let maxCardHeight: CGFloat = 278 + let name: String let description: String let profileImageUrl: String? @@ -8,57 +12,100 @@ struct AiCharacterCard: View { let originalTitle: String? var body: some View { - HStack(alignment: .top, spacing: SodaSpacing.s12) { - DownsampledKFImage(url: URL(string: profileImageUrl ?? ""), size: CGSize(width: 72, height: 72)) - .background(Color.gray800) - .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + let cardSize = Self.responsiveSize() + + ZStack(alignment: .top) { + Color.gray900 + + DownsampledKFImage( + url: URL(string: profileImageUrl ?? ""), + size: CGSize(width: cardSize.width, height: cardSize.width) + ) + .frame(width: cardSize.width, height: cardSize.width) + .background(Color.gray800) + + LinearGradient( + colors: [Color.gray900.opacity(0), Color.gray900], + startPoint: .top, + endPoint: .bottom + ) + .frame(width: cardSize.width, height: cardSize.width) + + VStack(alignment: .leading, spacing: SodaSpacing.s6) { + Spacer() - VStack(alignment: .leading, spacing: SodaSpacing.s4) { Text(name) - .appFont(.heading4) + .appFont(.heading3) .foregroundColor(.white) .lineLimit(1) .truncationMode(.tail) Text(description) .appFont(.body5) - .foregroundColor(Color.gray500) + .foregroundColor(.white) .lineLimit(2) .truncationMode(.tail) - HStack(spacing: SodaSpacing.s8) { - if let chatCount { - Text("Chat \(chatCount)") - .appFont(.caption2) - .foregroundColor(Color.gray500) - } - - if let originalTitle, !originalTitle.isEmpty { - Text(originalTitle) - .appFont(.caption2) - .foregroundColor(Color.gray500) - .lineLimit(1) - } + if let originalTitle, !originalTitle.isEmpty { + Text(originalTitle) + .appFont(.caption2) + .foregroundColor(Color.soda300) + .lineLimit(1) + .truncationMode(.tail) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .padding(.horizontal, SodaSpacing.s12) + .padding(.bottom, SodaSpacing.s20) - Spacer(minLength: 0) + if let chatCount { + HStack(spacing: 2) { + Image(systemName: "bubble.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + + Text(I18n.HomeRecommendation.compactChatCount(chatCount)) + .appFont(.body5) + .foregroundColor(Color.gray100) + } + .padding(.horizontal, SodaSpacing.s4) + .padding(.vertical, 2) + .background(Color.black.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous)) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.top, SodaSpacing.s6) + .padding(.trailing, SodaSpacing.s6) + } } - .padding(SodaSpacing.s12) - .background(Color.gray900) + .frame(width: cardSize.width, height: cardSize.height) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) } + + static func responsiveSize(deviceWidth: CGFloat = UIScreen.main.bounds.width) -> CGSize { + let scale = min(deviceWidth / baseDeviceWidth, 1) + return CGSize(width: maxCardWidth * scale, height: maxCardHeight * scale) + } } struct AiCharacterCard_Previews: PreviewProvider { static var previews: some View { - AiCharacterCard( - name: "AI 캐릭터", - description: "캐릭터 설명이 표시됩니다.", - profileImageUrl: nil, - chatCount: 128, - originalTitle: "원작" - ) + VStack(spacing: SodaSpacing.s20) { + AiCharacterCard( + name: "캐릭터 이름", + description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리", + profileImageUrl: "https://picsum.photos/500/500", + chatCount: 14_000, + originalTitle: nil + ) + + AiCharacterCard( + name: "캐릭터 이름", + description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리", + profileImageUrl: nil, + chatCount: 128, + originalTitle: "작품 이름" + ) + } .padding(SodaSpacing.s20) .background(Color.black) .previewLayout(.sizeThatFits) diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeAiCharacterSection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeAiCharacterSection.swift new file mode 100644 index 00000000..382a71b4 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeAiCharacterSection.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct MainHomeAiCharacterSection: View { + let items: [HomeAiCharacterItem] + let onTapCharacter: (Int) -> Void + + init( + items: [HomeAiCharacterItem], + onTapCharacter: @escaping (Int) -> Void = { _ in } + ) { + self.items = items + self.onTapCharacter = onTapCharacter + } + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: I18n.HomeRecommendation.aiCharacterSectionTitle) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .center, spacing: SodaSpacing.s4) { + ForEach(items, id: \.self) { item in + Button { + onTapCharacter(item.characterId) + } label: { + AiCharacterCard( + name: item.name, + description: item.description, + profileImageUrl: item.profileImage, + chatCount: item.totalChatCount, + originalTitle: item.originalWorkTitle + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, SodaSpacing.s14) + } + } + } + } +} + +struct MainHomeAiCharacterSection_Previews: PreviewProvider { + private static let previewImageUrl = "https://picsum.photos/500/500" + + static var previews: some View { + MainHomeAiCharacterSection( + items: [ + HomeAiCharacterItem( + characterId: 1, + creatorId: 10, + name: "캐릭터 이름", + description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리", + profileImage: previewImageUrl, + totalChatCount: 14_000, + originalWorkTitle: nil + ), + HomeAiCharacterItem( + characterId: 2, + creatorId: 11, + name: "캐릭터 이름", + description: "캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리", + profileImage: previewImageUrl, + totalChatCount: 128, + originalWorkTitle: "작품 이름" + ) + ] + ) + .padding(.vertical, SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeRecentDebutCreatorSection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeRecentDebutCreatorSection.swift index 066a349e..b3cfb520 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeRecentDebutCreatorSection.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeRecentDebutCreatorSection.swift @@ -33,20 +33,19 @@ struct MainHomeRecentDebutCreatorSection: View { } private struct MainHomeRecentDebutCreatorItemView: View { - private static let itemWidth: CGFloat = 185 - private static let itemHeight: CGFloat = 234 - let item: HomeCreatorItem let action: () -> Void var body: some View { + let itemSize = AiCharacterCard.responsiveSize() + Button(action: action) { ZStack(alignment: .bottom) { DownsampledKFImage( url: URL(string: item.creatorProfileImage), - size: CGSize(width: Self.itemWidth, height: Self.itemHeight) + size: itemSize ) - .frame(width: Self.itemWidth, height: Self.itemHeight) + .frame(width: itemSize.width, height: itemSize.height) .background(Color.gray800) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) @@ -55,7 +54,7 @@ private struct MainHomeRecentDebutCreatorItemView: View { startPoint: .top, endPoint: .bottom ) - .frame(height: 80) + .frame(height: min(80, itemSize.height)) Text(item.creatorNickname) .appFont(.heading4) @@ -65,7 +64,7 @@ private struct MainHomeRecentDebutCreatorItemView: View { .padding(.horizontal, SodaSpacing.s12) .padding(.bottom, SodaSpacing.s20) } - .frame(width: Self.itemWidth, height: Self.itemHeight) + .frame(width: itemSize.width, height: itemSize.height) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) } .buttonStyle(.plain) diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift index f3694ea7..ae07a094 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift @@ -42,6 +42,11 @@ struct MainHomeRecommendationView: View { items: viewModel.recommendations?.recentDebutCreators ?? [], onTapCreator: onTapCreator ) + + MainHomeAiCharacterSection( + items: viewModel.recommendations?.aiCharacters ?? [], + onTapCharacter: onTapCharacter + ) } .padding(.vertical, SodaSpacing.s20) } diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index 1c91fa2c..ce39ed4c 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -335,7 +335,7 @@ ### Phase 12: AI 캐릭터 섹션 UI -- [ ] **Task 12.1: AI 캐릭터 섹션 작성/보완** +- [x] **Task 12.1: AI 캐릭터 섹션 작성/보완** - 대상 파일: - 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeAiCharacterSection.swift` - 확인/보완: `SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift` @@ -477,6 +477,39 @@ ## 검증 기록 +### 2026-06-29 Phase 12 카드 반응형 크기와 채팅 수 표기 보완 + +- 목적: AI 캐릭터와 최근 데뷔 크리에이터 썸네일 카드 크기를 `402` 기준으로 보정하고, AI 캐릭터 채팅 수 배지를 다국어 축약 표기로 표시 +- 수행 내용: + - `AiCharacterCard.responsiveSize(deviceWidth:)`를 추가해 최대 `185x278`, 디바이스 폭이 `402`보다 작으면 `deviceWidth / 402` 비율로 축소되도록 구성 + - `MainHomeRecentDebutCreatorSection`의 아이템도 동일한 responsive size를 사용해 AI 캐릭터 카드와 최대 크기/축소 비율을 맞춤 + - `I18n.HomeRecommendation.compactChatCount(_:)`를 추가해 한국어/일본어는 `천`, `만`, `억` 계열, 영어는 `K`, `M`, `B` 계열로 표시 + - 축약 소수는 한 자리까지 표시하고 경계값 반올림으로 단위가 앞당겨지지 않도록 내림 처리 +- 검증: + - `rg -n "responsiveSize|baseDeviceWidth|maxCardWidth|maxCardHeight|compactChatCount|compactEastAsianCount|compactEnglishCount|100_000_000|1_000" SodaLive/Sources/V2 SodaLive/Sources/I18n/I18n.swift` 실행, 반응형 크기와 다국어 축약 로직 확인 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인 + - `git diff --check` 실행, 출력 없이 성공 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 + + +### 2026-06-29 Phase 12 AI 캐릭터 섹션 구현 완료 + +- 목적: V2 Main Home 추천 탭에 Figma `24:5551`, `24:5554` 기준 AI 캐릭터 섹션을 연결 +- 수행 내용: + - `AiCharacterCard`를 Figma 기준 `185x278` 썸네일 카드로 보완하고, `profileImage`, `totalChatCount`, `name`, `description`, `originalWorkTitle` 표시를 반영 + - `MainHomeAiCharacterSection`을 추가해 `HomeAiCharacterItem.characterId`, `creatorId`, `name`, `description`, `profileImage`, `totalChatCount`, `originalWorkTitle` 기반 가로 카드 목록을 표시 + - 데이터가 비어 있으면 섹션 제목과 목록을 렌더링하지 않도록 구성 + - `MainHomeRecommendationView`에서 최근 데뷔 크리에이터 섹션 다음 순서로 AI 캐릭터 섹션을 배치 + - 신규 `MainHomeAiCharacterSection.swift`를 `SodaLive.xcodeproj/project.pbxproj` 빌드 대상에 포함 + - `I18n.HomeRecommendation.aiCharacterSectionTitle`의 한국어 문구를 Figma 섹션 제목인 `크리에이터와 이야기를 나눠요!`로 반영 +- 검증: + - Figma `24:5551`, `24:5554` 디자인 컨텍스트와 스크린샷 확인, 섹션 제목/간격 14/리스트 좌측 inset 14/아이템 간격 4/아이템 `185x278` 구조 확인 + - 구현 전 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행, `Scheme SodaLive-dev is not currently configured for the test action`으로 테스트 액션 미구성 확인 + - `rg -n "MainHomeAiCharacterSection|AiCharacterCard|aiCharacters|totalChatCount|originalWorkTitle|profileImage|localhost:3845|figma.com" SodaLive/Sources/V2 SodaLive/Sources/I18n/I18n.swift` 실행, 추천 응답 필드 연결 및 forbidden URL 미포함 확인 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 + + ### 2026-06-29 Phase 12 제외 섹션 반영 - 목적: 요구사항 변경에 따라 제외된 추천 섹션 UI 구현 범위를 PRD와 계획 문서에서 제거