From 016a8bcca3befd12dd0a9f9ab0039e21ac8103b9 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 2 Jun 2026 14:56:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(main-home):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=ED=99=88=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + SodaLive/Sources/I18n/I18n.swift | 62 ++++++++++++ .../FollowRecommendedCreatorsRequest.swift | 5 + .../MainHomeRecommendationResponse.swift | 98 +++++++++++++++++++ .../Home/Models/RecommendedActivityType.swift | 37 +++++++ .../V2/Main/Home/Repository/MainHomeApi.swift | 47 +++++++++ .../Home/Repository/MainHomeRepository.swift | 16 +++ .../plan-task.md | 31 +++++- 8 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift create mode 100644 SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift create mode 100644 SodaLive/Sources/V2/Main/Home/Models/RecommendedActivityType.swift create mode 100644 SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift create mode 100644 SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift diff --git a/.gitignore b/.gitignore index 5f73a51..a605963 100644 --- a/.gitignore +++ b/.gitignore @@ -279,5 +279,6 @@ xcuserdata .kiro/ .junie/ +.omo/ # End of https://www.toptal.com/developers/gitignore/api/macos,xcode,appcode,swift,swiftpackagemanager,swiftpm,fastlane,cocoapods diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 6b8ffe1..21f3b30 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3060,6 +3060,68 @@ If you block this user, the following features will be restricted. } } + enum HomeRecommendation { + static var activityLive: String { + pick(ko: "라이브", en: "Live", ja: "ライブ") + } + + static var activityAudio: String { + pick(ko: "오디오", en: "Audio", ja: "オーディオ") + } + + static var activityCommunity: String { + pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ") + } + + static var liveSectionTitle: String { + pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") + } + + static var activeCreatorSectionTitle: String { + pick(ko: "방금 활동한 크리에이터", en: "Recently active creators", ja: "最近活動したクリエイター") + } + + static var recentDebutCreatorSectionTitle: String { + pick(ko: "최근 데뷔한 크리에이터", en: "Recently debuted creators", ja: "最近デビューしたクリエイター") + } + + static var firstAudioContentSectionTitle: String { + pick(ko: "처음 만나는 오디오", en: "First audio to discover", ja: "初めて出会うオーディオ") + } + + static var aiCharacterSectionTitle: String { + pick(ko: "AI 캐릭터", en: "AI characters", ja: "AIキャラクター") + } + + static var genreCreatorSectionTitle: String { + pick(ko: "장르의 크리에이터", en: "Creators by genre", ja: "ジャンルのクリエイター") + } + + static var cheerCreatorSectionTitle: String { + pick(ko: "최근 응원이 많은 크리에이터", en: "Recently cheered creators", ja: "最近応援が多いクリエイター") + } + + static var popularCommunitySectionTitle: String { + pick(ko: "인기 커뮤니티", en: "Popular community", ja: "人気コミュニティ") + } + + static var followAll: String { + pick(ko: "모두 팔로우하기", en: "Follow all", ja: "すべてフォロー") + } + + static var followAllCompleted: String { + pick(ko: "모두 팔로우 완료", en: "All followed", ja: "すべてフォロー済み") + } + + static var more: String { + pick(ko: "더보기", en: "More", ja: "もっと見る") + } + + static var collapse: String { + pick(ko: "접기", en: "Collapse", ja: "閉じる") + } + } + enum Explorer { static var channel: String { pick(ko: "채널", en: "Channel", ja: "チャンネル") diff --git a/SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift b/SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift new file mode 100644 index 0000000..8a7d260 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift @@ -0,0 +1,5 @@ +import Foundation + +struct FollowRecommendedCreatorsRequest: Encodable { + let creatorIds: [Int]? +} diff --git a/SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift b/SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift new file mode 100644 index 0000000..3c62bcf --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift @@ -0,0 +1,98 @@ +import Foundation + +struct HomeRecommendationResponse: Decodable { + let lives: [HomeLiveItem] + let banners: [HomeBannerItem] + let recentlyActiveCreators: [HomeActiveCreatorItem] + let recentDebutCreators: [HomeCreatorItem] + let firstAudioContents: [HomeFirstAudioContentItem] + let aiCharacters: [HomeAiCharacterItem] + let genreCreators: [HomeGenreCreatorGroupItem] + let cheerCreators: [HomeCreatorItem] + let popularCommunityPosts: [HomePopularCommunityPostItem] +} + +struct HomeLiveItem: Decodable, Hashable { + let liveRoomId: Int? + let roomId: Int? + let creatorId: Int? + let creatorNickname: String? + let title: String? + let profileImageUrl: String? + let thumbnailUrl: String? + let imageUrl: String? + let beginDateTime: String? + let participantCount: Int? +} + +struct HomeBannerItem: Decodable, Hashable { + let bannerId: Int? + let type: String? + let title: String? + let imageUrl: String? + let link: String? + let eventId: Int? + let creatorId: Int? + let seriesId: Int? +} + +struct HomeActiveCreatorItem: Decodable, Hashable { + let creatorId: Int? + let creatorNickname: String? + let profileImageUrl: String? + let activityType: RecommendedActivityType? + let activityAt: String? +} + +struct HomeCreatorItem: Decodable, Hashable { + let creatorId: Int? + let creatorNickname: String? + let nickname: String? + let profileImageUrl: String? + let imageUrl: String? + let description: String? + let followerCount: Int? + let cheerCount: Int? + let debutDate: String? +} + +struct HomeFirstAudioContentItem: Decodable, Hashable { + let contentId: Int? + let creatorId: Int? + let creatorNickname: String? + let title: String? + let imageUrl: String? + let coverImageUrl: String? + let releaseDate: String? +} + +struct HomeAiCharacterItem: Decodable, Hashable { + let characterId: Int? + let name: String? + let description: String? + let profileImageUrl: String? + let imageUrl: String? + let chatCount: Int? + let originalTitle: String? +} + +struct HomeGenreCreatorGroupItem: Decodable, Hashable { + let genreId: Int? + let genreName: String? + let title: String? + let creators: [HomeCreatorItem]? +} + +struct HomePopularCommunityPostItem: Decodable, Hashable { + let postId: Int? + let creatorId: Int? + let creatorNickname: String? + let creatorProfileImageUrl: String? + let content: String? + let imageUrl: String? + let price: Int? + let existOrdered: Bool? + let likeCount: Int? + let commentCount: Int? + let createdAt: String? +} diff --git a/SodaLive/Sources/V2/Main/Home/Models/RecommendedActivityType.swift b/SodaLive/Sources/V2/Main/Home/Models/RecommendedActivityType.swift new file mode 100644 index 0000000..3d99ccb --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Models/RecommendedActivityType.swift @@ -0,0 +1,37 @@ +import Foundation + +enum RecommendedActivityType: Decodable, Hashable { + case live + case audio + case community + case unknown(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let code = try container.decode(String.self) + + switch code { + case "LIVE", "LIVE_REPLAY": + self = .live + case "AUDIO": + self = .audio + case "COMMUNITY": + self = .community + default: + self = .unknown(code) + } + } + + var title: String { + switch self { + case .live: + return I18n.HomeRecommendation.activityLive + case .audio: + return I18n.HomeRecommendation.activityAudio + case .community: + return I18n.HomeRecommendation.activityCommunity + case .unknown: + return "" + } + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift b/SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift new file mode 100644 index 0000000..bf83494 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift @@ -0,0 +1,47 @@ +import Foundation +import Moya + +enum MainHomeApi { + case getRecommendations + case followRecommendedCreators(request: FollowRecommendedCreatorsRequest) +} + +extension MainHomeApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getRecommendations: + return "/api/v2/home/recommendations" + + case .followRecommendedCreators: + return "/api/v2/home/recommendations/creators/follow" + } + } + + var method: Moya.Method { + switch self { + case .getRecommendations: + return .get + + case .followRecommendedCreators: + return .post + } + } + + var task: Moya.Task { + switch self { + case .getRecommendations: + return .requestPlain + + case .followRecommendedCreators(let request): + return .requestJSONEncodable(request) + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift b/SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift new file mode 100644 index 0000000..f013515 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift @@ -0,0 +1,16 @@ +import Foundation +import CombineMoya +import Combine +import Moya + +final class MainHomeRepository { + private let api = MoyaProvider() + + func getRecommendations() -> AnyPublisher { + return api.requestPublisher(.getRecommendations) + } + + func followRecommendedCreators(request: FollowRecommendedCreatorsRequest) -> AnyPublisher { + return api.requestPublisher(.followRecommendedCreators(request: request)) + } +} diff --git a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md index 70b259e..3fb1daf 100644 --- a/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md +++ b/docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md @@ -78,7 +78,7 @@ ### Phase 1: 구현 기준과 데이터 계층 준비 -- [ ] **Task 1.1: 구현 전 구조 확인** +- [x] **Task 1.1: 구현 전 구조 확인** - 대상 파일: - 확인: `SodaLive/Sources/V2/Main/MainView.swift` - 확인: `SodaLive/Sources/V2/Component/SectionTitle.swift` @@ -97,7 +97,7 @@ - 기대 결과: `.home` placeholder 위치, 기존 `HomeApi`, `I18n` 구조를 확인할 수 있다. - 수동 확인: 신규 추천 API를 기존 `HomeApi.swift`에 추가하지 않아야 한다. -- [ ] **Task 1.2: 추천 API 모델 작성** +- [x] **Task 1.2: 추천 API 모델 작성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift` - 생성: `SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift` @@ -113,7 +113,7 @@ - 기대 결과: 세 모델/enum 정의가 검색된다. - 수동 확인: PRD의 응답 필드명이 Swift 모델에 누락 없이 반영되어야 한다. -- [ ] **Task 1.3: I18n 문구 추가** +- [x] **Task 1.3: I18n 문구 추가** - 대상 파일: - 수정: `SodaLive/Sources/I18n/I18n.swift` - 작업 내용: @@ -141,7 +141,7 @@ - 기대 결과: 홈 추천 I18n enum과 핵심 문구가 검색된다. - 수동 확인: 신규 사용자 노출 문자열이 하드코딩 View 텍스트로 흩어지지 않아야 한다. -- [ ] **Task 1.4: 신규 API와 Repository 작성** +- [x] **Task 1.4: 신규 API와 Repository 작성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift` - 생성: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift` @@ -389,3 +389,26 @@ - Swift 코드 구현 - Xcode 프로젝트 포함 여부 확인 - 빌드/기능 검증 + +### 2026-06-02 Phase 1 구현 완료 + +- 목적: Phase 1 범위인 구현 전 구조 확인, 추천 API 모델, I18n 문구, 신규 API/Repository 작성 +- 수행 내용: + - `SodaLive/Sources/V2/Main/MainView.swift`의 `.home` placeholder 유지 상태와 기존 `HomeApi`, `HomeTabRepository`, `I18n` 구조 확인 + - `SodaLive/Sources/V2/Main/Home/Models`에 `HomeRecommendationResponse`, `FollowRecommendedCreatorsRequest`, `RecommendedActivityType` 추가 + - `SodaLive/Sources/I18n/I18n.swift`에 `I18n.HomeRecommendation` 문구 추가 + - `SodaLive/Sources/V2/Main/Home/Repository`에 `MainHomeApi`, `MainHomeRepository` 추가 + - 신규 Swift 파일 5개를 `SodaLive.xcodeproj/project.pbxproj`의 `SodaLive`, `SodaLive-dev` Sources에 포함 +- 검증: + - `rg "case \.home|MainPlaceholderTabView|enum HomeApi|enum I18n" SodaLive/Sources/V2/Main SodaLive/Sources/Home SodaLive/Sources/I18n/I18n.swift` 실행, `.home` placeholder와 기존 `HomeApi`, `I18n` 구조 확인 + - `rg "struct HomeRecommendationResponse|struct FollowRecommendedCreatorsRequest|enum RecommendedActivityType" SodaLive/Sources/V2/Main/Home/Models` 실행, 모델 정의 검색 확인 + - `rg "enum HomeRecommendation|activityLive|followAllCompleted|collapse" SodaLive/Sources/I18n/I18n.swift` 실행, 신규 I18n 문구 검색 확인 + - `rg "/api/v2/home/recommendations|followRecommendedCreators|getRecommendations" SodaLive/Sources/V2/Main/Home/Repository` 실행, 신규 API 경로와 repository 메서드 검색 확인 + - `rg "/api/v2/home/recommendations" SodaLive/Sources/Home` 실행, 검색 결과 없음 확인 + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인 + - `git diff --check` 실행, 출력 없이 성공 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -list` 실행, 별도 테스트 타깃 없음 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인 +- 아직 수행하지 않은 작업: + - Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결 + - 테스트 타깃이 없어 Phase 1 전용 RED/GREEN 단위 테스트는 추가하지 않음