19 Commits

Author SHA1 Message Date
Yu Sung
aea5b5cdec feat(creator): 팔로워 목록 이동을 연결한다 2026-08-03 21:43:56 +09:00
Yu Sung
2db4ef1c35 fix(imagepicker): 크롭 범위를 보정한다 2026-08-03 21:13:45 +09:00
Yu Sung
9320657bac chore(project): 앱 버전을 1.56.0으로 올린다 2026-08-03 18:22:10 +09:00
Yu Sung
dc99bdf9c9 fix(main): 현재 라이브 전체보기를 보정한다 2026-08-03 15:37:27 +09:00
Yu Sung
498debbfc7 fix(main): 현재 라이브 전체 스타일을 보정한다 2026-08-03 14:06:28 +09:00
Yu Sung
a8fe416cb5 fix(main): 추천 홈 후속 검증을 보완한다 2026-07-31 21:12:32 +09:00
Yu Sung
3ae2dc19d6 fix(main): 최근 활동 이동을 보정한다 2026-07-30 16:13:32 +09:00
Yu Sung
0232cd4335 docs(agent): 문서 샘플 기준을 정리한다 2026-07-30 14:30:32 +09:00
Yu Sung
a0db58e53f feat(main): 라이브 전체 진입을 추가한다 2026-07-22 15:47:52 +09:00
Yu Sung
70af4923c6 feat(main): 생성 메뉴를 홈에 추가한다 2026-07-20 19:14:02 +09:00
Yu Sung
9a0e75a391 fix(creator): 후원 카드 너비를 보정한다 2026-07-20 18:09:51 +09:00
Yu Sung
43e32f1867 fix(live): 라이브 상세 표시를 보정한다 2026-07-20 17:31:49 +09:00
Yu Sung
dd5144ec3f chore(project): 앱 버전을 1.55.1로 올린다 2026-07-15 14:42:17 +09:00
Yu Sung
9cda165d30 fix(main): 이벤트 팝업 배경 투명도를 조정한다 2026-07-15 14:42:10 +09:00
Yu Sung
b99505174a fix(main): 복귀 시 메인 초기화를 한 번만 실행한다 2026-07-15 14:42:02 +09:00
Yu Sung
e466774a8a docs(main): 복귀 초기화 방지 계획을 추가한다 2026-07-15 14:41:46 +09:00
Yu Sung
063c2808de docs(live): 복귀 동작 계획을 갱신한다 2026-07-14 18:12:53 +09:00
Yu Sung
0388e48930 fix(live): 라이브 종료 후 이전 화면을 유지한다 2026-07-14 18:12:21 +09:00
Yu Sung
1b26bc28dd fix(live): 라이브룸 전역 표시를 보정한다 2026-07-14 17:42:05 +09:00
74 changed files with 5916 additions and 564 deletions

View File

@@ -151,8 +151,12 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
## 문서 작성 규칙 ## 문서 작성 규칙
- 구현 전 PRD 작성, 사용자 인터뷰, 계획/TASK 문서 작성, 체크리스트 갱신, 검증 기록 누적, 문서 분리 기준은 `docs/agent-guides/documentation-policy.md`를 따른다. - 구현 전 PRD 작성, 사용자 인터뷰, 계획/TASK 문서 작성, 체크리스트 갱신, 검증 기록 누적, 문서 분리 기준은 `docs/agent-guides/documentation-policy.md`를 따른다.
- 신규 PRD, 계획/TASK 문서, 코드 리뷰 리포트를 만들기 전에 각각 `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md`, `docs/sample/sample-review.md`를 확인하고 현재 샘플 구조를 따른다.
- 연속된 하나의 작업은 새 문서를 만들지 말고 기존 PRD와 `plan-task.md`에 이어서 기록한다. - 연속된 하나의 작업은 새 문서를 만들지 말고 기존 PRD와 `plan-task.md`에 이어서 기록한다.
- 계획/TASK 문서는 phase heading(`### Phase N: ...`)과 단계별 체크박스(`- [ ] **Task N.N: ...**`)를 사용하고,task에 대상 파일 경로와 검증 기준을 함께 적는다. - 계획/TASK 문서는 `sample-plan-task.md`의 Phase, Task, Goal, 완료 증거, 검증 기록 구조를 따르고Task에 대상 파일 경로와 검증 기준을 적는다.
- 코드 리뷰와 점검 결과는 PRD 및 `plan-task.md`와 같은 작업 디렉터리의 `reviews/` 아래에 `phase<번호>-<구현 내용을 나타내는 영문 kebab-case>.md` 형식의 리포트로 남긴다.
- 리뷰에서 수정이 필요하다고 확정한 항목은 코드 수정 전에 `plan-task.md`의 해당 Phase에 실행 가능한 신규 Task로 추가하고, 그 Task를 기준으로 수정 및 검증한다.
## 문서 유지보수 규칙 ## 문서 유지보수 규칙
- 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다. - 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다.
- `docs/sample/**`의 경로 또는 구조가 바뀌면 `AGENTS.md`, `docs/agent-guides/**`, 샘플 간 참조를 함께 갱신해 서로 일치시킨다.

View File

@@ -213,29 +213,20 @@
401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; }; 401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; };
401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; }; 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; }; 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */; };
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; };
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */; }; 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB42FEEAFA000A5BBA3 /* MainHomeView.swift */; };
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; }; 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */; };
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; }; 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; }; 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; }; 401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; };
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; 401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; }; 401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; }; 401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; }; 401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
401A3FC92FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; }; 401A3FC92FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; };
401A3FCA2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; };
401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; }; 401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */; };
401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; }; 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 */; }; 401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FCD2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift */; };
401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.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 */; }; 401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD42FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift */; };
401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; }; 401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; };
401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; }; 401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FD52FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift */; };
@@ -244,7 +235,6 @@
401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; }; 401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; };
401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; }; 401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDC2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift */; };
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; }; 401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; };
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; };
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; }; 401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FDF2FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift */; };
401B3F012FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */; }; 401B3F012FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */; };
401B3F022FEEF10000A5BBA3 /* MainHomeRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */; }; 401B3F022FEEF10000A5BBA3 /* MainHomeRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */; };
@@ -1087,6 +1077,8 @@
40B28AB12A839E59008BE463 /* DetailNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */; }; 40B28AB12A839E59008BE463 /* DetailNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */; };
40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; }; 40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; };
40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; }; 40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B28AB32A839E9D008BE463 /* ImagePicker.swift */; };
7A03C0022E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; };
7A03C0032E41400100C0DE01 /* ImageCropGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */; };
40B37EE82A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; }; 40B37EE82A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; };
40B37EE92A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; }; 40B37EE92A90A32C000DE782 /* ProfileUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */; };
40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */; }; 40B37EEC2A90A3CF000DE782 /* ProfileUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */; };
@@ -1687,7 +1679,6 @@
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F49E8BB9BACFCA8878B70D /* MainTabBarButton.swift */; }; B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F49E8BB9BACFCA8878B70D /* MainTabBarButton.swift */; };
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */; }; B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */; };
B5E9FA82CF4238F640702D4A /* CreatorDetailDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7406AE69F7667FAA2E6DD23 /* CreatorDetailDialogView.swift */; }; B5E9FA82CF4238F640702D4A /* CreatorDetailDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7406AE69F7667FAA2E6DD23 /* CreatorDetailDialogView.swift */; };
BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; };
BA511503C1E037162697D771 /* DonationRankingPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A42B63A1021DD9F8AA3F30 /* DonationRankingPeriod.swift */; }; BA511503C1E037162697D771 /* DonationRankingPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A42B63A1021DD9F8AA3F30 /* DonationRankingPeriod.swift */; };
BCA6CD9E57528C5F4203F1A5 /* CapsuleTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */; }; BCA6CD9E57528C5F4203F1A5 /* CapsuleTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74990A0798E64A97237DD113 /* CapsuleTabBar.swift */; };
BEAAA523CAD3198065A557BC /* EntityInfo-SodaLive-dev.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0ED95AC08C6481751AE9D97 /* EntityInfo-SodaLive-dev.generated.swift */; }; BEAAA523CAD3198065A557BC /* EntityInfo-SodaLive-dev.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0ED95AC08C6481751AE9D97 /* EntityInfo-SodaLive-dev.generated.swift */; };
@@ -1852,6 +1843,8 @@
C75E4F62AFB669F455449687 /* GetPushNotificationListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23D0834690CFE9FBDC10510 /* GetPushNotificationListResponse.swift */; }; C75E4F62AFB669F455449687 /* GetPushNotificationListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F23D0834690CFE9FBDC10510 /* GetPushNotificationListResponse.swift */; };
C78714671E92BE39D476E121 /* V2vState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4339CA77E17ECD66D39D6379 /* V2vState.swift */; }; C78714671E92BE39D476E121 /* V2vState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4339CA77E17ECD66D39D6379 /* V2vState.swift */; };
C8B170409F057CA2B8501362 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 927A707B5035B0A77C0F5EA6 /* KakaoSDKUser */; }; C8B170409F057CA2B8501362 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 927A707B5035B0A77C0F5EA6 /* KakaoSDKUser */; };
C922F89B79C84E542AD7DE4F /* V2ExpandableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C922F89A79C84E542AD7DE4F /* V2ExpandableTextView.swift */; };
C922F89C79C84E542AD7DE4F /* V2ExpandableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C922F89A79C84E542AD7DE4F /* V2ExpandableTextView.swift */; };
C960566AF07B50489E94D5D0 /* PushNotificationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26CB443DA0ED4C45A18B258 /* PushNotificationApi.swift */; }; C960566AF07B50489E94D5D0 /* PushNotificationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26CB443DA0ED4C45A18B258 /* PushNotificationApi.swift */; };
C9E67EC59CB2709A7A75A8B8 /* AudioContentCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */; }; C9E67EC59CB2709A7A75A8B8 /* AudioContentCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */; };
CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC05859619BDC03056911E7 /* MainTab.swift */; }; CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC05859619BDC03056911E7 /* MainTab.swift */; };
@@ -1880,7 +1873,7 @@
CC01011F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; }; CC01011F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; };
CC0101272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; }; CC0101272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; };
CC0101282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; }; CC0101282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; };
CC0101292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0101292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift */; };
CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; };
CC01012B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; CC01012B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; };
CC01012C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; }; CC01012C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; };
@@ -1912,7 +1905,7 @@
CC01021F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; }; CC01021F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; };
CC0102272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; }; CC0102272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; };
CC0102282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; }; CC0102282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; };
CC0102292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0102292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift */; };
CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; };
CC01022B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; CC01022B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; };
CC01022C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; }; CC01022C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; };
@@ -1944,7 +1937,7 @@
CC01031F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; }; CC01031F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */; };
CC0103272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; }; CC0103272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */; };
CC0103282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; }; CC0103282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */; };
CC0103292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */; }; CC0103292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift */; };
CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; }; CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002A2FF0100000A5BBA3 /* ContentSort.swift */; };
CC01032B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; }; CC01032B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */; };
CC01032C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; }; CC01032C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */; };
@@ -2653,6 +2646,7 @@
40B28AAC2A839DE4008BE463 /* BaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = "<group>"; }; 40B28AAC2A839DE4008BE463 /* BaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = "<group>"; };
40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailNavigationBar.swift; sourceTree = "<group>"; }; 40B28AAF2A839E59008BE463 /* DetailNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailNavigationBar.swift; sourceTree = "<group>"; };
40B28AB32A839E9D008BE463 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; }; 40B28AB32A839E9D008BE463 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCropGeometry.swift; sourceTree = "<group>"; };
40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateView.swift; sourceTree = "<group>"; }; 40B37EE72A90A32C000DE782 /* ProfileUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateView.swift; sourceTree = "<group>"; };
40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateViewModel.swift; sourceTree = "<group>"; }; 40B37EEB2A90A3CF000DE782 /* ProfileUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdateViewModel.swift; sourceTree = "<group>"; };
40B37EF02A90A484000DE782 /* ModifyPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyPasswordView.swift; sourceTree = "<group>"; }; 40B37EF02A90A484000DE782 /* ModifyPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyPasswordView.swift; sourceTree = "<group>"; };
@@ -3008,7 +3002,7 @@
C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.swift; sourceTree = "<group>"; }; C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.swift; sourceTree = "<group>"; };
C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingIconButton.swift; sourceTree = "<group>"; }; C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingIconButton.swift; sourceTree = "<group>"; };
C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = "<group>"; }; C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = "<group>"; };
C922F89A79C84E542AD7DE4F /* ExpandableTextView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExpandableTextView.swift; sourceTree = "<group>"; }; C922F89A79C84E542AD7DE4F /* V2ExpandableTextView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = V2ExpandableTextView.swift; sourceTree = "<group>"; };
CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeResponse.swift; sourceTree = "<group>"; }; CC0100012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeResponse.swift; sourceTree = "<group>"; };
CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeApi.swift; sourceTree = "<group>"; }; CC0100032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeApi.swift; sourceTree = "<group>"; };
CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeRepository.swift; sourceTree = "<group>"; }; CC0100042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeRepository.swift; sourceTree = "<group>"; };
@@ -3033,7 +3027,7 @@
CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelActivitySection.swift; sourceTree = "<group>"; }; CC01001F2FF0100000A5BBA3 /* CreatorChannelActivitySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelActivitySection.swift; sourceTree = "<group>"; };
CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSnsSection.swift; sourceTree = "<group>"; }; CC0100272FF0100000A5BBA3 /* CreatorChannelSnsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSnsSection.swift; sourceTree = "<group>"; };
CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTalkActionSection.swift; sourceTree = "<group>"; }; CC0100282FF0100000A5BBA3 /* CreatorChannelTalkActionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTalkActionSection.swift; sourceTree = "<group>"; };
CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingActionMenu.swift; sourceTree = "<group>"; }; CC0100292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorContentCreationFloatingActionMenu.swift; sourceTree = "<group>"; };
CC01002A2FF0100000A5BBA3 /* ContentSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSort.swift; sourceTree = "<group>"; }; CC01002A2FF0100000A5BBA3 /* ContentSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSort.swift; sourceTree = "<group>"; };
CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortBar.swift; sourceTree = "<group>"; }; CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortBar.swift; sourceTree = "<group>"; };
CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortContextPopup.swift; sourceTree = "<group>"; }; CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSortContextPopup.swift; sourceTree = "<group>"; };
@@ -3221,7 +3215,7 @@
0FD267162A809D893E2E3B3F /* Text */ = { 0FD267162A809D893E2E3B3F /* Text */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C922F89A79C84E542AD7DE4F /* ExpandableTextView.swift */, C922F89A79C84E542AD7DE4F /* V2ExpandableTextView.swift */,
); );
path = Text; path = Text;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -4607,6 +4601,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
40B28AB32A839E9D008BE463 /* ImagePicker.swift */, 40B28AB32A839E9D008BE463 /* ImagePicker.swift */,
7A03C0012E41400100C0DE01 /* ImageCropGeometry.swift */,
); );
path = ImagePicker; path = ImagePicker;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -5671,6 +5666,7 @@
B068C16B9D7F3F80901622E8 /* Button */ = { B068C16B9D7F3F80901622E8 /* Button */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CC0100292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift */,
348933CFF7DC1B9DB6E5D1A1 /* FollowAllButton.swift */, 348933CFF7DC1B9DB6E5D1A1 /* FollowAllButton.swift */,
); );
path = Button; path = Button;
@@ -6045,7 +6041,6 @@
CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */, CC0100112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift */,
CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */, CC0100122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift */,
CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */, CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */,
CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */,
CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */, CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */,
CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */, CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */,
C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */, C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */,
@@ -6765,7 +6760,7 @@
CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
CC0101132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */, CC0101132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */,
CC0101292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */, CC0101292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift in Sources */,
CC0101092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0101092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */,
CC0101102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, CC0101102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */,
CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, CC01012A2FF0100000A5BBA3 /* ContentSort.swift in Sources */,
@@ -6840,6 +6835,7 @@
401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */, 401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
C922F89B79C84E542AD7DE4F /* V2ExpandableTextView.swift in Sources */,
C0A704000000000000000003 /* AudioContentListRow.swift in Sources */, C0A704000000000000000003 /* AudioContentListRow.swift in Sources */,
C0A711000000000000000001 /* MainContentAllTabResponse.swift in Sources */, C0A711000000000000000001 /* MainContentAllTabResponse.swift in Sources */,
C0A711000000000000000002 /* MainContentAllApi.swift in Sources */, C0A711000000000000000002 /* MainContentAllApi.swift in Sources */,
@@ -7129,6 +7125,7 @@
4091440E2A85C90600EEBFB1 /* UserProfileViewModel.swift in Sources */, 4091440E2A85C90600EEBFB1 /* UserProfileViewModel.swift in Sources */,
409143E92A85A7FE00EEBFB1 /* ContentCreateView.swift in Sources */, 409143E92A85A7FE00EEBFB1 /* ContentCreateView.swift in Sources */,
40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */, 40B28AB42A839E9D008BE463 /* ImagePicker.swift in Sources */,
7A03C0022E41400100C0DE01 /* ImageCropGeometry.swift in Sources */,
409868562DDB8F7D00540FD9 /* GetPointRewardStatusResponse.swift in Sources */, 409868562DDB8F7D00540FD9 /* GetPointRewardStatusResponse.swift in Sources */,
409143D22A85A5D200EEBFB1 /* AudioContentDonationRequest.swift in Sources */, 409143D22A85A5D200EEBFB1 /* AudioContentDonationRequest.swift in Sources */,
40B93FA02A83E50800AE29E2 /* MyLiveReservationItemView.swift in Sources */, 40B93FA02A83E50800AE29E2 /* MyLiveReservationItemView.swift in Sources */,
@@ -7654,7 +7651,6 @@
AD6424211AA898794BBE8470 /* SectionTitle.swift in Sources */, AD6424211AA898794BBE8470 /* SectionTitle.swift in Sources */,
38D73A7B6BFB9A984834984B /* TextTabBar.swift in Sources */, 38D73A7B6BFB9A984834984B /* TextTabBar.swift in Sources */,
372837FE9857C16B6B5087FF /* TitleBar.swift in Sources */, 372837FE9857C16B6B5087FF /* TitleBar.swift in Sources */,
BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */,
CC0102012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */, CC0102012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */,
CC0102032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */, CC0102032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */,
CC0102042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */, CC0102042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */,
@@ -7692,7 +7688,7 @@
CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
CC0102132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */, CC0102132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */,
CC0102292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */, CC0102292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift in Sources */,
CC0102092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0102092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */,
CC0102102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, CC0102102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */,
CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, CC01022A2FF0100000A5BBA3 /* ContentSort.swift in Sources */,
@@ -7742,17 +7738,9 @@
CE93595BF7A2BF7301F517DB /* CreatorChannelFanTalkWriteView.swift in Sources */, CE93595BF7A2BF7301F517DB /* CreatorChannelFanTalkWriteView.swift in Sources */,
CE93595CF7A2BF7301F517DB /* SodaV2ActionModal.swift in Sources */, CE93595CF7A2BF7301F517DB /* SodaV2ActionModal.swift in Sources */,
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
401A3FCA2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */,
401A3FCF2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
401A3FD22FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */, 401A3FD72FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */,
401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, 401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
C0A712000000000000000001 /* MainContentAllTabResponse.swift in Sources */, C0A712000000000000000001 /* MainContentAllTabResponse.swift in Sources */,
C0A712000000000000000002 /* MainContentAllApi.swift in Sources */, C0A712000000000000000002 /* MainContentAllApi.swift in Sources */,
C0A712000000000000000003 /* MainContentAllRepository.swift in Sources */, C0A712000000000000000003 /* MainContentAllRepository.swift in Sources */,
@@ -7796,8 +7784,6 @@
C0A72200000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */, C0A72200000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */,
C0A722000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */, C0A722000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */,
C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */, C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */,
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
401C10212FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */, 401C10212FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */,
401C10222FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */, 401C10222FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */,
401C10232FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */, 401C10232FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */,
@@ -8019,6 +8005,7 @@
40C1B6012BDFECB8008F6810 /* SeriesKeywordChipView.swift in Sources */, 40C1B6012BDFECB8008F6810 /* SeriesKeywordChipView.swift in Sources */,
409144352A85D6A400EEBFB1 /* GetFollowerListResponse.swift in Sources */, 409144352A85D6A400EEBFB1 /* GetFollowerListResponse.swift in Sources */,
405F792D2D2BCA790037DE3A /* ExpandableTextView.swift in Sources */, 405F792D2D2BCA790037DE3A /* ExpandableTextView.swift in Sources */,
C922F89C79C84E542AD7DE4F /* V2ExpandableTextView.swift in Sources */,
40ED907B2E616C7D0097BB4E /* CharacterView.swift in Sources */, 40ED907B2E616C7D0097BB4E /* CharacterView.swift in Sources */,
40B93F832A83C05900AE29E2 /* GetRecommendChannelResponse.swift in Sources */, 40B93F832A83C05900AE29E2 /* GetRecommendChannelResponse.swift in Sources */,
40728D582D79A66300D2C580 /* AppViewModel.swift in Sources */, 40728D582D79A66300D2C580 /* AppViewModel.swift in Sources */,
@@ -8072,6 +8059,7 @@
40C406F82B2053570068441C /* RouletteApi.swift in Sources */, 40C406F82B2053570068441C /* RouletteApi.swift in Sources */,
400EA7972A89F591004992CF /* LiveRoomEditViewModel.swift in Sources */, 400EA7972A89F591004992CF /* LiveRoomEditViewModel.swift in Sources */,
40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */, 40B28AB52A839E9D008BE463 /* ImagePicker.swift in Sources */,
7A03C0032E41400100C0DE01 /* ImageCropGeometry.swift in Sources */,
409143D32A85A5D200EEBFB1 /* AudioContentDonationRequest.swift in Sources */, 409143D32A85A5D200EEBFB1 /* AudioContentDonationRequest.swift in Sources */,
40B93FA12A83E50800AE29E2 /* MyLiveReservationItemView.swift in Sources */, 40B93FA12A83E50800AE29E2 /* MyLiveReservationItemView.swift in Sources */,
409868552DDB8F7D00540FD9 /* GetPointRewardStatusResponse.swift in Sources */, 409868552DDB8F7D00540FD9 /* GetPointRewardStatusResponse.swift in Sources */,
@@ -8600,6 +8588,16 @@
7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */, 7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */,
5EEE98FE0EC4039761C604EE /* TitleBar.swift in Sources */, 5EEE98FE0EC4039761C604EE /* TitleBar.swift in Sources */,
A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */, A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */,
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */,
401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
CC0103012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */, CC0103012FF0100000A5BBA3 /* CreatorChannelHomeResponse.swift in Sources */,
CC0103032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */, CC0103032FF0100000A5BBA3 /* CreatorChannelHomeApi.swift in Sources */,
CC0103042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */, CC0103042FF0100000A5BBA3 /* CreatorChannelHomeRepository.swift in Sources */,
@@ -8637,7 +8635,7 @@
CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
CC0103132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */, CC0103132FF0100000A5BBA3 /* CreatorChannelTabBar.swift in Sources */,
CC0103292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift in Sources */, CC0103292FF0100000A5BBA3 /* CreatorContentCreationFloatingActionMenu.swift in Sources */,
CC0103092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */, CC0103092FF0100000A5BBA3 /* CreatorChannelViewModel.swift in Sources */,
CC0103102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */, CC0103102FF0100000A5BBA3 /* CreatorChannelTab.swift in Sources */,
CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */, CC01032A2FF0100000A5BBA3 /* ContentSort.swift in Sources */,
@@ -8684,17 +8682,9 @@
CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */, CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */,
CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */, CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */,
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
401A3FCB2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift in Sources */,
401A3FD02FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */,
401A3FD32FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */, 401A3FD82FEEB00600A5BBA3 /* MainHomeGenreCreatorSection.swift in Sources */,
401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, 401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
C0A713000000000000000001 /* MainContentAllTabResponse.swift in Sources */, C0A713000000000000000001 /* MainContentAllTabResponse.swift in Sources */,
C0A713000000000000000002 /* MainContentAllApi.swift in Sources */, C0A713000000000000000002 /* MainContentAllApi.swift in Sources */,
C0A713000000000000000003 /* MainContentAllRepository.swift in Sources */, C0A713000000000000000003 /* MainContentAllRepository.swift in Sources */,
@@ -8748,8 +8738,6 @@
C0A72300000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */, C0A72300000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */,
C0A723000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */, C0A723000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */,
C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */, C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */,
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
401C10312FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */, 401C10312FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */,
401C10322FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */, 401C10322FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */,
401C10332FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */, 401C10332FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */,
@@ -8924,7 +8912,7 @@
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements; CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
DEVELOPMENT_TEAM = 3A8QGPMRXM; DEVELOPMENT_TEAM = 3A8QGPMRXM;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -8946,7 +8934,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.55.0; MARKETING_VERSION = 1.56.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive; PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -8965,7 +8953,7 @@
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements; CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
DEVELOPMENT_TEAM = 3A8QGPMRXM; DEVELOPMENT_TEAM = 3A8QGPMRXM;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -8987,7 +8975,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.55.0; MARKETING_VERSION = 1.56.0;
PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive; PRODUCT_BUNDLE_IDENTIFIER = kr.co.vividnext.sodalive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -38,10 +38,26 @@ class AppState: ObservableObject {
if isShowPlayer { if isShowPlayer {
ContentPlayManager.shared.stopAudio() ContentPlayManager.shared.stopAudio()
ContentPlayerPlayManager.shared.resetPlayer() ContentPlayerPlayManager.shared.resetPlayer()
return
}
guard let pendingExternalNavigationAction else {
return
}
self.pendingExternalNavigationAction = nil
pendingExternalNavigationCancelAction = nil
DispatchQueue.main.async {
pendingExternalNavigationAction()
} }
} }
} }
@Published private(set) var isShowLeaveLiveNavigationDialog = false
private var pendingExternalNavigationAction: (() -> Void)?
private var pendingExternalNavigationCancelAction: (() -> Void)?
@Published var isShowNotificationSettingsDialog = false @Published var isShowNotificationSettingsDialog = false
@Published var pushRoomId = 0 @Published var pushRoomId = 0
@@ -148,6 +164,37 @@ class AppState: ObservableObject {
} }
} }
func requestExternalNavigation(
navigationAction: @escaping () -> Void,
cancelAction: @escaping () -> Void
) {
guard isShowPlayer else {
navigationAction()
return
}
pendingExternalNavigationAction = navigationAction
pendingExternalNavigationCancelAction = cancelAction
isShowLeaveLiveNavigationDialog = true
}
func confirmExternalNavigation() {
guard pendingExternalNavigationAction != nil else {
isShowLeaveLiveNavigationDialog = false
return
}
isShowLeaveLiveNavigationDialog = false
NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil)
}
func cancelExternalNavigation() {
isShowLeaveLiveNavigationDialog = false
pendingExternalNavigationAction = nil
pendingExternalNavigationCancelAction?()
pendingExternalNavigationCancelAction = nil
}
func consumePendingDeepLinkAction() -> AppDeepLinkAction? { func consumePendingDeepLinkAction() -> AppDeepLinkAction? {
let action = pendingDeepLinkAction let action = pendingDeepLinkAction
pendingDeepLinkAction = nil pendingDeepLinkAction = nil

View File

@@ -87,7 +87,7 @@ enum AppStep {
onSuccess: (CreateLiveRoomResponse) -> Void onSuccess: (CreateLiveRoomResponse) -> Void
) )
case liveNowAll(onClickParticipant: (Int) -> Void) case liveNowAll
case liveReservationAll( case liveReservationAll(
onClickReservation: (Int) -> Void, onClickReservation: (Int) -> Void,

View File

@@ -30,21 +30,6 @@ struct ContentView: View {
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
} }
if let liveDetailSheet = appState.liveDetailSheet {
LiveDetailView(
roomId: liveDetailSheet.roomId,
onClickParticipant: liveDetailSheet.onClickParticipant,
onClickReservation: liveDetailSheet.onClickReservation,
onClickStart: liveDetailSheet.onClickStart,
onClickCancel: liveDetailSheet.onClickCancel,
onClickClose: {
withAnimation {
appState.hideLiveDetailSheet()
}
}
)
}
if isShowDialog { if isShowDialog {
SodaDialog( SodaDialog(
title: I18n.Common.pointGrantTitle, title: I18n.Common.pointGrantTitle,
@@ -62,7 +47,6 @@ struct ContentView: View {
self.isShowDialog = true self.isShowDialog = true
} }
} }
.sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1)
.navigationDestination(for: AppRoute.self) { route in .navigationDestination(for: AppRoute.self) { route in
if let step = appState.appStep(for: route) { if let step = appState.appStep(for: route) {
AppStepLayerView(step: step, canPgPaymentViewModel: canPgPaymentViewModel) AppStepLayerView(step: step, canPgPaymentViewModel: canPgPaymentViewModel)
@@ -73,6 +57,49 @@ struct ContentView: View {
} }
} }
} }
.overlay {
ZStack {
if let liveDetailSheet = appState.liveDetailSheet {
LiveDetailView(
roomId: liveDetailSheet.roomId,
onClickParticipant: liveDetailSheet.onClickParticipant,
onClickReservation: liveDetailSheet.onClickReservation,
onClickStart: liveDetailSheet.onClickStart,
onClickCancel: liveDetailSheet.onClickCancel,
onClickClose: {
withAnimation {
appState.hideLiveDetailSheet()
}
}
)
}
if appState.isShowPlayer {
LiveRoomViewV2()
}
if appState.isShowLeaveLiveNavigationDialog {
leaveLiveNavigationDialog
}
}
}
.sodaToast(isPresented: $appState.isShowErrorPopup, message: appState.errorMessage, autohideIn: 1)
}
private var leaveLiveNavigationDialog: some View {
SodaV2ActionModal(
title: I18n.Common.alertTitle,
message: I18n.LiveRoom.leaveLiveForNavigationDesc,
button1: SodaV2ActionModalButton(
label: I18n.Common.confirm,
action: appState.confirmExternalNavigation
),
button2: SodaV2ActionModalButton(
label: I18n.Common.cancel,
action: appState.cancelExternalNavigation
),
onDimmedTap: appState.cancelExternalNavigation
)
} }
} }
@@ -189,8 +216,8 @@ struct AppStepLayerView: View {
onSuccess: onSuccess onSuccess: onSuccess
) )
case .liveNowAll(let onClickParticipant): case .liveNowAll:
LiveNowAllView(onClickParticipant: onClickParticipant) LiveNowAllView()
case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive): case .liveReservationAll(let onClickReservation, let onClickStart, let onClickCancel, let onTapCreateLive):
LiveReservationAllView( LiveReservationAllView(

View File

@@ -300,7 +300,6 @@ final class UserProfileViewModel: ObservableObject {
} else { } else {
if roomId > 0 { if roomId > 0 {
AppState.shared.isShowPlayer = true AppState.shared.isShowPlayer = true
AppState.shared.setAppStep(step: .main)
} }
} }
} else { } else {

View File

@@ -453,9 +453,9 @@ enum I18n {
static var pinLimitDesc: String { static var pinLimitDesc: String {
pick( pick(
ko: "이 콘텐츠를 고정하시겠어요? 채널에 콘텐츠를 최대 3개까지 고정할 수 있습니다. 이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.", ko: "이 콘텐츠를 고정하시겠어요? 채널에 콘텐츠를 최대 9개까지 고정할 수 있습니다. 이 콘텐츠를 고정하면 가장 오래된 콘텐츠가 대체됩니다.",
en: "Do you want to pin this content? You can pin up to 3 contents in a channel. Pinning this will replace the oldest pinned content.", en: "Do you want to pin this content? You can pin up to 9 contents in a channel. Pinning this will replace the oldest pinned content.",
ja: "このコンテンツを固定しますか?チャンネルには最大3件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。" ja: "このコンテンツを固定しますか?チャンネルには最大9件まで固定できます。このコンテンツを固定すると最も古い固定コンテンツが置き換えられます。"
) )
} }
@@ -2367,7 +2367,7 @@ enum I18n {
enum LiveNow { enum LiveNow {
static var allTitle: String { static var allTitle: String {
pick(ko: "지금 라이브 중 전체보기", en: "Live Now - All", ja: "ライブ配信中(全て)") pick(ko: "On Air", en: "On Air", ja: "On Air")
} }
static var sectionTitle: String { static var sectionTitle: String {
@@ -3546,6 +3546,10 @@ If you block this user, the following features will be restricted.
pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ")
} }
static var liveAll: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var activeCreatorSectionTitle: String { static var activeCreatorSectionTitle: String {
pick(ko: "방금 활동한 크리에이터", en: "Recently active creators", ja: "最近活動したクリエイター") pick(ko: "방금 활동한 크리에이터", en: "Recently active creators", ja: "最近活動したクリエイター")
} }
@@ -3633,6 +3637,18 @@ If you block this user, the following features will be restricted.
pick(ko: "모두 팔로우 완료", en: "All followed", ja: "すべてフォロー済み") pick(ko: "모두 팔로우 완료", en: "All followed", ja: "すべてフォロー済み")
} }
static var followAllConfirmTitle: String {
pick(ko: "모두 팔로우할까요?", en: "Follow all creators?", ja: "すべてのクリエイターをフォローしますか?")
}
static var followAllConfirmDescription: String {
pick(ko: "추천된 크리에이터를 모두 팔로우합니다.", en: "Youll follow all recommended creators.", ja: "おすすめのクリエイターをすべてフォローします。")
}
static var followAllConfirmAction: String {
pick(ko: "팔로우 하기", en: "Follow", ja: "フォローする")
}
static var more: String { static var more: String {
pick(ko: "더보기", en: "More", ja: "もっと見る") pick(ko: "더보기", en: "More", ja: "もっと見る")
} }

View File

@@ -0,0 +1,15 @@
import CoreGraphics
enum ImageCropGeometry {
static func maximumCropSize(fitting imageSize: CGSize, aspectRatio: CGFloat) -> CGSize {
guard imageSize.width > 0, imageSize.height > 0, aspectRatio > 0 else {
return .zero
}
if imageSize.width / imageSize.height > aspectRatio {
return CGSize(width: imageSize.height * aspectRatio, height: imageSize.height)
}
return CGSize(width: imageSize.width, height: imageSize.width / aspectRatio)
}
}

View File

@@ -274,27 +274,35 @@ struct ImageCropEditorView: View {
private func cropSize(in canvas: CGSize) -> CGSize { private func cropSize(in canvas: CGSize) -> CGSize {
switch aspectPolicy { switch aspectPolicy {
case .square: case .square:
let side = max(120, min(canvas.width, canvas.height) * 0.72) let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
return CGSize(width: side, height: side) return ImageCropGeometry.maximumCropSize(fitting: fittedSize, aspectRatio: 1)
case .free: case .free:
if freeCropSize == .zero { if freeCropSize == .zero {
return defaultFreeCropSize(in: canvas) return defaultFreeCropSize(in: canvas)
} }
let minCropSize: CGFloat = 120 let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
let maxCropWidth = max(minCropSize, canvas.width - 24) let maxCropWidth = fittedSize.width
let maxCropHeight = max(minCropSize, canvas.height - 24) let maxCropHeight = fittedSize.height
let minCropWidth = min(120, maxCropWidth)
let minCropHeight = min(120, maxCropHeight)
return CGSize( return CGSize(
width: freeCropSize.width.clamped(min: minCropSize, max: maxCropWidth), width: freeCropSize.width.clamped(min: minCropWidth, max: maxCropWidth),
height: freeCropSize.height.clamped(min: minCropSize, max: maxCropHeight) height: freeCropSize.height.clamped(min: minCropHeight, max: maxCropHeight)
) )
} }
} }
private func defaultFreeCropSize(in canvas: CGSize) -> CGSize { private func defaultFreeCropSize(in canvas: CGSize) -> CGSize {
let width = max(120, min(canvas.width * 0.82, canvas.width - 24)) let preferredSize = CGSize(
let height = max(120, min(canvas.height * 0.58, canvas.height - 24)) width: max(120, min(canvas.width * 0.82, canvas.width - 24)),
return CGSize(width: width, height: height) height: max(120, min(canvas.height * 0.58, canvas.height - 24))
)
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
return ImageCropGeometry.maximumCropSize(
fitting: fittedSize,
aspectRatio: preferredSize.width / preferredSize.height
)
} }
private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint { private func handlePosition(handle: CropHandle, cropSize: CGSize, canvasSize: CGSize) -> CGPoint {
@@ -318,14 +326,16 @@ struct ImageCropEditorView: View {
lastFreeCropSize = currentCropSize lastFreeCropSize = currentCropSize
} }
let minCropSize: CGFloat = 120 let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize)
let maxCropWidth = max(minCropSize, canvasSize.width - 24) let maxCropWidth = fittedSize.width
let maxCropHeight = max(minCropSize, canvasSize.height - 24) let maxCropHeight = fittedSize.height
let minCropWidth = min(120, maxCropWidth)
let minCropHeight = min(120, maxCropHeight)
let width = (lastFreeCropSize.width + (translation.width * handle.xDirection)) let width = (lastFreeCropSize.width + (translation.width * handle.xDirection))
.clamped(min: minCropSize, max: maxCropWidth) .clamped(min: minCropWidth, max: maxCropWidth)
let height = (lastFreeCropSize.height + (translation.height * handle.yDirection)) let height = (lastFreeCropSize.height + (translation.height * handle.yDirection))
.clamped(min: minCropSize, max: maxCropHeight) .clamped(min: minCropHeight, max: maxCropHeight)
freeCropSize = CGSize(width: width, height: height) freeCropSize = CGSize(width: width, height: height)
adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize) adjustTransforms(canvasSize: canvasSize, cropSize: freeCropSize)

View File

@@ -60,13 +60,6 @@ struct LiveView: View {
LazyVStack(spacing: 48) { LazyVStack(spacing: 48) {
SectionLiveNowView( SectionLiveNowView(
items: viewModel.liveNowItems, items: viewModel.liveNowItems,
onClickParticipant: {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
viewModel.enterLiveRoom(roomId: $0)
} else {
AppState.shared.setAppStep(step: .login)
}
},
onTapItem: { item in onTapItem: { item in
onTapLiveNowItem(item.roomId, item.isAdult) onTapLiveNowItem(item.roomId, item.isAdult)
}, },

View File

@@ -144,7 +144,6 @@ final class LiveViewModel: ObservableObject {
} else { } else {
if roomId > 0 { if roomId > 0 {
AppState.shared.isShowPlayer = true AppState.shared.isShowPlayer = true
AppState.shared.setAppStep(step: .main)
} }
} }
} else { } else {

View File

@@ -14,117 +14,92 @@ struct LiveNowAllItemView: View {
let itemWidth: CGFloat let itemWidth: CGFloat
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center, spacing: 14) {
ZStack {
KFImage(URL(string: item.coverImageUrl))
.cancelOnDisappear(true)
.downsampling(
size: CGSize(
width: itemWidth,
height: itemWidth * 144 / 102
)
)
.resizable()
.scaledToFill()
.frame(width: itemWidth, height: itemWidth * 144 / 102, alignment: .center)
.cornerRadius(4.7)
.clipped()
LinearGradient(
colors: [Color.black.opacity(0.1), Color.black.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .trailing, spacing: 0) {
HStack(spacing: 0) {
HStack(spacing: 1) {
if item.price > 0 {
Image("ic_can_white")
}
Text(item.price > 0 ? "\(item.price)" : I18n.CreateContent.free)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.white)
}
.padding(.horizontal, 7.3)
.padding(.vertical, 4)
.background(item.price > 0 ? Color.mainRed3 : Color.gray11)
.cornerRadius(13.3)
Spacer()
if item.isPrivateRoom {
Image("ic_lock")
.resizable()
.frame(width: 20, height: 20)
.padding(2.7)
.background(Color.gray33.opacity(0.7))
.clipShape(Circle())
}
}
.padding(.horizontal, 3.3)
.padding(.top, 3.3)
Spacer()
if item.numberOfPeople - item.numberOfParticipate < 3 {
HStack(spacing: 0) {
Spacer()
HStack(spacing: 2) {
Text(I18n.LiveNow.remaining)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.grayee)
Text("\(item.numberOfPeople - item.numberOfParticipate)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color.button)
}
.padding(.horizontal, 4)
.padding(.vertical, 3)
.background(Color.gray33.opacity(0.7))
.cornerRadius(13.3)
}
.padding(.horizontal, 3.3)
.padding(.bottom, 3.3)
}
}
}
.frame(width: itemWidth, height: itemWidth * 144 / 102)
Text("\(item.title)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "eeeeee"))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
if item.tags.count > 0 {
Text("\(item.tags.map { "#\($0)" }.joined(separator: " "))")
.appFont(size: 11, weight: .medium)
.foregroundColor(Color.button)
}
HStack(spacing: 5.3) {
KFImage(URL(string: item.creatorProfileImage)) KFImage(URL(string: item.creatorProfileImage))
.cancelOnDisappear(true) .cancelOnDisappear(true)
.downsampling( .downsampling(size: CGSize(width: 75, height: 75))
size: CGSize(
width: 21.3,
height: 21.3
)
)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: 21.3, height: 21.3, alignment: .center) .frame(width: 75, height: 75)
.clipShape(Circle()) .clipShape(Circle())
Text("\(item.creatorNickname)") VStack(alignment: .leading, spacing: 4) {
.appFont(size: 10, weight: .medium) HStack(spacing: 8) {
.foregroundColor(Color.gray77) liveTag
Text(startTimeText)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
}
Text(item.title)
.appFont(size: 18, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(item.creatorNickname)
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
priceView
}
.frame(width: itemWidth, height: 99)
}
private var liveTag: some View {
HStack(spacing: 4) {
Circle()
.fill(Color.red400)
.frame(width: 8, height: 8)
Text("LIVE")
.appFont(size: 12, weight: .regular)
.foregroundColor(.white)
}
.padding(.horizontal, 4)
.padding(.vertical, 2)
.frame(height: 18)
.background(Color.black)
.clipShape(Capsule())
}
@ViewBuilder
private var priceView: some View {
if item.price > 0 {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text("\(item.price)")
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
}
.padding(.trailing, 7)
.frame(width: 60, alignment: .trailing)
} else {
Text(I18n.CreateContent.free)
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.lineLimit(1)
.padding(.trailing, 7)
.frame(width: 60, alignment: .trailing)
} }
} }
.frame(width: itemWidth)
private var startTimeText: String {
guard let date = DateParser.parse(item.beginDateTimeUtc) else {
return item.beginDateTimeUtc
}
return date.convertDateFormat(dateFormat: "HH:mm")
} }
} }

View File

@@ -13,7 +13,6 @@ import BootpayUI
struct LiveNowAllView: View { struct LiveNowAllView: View {
@StateObject var viewModel = LiveViewModel() @StateObject var viewModel = LiveViewModel()
@StateObject var liveAllViewModel = LiveAllViewModel()
@StateObject var mypageViewModel = MyPageViewModel() @StateObject var mypageViewModel = MyPageViewModel()
let spacing: CGFloat = 16 let spacing: CGFloat = 16
@@ -26,15 +25,6 @@ struct LiveNowAllView: View {
@State private var pendingAction: (() -> Void)? = nil @State private var pendingAction: (() -> Void)? = nil
@State private var payload = Payload() @State private var payload = Payload()
var columns: [GridItem] {
[
GridItem(.flexible(), spacing: spacing, alignment: .top),
GridItem(.flexible(), spacing: spacing, alignment: .top)
]
}
let onClickParticipant: (Int) -> Void
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
ZStack { ZStack {
@@ -50,13 +40,13 @@ struct LiveNowAllView: View {
viewModel.getLiveNowList() viewModel.getLiveNowList()
}, },
content: { content: {
let itemWidth = (proxy.size.width - (spacing * 3)) / 2 let itemWidth = proxy.size.width - (spacing * 2)
LazyVGrid(columns: columns, spacing: spacing) { LazyVStack(spacing: spacing) {
ForEach(0..<viewModel.liveNowItems.count, id: \.self) { index in ForEach(0..<viewModel.liveNowItems.count, id: \.self) { index in
let item = viewModel.liveNowItems[index] let item = viewModel.liveNowItems[index]
LiveNowItemView(item: item, itemWidth: itemWidth) LiveNowAllItemView(item: item, itemWidth: itemWidth)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
handleLiveNowItemTap( handleLiveNowItemTap(
@@ -85,24 +75,6 @@ struct LiveNowAllView: View {
.edgesIgnoringSafeArea(.bottom) .edgesIgnoringSafeArea(.bottom)
} }
if liveAllViewModel.isShowLiveDetail {
LiveDetailView(
roomId: liveAllViewModel.selectedRoomId,
onClickParticipant: {
AppState.shared.isShowPlayer = false
onClickParticipant(liveAllViewModel.selectedRoomId)
},
onClickReservation: {},
onClickStart: {},
onClickCancel: {},
onClickClose: {
withAnimation {
liveAllViewModel.isShowLiveDetail = false
}
}
)
}
if isShowAuthConfirmView { if isShowAuthConfirmView {
SodaDialog( SodaDialog(
title: I18n.Main.Auth.dialogTitle, title: I18n.Main.Auth.dialogTitle,
@@ -120,6 +92,28 @@ struct LiveNowAllView: View {
textAlignment: .center textAlignment: .center
) )
} }
if viewModel.isShowPaymentDialog {
LivePaymentDialog(
title: viewModel.paymentDialogTitle,
desc: viewModel.paymentDialogDesc,
desc2: viewModel.paymentDialogDesc2,
confirmButtonTitle: viewModel.paymentDialogConfirmTitle,
confirmButtonAction: viewModel.paymentDialogConfirmAction,
cancelButtonTitle: viewModel.paymentDialogCancelTitle,
cancelButtonAction: viewModel.hidePopup,
startDateTime: viewModel.liveStartDate,
nowDateTime: viewModel.nowDate
)
}
if viewModel.isShowPasswordDialog {
LiveRoomPasswordDialog(
isShowing: $viewModel.isShowPasswordDialog,
can: viewModel.secretOrPasswordDialogCan,
confirmAction: viewModel.passwordDialogConfirmAction
)
}
} }
} }
.onAppear { .onAppear {
@@ -160,6 +154,7 @@ struct LiveNowAllView: View {
isShowAuthView = false isShowAuthView = false
} }
} }
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
} }
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) { private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
@@ -177,7 +172,7 @@ struct LiveNowAllView: View {
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR" let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false { if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) } pendingAction = { handleLiveNowItemTap(roomId: roomId, isAdult: isAdult) }
isShowAuthConfirmView = true isShowAuthConfirmView = true
return return
} }
@@ -191,13 +186,11 @@ struct LiveNowAllView: View {
} }
} }
openLiveDetail(roomId: roomId) enterLiveRoom(roomId: roomId)
} }
private func openLiveDetail(roomId: Int) { private func enterLiveRoom(roomId: Int) {
liveAllViewModel.selectedRoomId = roomId AppState.shared.isShowPlayer = false
withAnimation { viewModel.enterLiveRoom(roomId: roomId)
liveAllViewModel.isShowLiveDetail = true
}
} }
} }

View File

@@ -11,7 +11,6 @@ struct SectionLiveNowView: View {
let items: [GetRoomListResponse] let items: [GetRoomListResponse]
let onClickParticipant: (Int) -> Void
let onTapItem: (GetRoomListResponse) -> Void let onTapItem: (GetRoomListResponse) -> Void
let onTapCreateLive: () -> Void let onTapCreateLive: () -> Void
let onClickRefresh: () -> Void let onClickRefresh: () -> Void
@@ -29,7 +28,7 @@ struct SectionLiveNowView: View {
Text(I18n.Common.viewAll) Text(I18n.Common.viewAll)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C")) .foregroundColor(Color(hex: "78909C"))
.onTapGesture { AppState.shared.setAppStep(step: .liveNowAll(onClickParticipant: onClickParticipant)) } .onTapGesture { AppState.shared.setAppStep(step: .liveNowAll) }
} }
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
@@ -95,7 +94,6 @@ struct SectionLiveNowView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
SectionLiveNowView( SectionLiveNowView(
items: [], items: [],
onClickParticipant: { _ in },
onTapItem: { _ in }, onTapItem: { _ in },
onTapCreateLive: {}, onTapCreateLive: {},
onClickRefresh: {} onClickRefresh: {}

View File

@@ -272,6 +272,10 @@ struct LiveDetailView: View {
.onAppear { .onAppear {
viewModel.getDetail(roomId: roomId) viewModel.getDetail(roomId: roomId)
} }
.onChange(of: viewModel.isShowPopup) { isShowing in
guard isShowing, viewModel.room == nil else { return }
handleDetailLoadFailure()
}
} }
@ViewBuilder @ViewBuilder
@@ -473,6 +477,18 @@ struct LiveDetailView: View {
} }
} }
private func handleDetailLoadFailure() {
let message = viewModel.errorMessage
viewModel.isShowPopup = false
AppState.shared.isShowErrorPopup = false
hideView()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
AppState.shared.errorMessage = message
AppState.shared.isShowErrorPopup = true
}
}
private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] { private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] {
var items = [LiveDetailSnsItem]() var items = [LiveDetailSnsItem]()

View File

@@ -462,7 +462,6 @@ struct LiveRoomViewV2: View {
} }
) )
.padding(.top, isV2VCaptionVisible ? -13.3 : 0) .padding(.top, isV2VCaptionVisible ? -13.3 : 0)
.padding(.bottom, 10)
} }
if viewModel.isShowingNewChat { if viewModel.isShowingNewChat {
@@ -1004,7 +1003,6 @@ struct LiveRoomViewV2: View {
viewModel.setMute(true) viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true shouldRestoreMicMuteAfterCapture = true
} }
.ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init()) .edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
.sheet( .sheet(
isPresented: $viewModel.isShowShareView, isPresented: $viewModel.isShowShareView,
@@ -1113,6 +1111,8 @@ struct LiveRoomViewV2: View {
} }
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
} }
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat { private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
@@ -1198,6 +1198,7 @@ private final class ScreenCaptureSecureHostingController<Content: View>: UIViewC
hostingController = UIHostingController(rootView: rootView) hostingController = UIHostingController(rootView: rootView)
self.isSecureModeEnabled = isSecureModeEnabled self.isSecureModeEnabled = isSecureModeEnabled
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
hostingController.safeAreaRegions = .container
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {

View File

@@ -116,7 +116,10 @@ struct SplashView: View {
if !UserDefaults.string(forKey: UserDefaultsKey.token).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !UserDefaults.string(forKey: UserDefaultsKey.token).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if AppState.shared.pushRoomId > 0 { if AppState.shared.pushRoomId > 0 {
liveViewModel.enterLiveRoom(roomId: AppState.shared.pushRoomId) let roomId = AppState.shared.pushRoomId
AppState.shared.pushRoomId = 0
AppState.shared.setAppStep(step: .main)
liveViewModel.enterLiveRoom(roomId: roomId)
} else if AppState.shared.pushChannelId > 0 { } else if AppState.shared.pushChannelId > 0 {
AppState.shared.setAppStep(step: .creatorDetail(userId: AppState.shared.pushChannelId)) AppState.shared.setAppStep(step: .creatorDetail(userId: AppState.shared.pushChannelId))
} else if AppState.shared.pushAudioContentId > 0 { } else if AppState.shared.pushAudioContentId > 0 {

View File

@@ -22,6 +22,7 @@ struct BannerCarousel: View {
@GestureState private var dragOffset: CGFloat = 0 @GestureState private var dragOffset: CGFloat = 0
@State private var autoScrollTimer: Timer? @State private var autoScrollTimer: Timer?
@State private var isSwiping = false @State private var isSwiping = false
@State private var itemsRevision = 0
init( init(
items: [BannerCarouselItem], items: [BannerCarouselItem],
@@ -70,6 +71,7 @@ struct BannerCarousel: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
.simultaneousGesture(dragGesture(step: step)) .simultaneousGesture(dragGesture(step: step))
.onChange(of: items) { _ in .onChange(of: items) { _ in
itemsRevision += 1
displayIndex = items.count > 1 ? 1 : 0 displayIndex = items.count > 1 ? 1 : 0
restartAutoScrollTimer() restartAutoScrollTimer()
} }
@@ -179,8 +181,15 @@ struct BannerCarousel: View {
private func wrapIfNeeded() { private func wrapIfNeeded() {
guard items.count > 1 else { return } guard items.count > 1 else { return }
displayIndex = min(max(displayIndex, 0), items.count + 1)
let scheduledIndex = displayIndex
let scheduledRevision = itemsRevision
if displayIndex == 0 { if displayIndex == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) { DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
guard displayIndex == scheduledIndex, itemsRevision == scheduledRevision else { return }
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true
withTransaction(transaction) { withTransaction(transaction) {
@@ -189,6 +198,8 @@ struct BannerCarousel: View {
} }
} else if displayIndex == items.count + 1 { } else if displayIndex == items.count + 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) { DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
guard displayIndex == scheduledIndex, itemsRevision == scheduledRevision else { return }
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true
withTransaction(transaction) { withTransaction(transaction) {

View File

@@ -1,11 +1,26 @@
import SwiftUI import SwiftUI
struct CreatorChannelFloatingActionMenu: View { struct CreatorContentCreationFloatingActionMenu: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
let bottomPadding: CGFloat
let onTapCommunityPost: () -> Void let onTapCommunityPost: () -> Void
let onTapAudioContent: () -> Void let onTapAudioContent: () -> Void
let onTapCreateLive: () -> Void let onTapCreateLive: () -> Void
init(
isPresented: Binding<Bool>,
bottomPadding: CGFloat = 0,
onTapCommunityPost: @escaping () -> Void,
onTapAudioContent: @escaping () -> Void,
onTapCreateLive: @escaping () -> Void
) {
_isPresented = isPresented
self.bottomPadding = bottomPadding
self.onTapCommunityPost = onTapCommunityPost
self.onTapAudioContent = onTapAudioContent
self.onTapCreateLive = onTapCreateLive
}
private let animation = Animation.interpolatingSpring(mass: 1, stiffness: 256, damping: 24, initialVelocity: 0) private let animation = Animation.interpolatingSpring(mass: 1, stiffness: 256, damping: 24, initialVelocity: 0)
var body: some View { var body: some View {
@@ -66,6 +81,7 @@ struct CreatorChannelFloatingActionMenu: View {
} }
} }
.padding(.trailing, SodaSpacing.s14) .padding(.trailing, SodaSpacing.s14)
.padding(.bottom, bottomPadding)
.animation(animation, value: isPresented) .animation(animation, value: isPresented)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
@@ -73,11 +89,11 @@ struct CreatorChannelFloatingActionMenu: View {
} }
struct CreatorChannelFloatingActionMenu_Previews: PreviewProvider { struct CreatorContentCreationFloatingActionMenu_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
CreatorChannelFloatingActionMenu( CreatorContentCreationFloatingActionMenu(
isPresented: .constant(false), isPresented: .constant(false),
onTapCommunityPost: {}, onTapCommunityPost: {},
onTapAudioContent: {}, onTapAudioContent: {},

View File

@@ -69,6 +69,8 @@ struct CommunityPostCard: View {
if hasImage { if hasImage {
imageContent imageContent
} else if isLocked {
lockedTextPurchaseButton
} }
if isAccessiblePost { if isAccessiblePost {
@@ -172,6 +174,31 @@ struct CommunityPostCard: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private var lockedTextPurchaseButton: some View {
Button(action: onTapPurchase) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 18, height: 18)
Image("ic_bar_cash")
.resizable()
.frame(width: 20, height: 20)
Text("\(price)")
.appFont(.body2)
.foregroundColor(.black)
}
.padding(.vertical, SodaSpacing.s8)
.padding(.horizontal, SodaSpacing.s12)
.background(Color.white)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var audioPlayButton: some View { private var audioPlayButton: some View {
Button { Button {
guard let audioUrl, !audioUrl.isEmpty else { return } guard let audioUrl, !audioUrl.isEmpty else { return }
@@ -232,7 +259,7 @@ struct CommunityPostCard: View {
} }
private var isAccessiblePost: Bool { private var isAccessiblePost: Bool {
price == 0 || existOrdered price <= 0 || existOrdered
} }
private func toggleLike() { private func toggleLike() {
@@ -293,6 +320,21 @@ struct CommunityPostCard_Previews: PreviewProvider {
likeCount: 5, likeCount: 5,
commentCount: 6 commentCount: 6
) )
CommunityPostCard(
postId: 4,
creatorNickname: "크리에이터 이름",
creatorProfileImageUrl: previewImageUrl,
content: "이미지 없는 유료 커뮤니티 게시글입니다.",
imageUrl: nil,
audioUrl: nil,
price: 30,
existOrdered: false,
createdAt: "2분 전",
isLike: false,
likeCount: 5,
commentCount: 6
)
} }
.padding(SodaSpacing.s14) .padding(SodaSpacing.s14)
.background(Color.black) .background(Color.black)

View File

@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
struct ExpandableTextView: View { struct V2ExpandableTextView: View {
let text: String let text: String
let lineLimit: Int let lineLimit: Int
let moreTitle: String let moreTitle: String
@@ -71,9 +71,9 @@ struct ExpandableTextView: View {
} }
} }
struct ExpandableTextView_Previews: PreviewProvider { struct V2ExpandableTextView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ExpandableTextView(text: "긴 사업자 정보 텍스트가 들어가는 영역입니다. 세 줄을 넘어가면 더보기 버튼이 표시되고, 더보기 버튼을 누르면 전체 문구가 표시됩니다.") V2ExpandableTextView(text: "긴 사업자 정보 텍스트가 들어가는 영역입니다. 세 줄을 넘어가면 더보기 버튼이 표시되고, 더보기 버튼을 누르면 전체 문구가 표시됩니다.")
.padding(SodaSpacing.s20) .padding(SodaSpacing.s20)
.background(Color.black) .background(Color.black)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)

View File

@@ -4,6 +4,7 @@ import Kingfisher
struct CreatorChannelHeaderSection: View { struct CreatorChannelHeaderSection: View {
let creator: CreatorChannelCreatorResponse let creator: CreatorChannelCreatorResponse
let isOwnCreatorChannel: Bool let isOwnCreatorChannel: Bool
let onTapFollowerCount: () -> Void
let onTapTalk: () -> Void let onTapTalk: () -> Void
let onTapDm: () -> Void let onTapDm: () -> Void
@@ -46,6 +47,7 @@ struct CreatorChannelHeaderSection: View {
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.onTapGesture(perform: onTapFollowerCount)
Text(creator.nickname) Text(creator.nickname)
.appFont(size: 32, weight: .bold) .appFont(size: 32, weight: .bold)

View File

@@ -90,7 +90,7 @@ struct CreatorChannelView: View {
} }
if isOwnCreatorChannel && viewModel.selectedTab == .home && !viewModel.isLoading { if isOwnCreatorChannel && viewModel.selectedTab == .home && !viewModel.isLoading {
CreatorChannelFloatingActionMenu( CreatorContentCreationFloatingActionMenu(
isPresented: $isCreatorActionMenuPresented, isPresented: $isCreatorActionMenuPresented,
onTapCommunityPost: showCommunityWrite, onTapCommunityPost: showCommunityWrite,
onTapAudioContent: showAudioContentUpload, onTapAudioContent: showAudioContentUpload,
@@ -237,6 +237,11 @@ struct CreatorChannelView: View {
CreatorChannelHeaderSection( CreatorChannelHeaderSection(
creator: creator, creator: creator,
isOwnCreatorChannel: isOwnCreatorChannel, isOwnCreatorChannel: isOwnCreatorChannel,
onTapFollowerCount: {
if isOwnCreatorChannel {
AppState.shared.setAppStep(step: .followerList(userId: creator.creatorId))
}
},
onTapTalk: handleTalkTap, onTapTalk: handleTalkTap,
onTapDm: handleDmTap onTapDm: handleDmTap
) )
@@ -506,9 +511,9 @@ struct CreatorChannelView: View {
guard targetId > 0 else { return } guard targetId > 0 else { return }
switch type { switch type {
case .live, .liveReplay: case .live:
showLiveDetail(targetId) showLiveDetail(targetId)
case .audio: case .audio, .liveReplay:
showContentDetail(targetId) showContentDetail(targetId)
case .community: case .community:
showCommunity(targetId) showCommunity(targetId)
@@ -534,7 +539,7 @@ struct CreatorChannelView: View {
if isKoreanCountry && auth == false { if isKoreanCountry && auth == false {
pendingAction = { pendingAction = {
startChat(characterId: characterId) handleTalkTap()
} }
isShowAuthConfirmView = true isShowAuthConfirmView = true
return return

View File

@@ -1,10 +1,17 @@
import SwiftUI import SwiftUI
struct CreatorChannelDonationSection: View { struct CreatorChannelDonationSection: View {
private static let baseDeviceWidth: CGFloat = 402
private static let maxCardWidth: CGFloat = 374
let channelDonations: [CreatorChannelDonationResponse] let channelDonations: [CreatorChannelDonationResponse]
let onSelectTab: (CreatorChannelTab) -> Void let onSelectTab: (CreatorChannelTab) -> Void
let onTapDonate: () -> Void let onTapDonate: () -> Void
private var cardWidth: CGFloat {
Self.maxCardWidth * min(UIScreen.main.bounds.width / Self.baseDeviceWidth, 1)
}
init( init(
channelDonations: [CreatorChannelDonationResponse], channelDonations: [CreatorChannelDonationResponse],
onSelectTab: @escaping (CreatorChannelTab) -> Void, onSelectTab: @escaping (CreatorChannelTab) -> Void,
@@ -29,7 +36,7 @@ struct CreatorChannelDonationSection: View {
HStack(alignment: .top, spacing: SodaSpacing.s4) { HStack(alignment: .top, spacing: SodaSpacing.s4) {
ForEach(channelDonations.indices, id: \.self) { index in ForEach(channelDonations.indices, id: \.self) { index in
CreatorChannelDonationCard(donation: channelDonations[index]) CreatorChannelDonationCard(donation: channelDonations[index])
.frame(width: 374) .frame(width: cardWidth)
} }
} }
.padding(.leading, SodaSpacing.s14) .padding(.leading, SodaSpacing.s14)

View File

@@ -10,6 +10,7 @@ struct MainHomeView: View {
let onTapCharacter: (Int) -> Void let onTapCharacter: (Int) -> Void
let onTapCommunityPost: (Int) -> Void let onTapCommunityPost: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void let onTapBanner: (RecommendationBannerResponse) -> Void
let onTapLiveAll: () -> Void
let onTapFollowAll: (@escaping () -> Void) -> Void let onTapFollowAll: (@escaping () -> Void) -> Void
let onSelectFollowingTab: () -> Bool let onSelectFollowingTab: () -> Bool
let onTapFollowingLogin: () -> Void let onTapFollowingLogin: () -> Void
@@ -71,6 +72,7 @@ struct MainHomeView: View {
onTapCharacter: onTapCharacter, onTapCharacter: onTapCharacter,
onTapCommunityPost: onTapCommunityPost, onTapCommunityPost: onTapCommunityPost,
onTapBanner: onTapBanner, onTapBanner: onTapBanner,
onTapLiveAll: onTapLiveAll,
onTapFollowAll: onTapFollowAll onTapFollowAll: onTapFollowAll
) )
case .ranking: case .ranking:
@@ -99,11 +101,11 @@ private enum MainHomeTab: CaseIterable, Hashable {
var title: String { var title: String {
switch self { switch self {
case .recommendation: case .recommendation:
return "추천" return I18n.MainContentRecommendation.tabTitle
case .ranking: case .ranking:
return "랭킹" return I18n.MainContentRanking.tabTitle
case .following: case .following:
return "팔로잉" return I18n.HomeFollowing.tabTitle
} }
} }
} }
@@ -120,6 +122,7 @@ struct MainHomeView_Previews: PreviewProvider {
onTapCharacter: { _ in }, onTapCharacter: { _ in },
onTapCommunityPost: { _ in }, onTapCommunityPost: { _ in },
onTapBanner: { _ in }, onTapBanner: { _ in },
onTapLiveAll: {},
onTapFollowAll: { action in action() }, onTapFollowAll: { action in action() },
onSelectFollowingTab: { true }, onSelectFollowingTab: { true },
onTapFollowingLogin: {}, onTapFollowingLogin: {},

View File

@@ -3,17 +3,20 @@ import SwiftUI
struct MainHomeActiveCreatorSection: View { struct MainHomeActiveCreatorSection: View {
let items: [HomeActiveCreatorItem] let items: [HomeActiveCreatorItem]
let onTapLive: (Int) -> Void let onTapLive: (Int) -> Void
let onTapCreator: (Int) -> Void
let onTapContent: (Int) -> Void let onTapContent: (Int) -> Void
let onTapCommunityPost: (Int) -> Void let onTapCommunityPost: (Int) -> Void
init( init(
items: [HomeActiveCreatorItem], items: [HomeActiveCreatorItem],
onTapLive: @escaping (Int) -> Void = { _ in }, onTapLive: @escaping (Int) -> Void = { _ in },
onTapCreator: @escaping (Int) -> Void = { _ in },
onTapContent: @escaping (Int) -> Void = { _ in }, onTapContent: @escaping (Int) -> Void = { _ in },
onTapCommunityPost: @escaping (Int) -> Void = { _ in } onTapCommunityPost: @escaping (Int) -> Void = { _ in }
) { ) {
self.items = items self.items = items
self.onTapLive = onTapLive self.onTapLive = onTapLive
self.onTapCreator = onTapCreator
self.onTapContent = onTapContent self.onTapContent = onTapContent
self.onTapCommunityPost = onTapCommunityPost self.onTapCommunityPost = onTapCommunityPost
} }
@@ -38,16 +41,16 @@ struct MainHomeActiveCreatorSection: View {
} }
private func handleTap(_ item: HomeActiveCreatorItem) { private func handleTap(_ item: HomeActiveCreatorItem) {
guard let targetId = item.targetId else { return } switch item.activeCreatorTapDestination {
case .live(let roomId):
switch item.activityType { onTapLive(roomId)
case .live: case .creator(let creatorId):
onTapLive(targetId) onTapCreator(creatorId)
case .audio: case .content(let contentId):
onTapContent(targetId) onTapContent(contentId)
case .community: case .communityPost(let postId):
onTapCommunityPost(targetId) onTapCommunityPost(postId)
case .unknown: case nil:
break break
} }
} }
@@ -84,7 +87,7 @@ private struct MainHomeActiveCreatorItemView: View {
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous))
} }
Text(item.relativeActivityAtText()) Text(DateParser.relativeTimeText(fromUTC: item.activityAt, fallback: item.activityAt))
.appFont(.body4) .appFont(.body4)
.foregroundColor(Color.gray500) .foregroundColor(Color.gray500)
.lineLimit(1) .lineLimit(1)
@@ -100,40 +103,6 @@ private struct MainHomeActiveCreatorItemView: View {
} }
} }
private extension HomeActiveCreatorItem {
func relativeActivityAtText(now: Date = Date()) -> String {
guard let activityDate = DateParser.parse(activityAt) else {
return activityAt
}
let interval = max(0, now.timeIntervalSince(activityDate))
let calendar = Calendar.current
let yearMonth = calendar.dateComponents([.year, .month], from: activityDate, to: now)
if let years = yearMonth.year, years >= 1 {
return I18n.Time.yearsAgo(years)
}
if let months = yearMonth.month, months >= 1 {
return I18n.Time.monthsAgo(months)
}
if interval < 60 {
return I18n.Time.justNow
}
if interval < 3600 {
return I18n.Time.minutesAgo(max(1, Int(interval / 60)))
}
if interval < 86_400 {
return I18n.Time.hoursAgo(max(1, Int(interval / 3600)))
}
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
}
}
struct MainHomeActiveCreatorSection_Previews: PreviewProvider { struct MainHomeActiveCreatorSection_Previews: PreviewProvider {
private static let previewImageUrl = "https://picsum.photos/500/500" private static let previewImageUrl = "https://picsum.photos/500/500"
@@ -141,6 +110,7 @@ struct MainHomeActiveCreatorSection_Previews: PreviewProvider {
MainHomeActiveCreatorSection( MainHomeActiveCreatorSection(
items: [ items: [
HomeActiveCreatorItem( HomeActiveCreatorItem(
creatorId: 1,
creatorNickname: "크리에이터이름", creatorNickname: "크리에이터이름",
creatorProfileImage: previewImageUrl, creatorProfileImage: previewImageUrl,
activityType: .live, activityType: .live,
@@ -148,6 +118,7 @@ struct MainHomeActiveCreatorSection_Previews: PreviewProvider {
targetId: 1 targetId: 1
), ),
HomeActiveCreatorItem( HomeActiveCreatorItem(
creatorId: 2,
creatorNickname: "크리에이터이름", creatorNickname: "크리에이터이름",
creatorProfileImage: previewImageUrl, creatorProfileImage: previewImageUrl,
activityType: .audio, activityType: .audio,

View File

@@ -21,7 +21,7 @@ struct MainHomeAiCharacterSection: View {
LazyHStack(alignment: .center, spacing: SodaSpacing.s4) { LazyHStack(alignment: .center, spacing: SodaSpacing.s4) {
ForEach(items, id: \.self) { item in ForEach(items, id: \.self) { item in
Button { Button {
onTapCharacter(item.creatorId) onTapCharacter(item.characterId)
} label: { } label: {
AiCharacterCard( AiCharacterCard(
name: item.name, name: item.name,

View File

@@ -2,7 +2,7 @@ import SwiftUI
struct MainHomeBusinessInfoSection: View { struct MainHomeBusinessInfoSection: View {
var body: some View { var body: some View {
BusinessInfoExpandableTextView( V2ExpandableTextView(
text: I18n.Settings.companyInfo, text: I18n.Settings.companyInfo,
lineLimit: 3, lineLimit: 3,
moreTitle: I18n.HomeRecommendation.more, moreTitle: I18n.HomeRecommendation.more,
@@ -15,57 +15,6 @@ struct MainHomeBusinessInfoSection: View {
} }
} }
private struct BusinessInfoExpandableTextView: View {
let text: String
let lineLimit: Int
let moreTitle: String
let collapseTitle: String
let font: SodaTypography
let foregroundColor: Color
@State private var isExpanded = false
@State private var fullTextHeight: CGFloat = 0
@State private var limitedTextHeight: CGFloat = 0
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
Text(text)
.appFont(font)
.foregroundColor(foregroundColor)
.lineLimit(isExpanded ? nil : lineLimit)
.background(measuringText(lineLimit: nil, height: $fullTextHeight))
.background(measuringText(lineLimit: lineLimit, height: $limitedTextHeight))
if fullTextHeight > limitedTextHeight + 1 {
Button {
isExpanded.toggle()
} label: {
Text(isExpanded ? collapseTitle : moreTitle)
.appFont(.body5)
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
}
private func measuringText(lineLimit: Int?, height: Binding<CGFloat>) -> some View {
Text(text)
.appFont(font)
.lineLimit(lineLimit)
.fixedSize(horizontal: false, vertical: true)
.hidden()
.background(
GeometryReader { proxy in
Color.clear
.onAppear { height.wrappedValue = proxy.size.height }
.onChange(of: proxy.size.height) { newHeight in height.wrappedValue = newHeight }
.onChange(of: text) { _ in height.wrappedValue = proxy.size.height }
}
)
}
}
struct MainHomeBusinessInfoSection_Previews: PreviewProvider { struct MainHomeBusinessInfoSection_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
MainHomeBusinessInfoSection() MainHomeBusinessInfoSection()

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct MainHomeLiveSection: View { struct MainHomeLiveSection: View {
let items: [HomeLiveItem] let items: [HomeLiveItem]
let onTapLive: (Int) -> Void let onTapLive: (Int) -> Void
let onTapAll: () -> Void
var body: some View { var body: some View {
if !items.isEmpty { if !items.isEmpty {
@@ -11,6 +12,8 @@ struct MainHomeLiveSection: View {
ForEach(items, id: \.self) { item in ForEach(items, id: \.self) { item in
MainHomeLiveItemView(item: item, onTapLive: onTapLive) MainHomeLiveItemView(item: item, onTapLive: onTapLive)
} }
MainHomeLiveAllItemView(onTapAll: onTapAll)
} }
.padding(.horizontal, SodaSpacing.s20) .padding(.horizontal, SodaSpacing.s20)
} }
@@ -54,6 +57,20 @@ private struct MainHomeLiveItemView: View {
} }
} }
private struct MainHomeLiveAllItemView: View {
let onTapAll: () -> Void
var body: some View {
Button(action: onTapAll) {
Text(I18n.HomeRecommendation.liveAll)
.appFont(.body5)
.foregroundColor(Color.soda400)
.frame(width: 70, height: 102, alignment: .center)
}
.buttonStyle(.plain)
}
}
struct MainHomeLiveSection_Previews: PreviewProvider { struct MainHomeLiveSection_Previews: PreviewProvider {
private static let previewImageUrl = "https://picsum.photos/500/500" private static let previewImageUrl = "https://picsum.photos/500/500"
@@ -72,7 +89,8 @@ struct MainHomeLiveSection_Previews: PreviewProvider {
HomeLiveItem(roomId: 1, creatorNickname: "설린", creatorProfileImage: previewImageUrl), HomeLiveItem(roomId: 1, creatorNickname: "설린", creatorProfileImage: previewImageUrl),
HomeLiveItem(roomId: 2, creatorNickname: "크리에이터", creatorProfileImage: previewImageUrl) HomeLiveItem(roomId: 2, creatorNickname: "크리에이터", creatorProfileImage: previewImageUrl)
], ],
onTapLive: { _ in } onTapLive: { _ in },
onTapAll: {}
) )
.padding(.vertical, SodaSpacing.s20) .padding(.vertical, SodaSpacing.s20)
.background(Color.black) .background(Color.black)

View File

@@ -7,6 +7,7 @@ struct MainHomeRecommendationView: View {
let onTapCharacter: (Int) -> Void let onTapCharacter: (Int) -> Void
let onTapCommunityPost: (Int) -> Void let onTapCommunityPost: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void let onTapBanner: (RecommendationBannerResponse) -> Void
let onTapLiveAll: () -> Void
let onTapFollowAll: (@escaping () -> Void) -> Void let onTapFollowAll: (@escaping () -> Void) -> Void
@StateObject private var viewModel = MainHomeRecommendationViewModel() @StateObject private var viewModel = MainHomeRecommendationViewModel()
@@ -25,7 +26,8 @@ struct MainHomeRecommendationView: View {
VStack(alignment: .leading, spacing: SodaSpacing.s24) { VStack(alignment: .leading, spacing: SodaSpacing.s24) {
MainHomeLiveSection( MainHomeLiveSection(
items: viewModel.recommendations?.lives ?? [], items: viewModel.recommendations?.lives ?? [],
onTapLive: onTapLive onTapLive: onTapLive,
onTapAll: onTapLiveAll
) )
MainHomeBannerSection( MainHomeBannerSection(
@@ -36,6 +38,7 @@ struct MainHomeRecommendationView: View {
MainHomeActiveCreatorSection( MainHomeActiveCreatorSection(
items: viewModel.recommendations?.recentlyActiveCreators ?? [], items: viewModel.recommendations?.recentlyActiveCreators ?? [],
onTapLive: onTapLive, onTapLive: onTapLive,
onTapCreator: onTapCreator,
onTapContent: onTapContent, onTapContent: onTapContent,
onTapCommunityPost: onTapCommunityPost onTapCommunityPost: onTapCommunityPost
) )
@@ -122,6 +125,7 @@ struct MainHomeRecommendationView_Previews: PreviewProvider {
onTapCharacter: { _ in }, onTapCharacter: { _ in },
onTapCommunityPost: { _ in }, onTapCommunityPost: { _ in },
onTapBanner: { _ in }, onTapBanner: { _ in },
onTapLiveAll: {},
onTapFollowAll: { action in action() } onTapFollowAll: { action in action() }
) )
} }

View File

@@ -48,6 +48,7 @@ final class MainHomeRecommendationViewModel: ObservableObject {
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }

View File

@@ -5,7 +5,6 @@ struct HomeRecommendationResponse: Decodable {
let banners: [RecommendationBannerResponse] let banners: [RecommendationBannerResponse]
let recentlyActiveCreators: [HomeActiveCreatorItem] let recentlyActiveCreators: [HomeActiveCreatorItem]
let recentDebutCreators: [HomeCreatorItem] let recentDebutCreators: [HomeCreatorItem]
let firstAudioContents: [HomeFirstAudioContentItem]
let aiCharacters: [HomeAiCharacterItem] let aiCharacters: [HomeAiCharacterItem]
let genreCreators: [HomeGenreCreatorGroupItem] let genreCreators: [HomeGenreCreatorGroupItem]
let cheerCreators: [HomeCreatorItem] let cheerCreators: [HomeCreatorItem]
@@ -27,6 +26,7 @@ struct RecommendationBannerResponse: Decodable, Hashable {
} }
struct HomeActiveCreatorItem: Decodable, Hashable { struct HomeActiveCreatorItem: Decodable, Hashable {
let creatorId: Int
let creatorNickname: String let creatorNickname: String
let creatorProfileImage: String let creatorProfileImage: String
let activityType: RecommendedActivityType let activityType: RecommendedActivityType
@@ -34,23 +34,38 @@ struct HomeActiveCreatorItem: Decodable, Hashable {
let targetId: Int? let targetId: Int?
} }
enum HomeActiveCreatorTapDestination: Equatable {
case live(Int)
case creator(Int)
case content(Int)
case communityPost(Int)
}
extension HomeActiveCreatorItem {
var activeCreatorTapDestination: HomeActiveCreatorTapDestination? {
switch activityType {
case .live:
if let targetId {
return .live(targetId)
}
return creatorId > 0 ? .creator(creatorId) : nil
case .liveReplay, .audio:
return targetId.map(HomeActiveCreatorTapDestination.content)
case .community:
return targetId.map(HomeActiveCreatorTapDestination.communityPost)
case .unknown:
return nil
}
}
}
struct HomeCreatorItem: Decodable, Hashable { struct HomeCreatorItem: Decodable, Hashable {
let creatorId: Int let creatorId: Int
let creatorNickname: String let creatorNickname: String
let creatorProfileImage: String let creatorProfileImage: String
} }
struct HomeFirstAudioContentItem: Decodable, Hashable {
let contentId: Int
let creatorId: Int
let creatorNickname: String
let creatorProfileImage: String
let title: String
let price: Int
let coverImage: String?
let isPointAvailable: Bool
}
struct HomeAiCharacterItem: Decodable, Hashable { struct HomeAiCharacterItem: Decodable, Hashable {
let characterId: Int let characterId: Int
let creatorId: Int let creatorId: Int

View File

@@ -2,6 +2,7 @@ import Foundation
enum RecommendedActivityType: Decodable, Hashable { enum RecommendedActivityType: Decodable, Hashable {
case live case live
case liveReplay
case audio case audio
case community case community
case unknown(String) case unknown(String)
@@ -11,8 +12,10 @@ enum RecommendedActivityType: Decodable, Hashable {
let code = try container.decode(String.self) let code = try container.decode(String.self)
switch code { switch code {
case "LIVE", "LIVE_REPLAY": case "LIVE":
self = .live self = .live
case "LIVE_REPLAY":
self = .liveReplay
case "AUDIO": case "AUDIO":
self = .audio self = .audio
case "COMMUNITY": case "COMMUNITY":
@@ -24,7 +27,7 @@ enum RecommendedActivityType: Decodable, Hashable {
var title: String { var title: String {
switch self { switch self {
case .live: case .live, .liveReplay:
return I18n.HomeRecommendation.activityLive return I18n.HomeRecommendation.activityLive
case .audio: case .audio:
return I18n.HomeRecommendation.activityAudio return I18n.HomeRecommendation.activityAudio

View File

@@ -24,11 +24,15 @@ struct MainView: View {
@State private var isShowPlayer = false @State private var isShowPlayer = false
@State private var isShowAuthView = false @State private var isShowAuthView = false
@State private var isShowAuthConfirmView = false @State private var isShowAuthConfirmView = false
@State private var isShowRecommendationFollowAllConfirmDialog = false
@State private var isCreatorActionMenuPresented = false
@State private var pendingAction: (() -> Void)? = nil @State private var pendingAction: (() -> Void)? = nil
@State private var isShowLeaveLiveNavigationDialog = false @State private var pendingRecommendationFollowAllAction: (() -> Void)? = nil
@State private var pendingExternalNavigationAction: (() -> Void)? = nil
@State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil
@State private var payload = Payload() @State private var payload = Payload()
@State private var hasLoadedMainData = false
private let mainTabBarHeight: CGFloat = 66
private let miniPlayerHeight: CGFloat = 58.1
var body: some View { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
@@ -54,13 +58,16 @@ struct MainView: View {
} }
.onAppear { .onAppear {
configurePayload() configurePayload()
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
guard !hasLoadedMainData else { return }
guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
hasLoadedMainData = true
pushTokenUpdate() pushTokenUpdate()
legacyHomeViewModel.getMemberInfo() legacyHomeViewModel.getMemberInfo()
legacyHomeViewModel.getEventPopup() legacyHomeViewModel.getEventPopup()
legacyHomeViewModel.addAllPlaybackTracking() legacyHomeViewModel.addAllPlaybackTracking()
} }
}
if appState.isShowNotificationSettingsDialog { if appState.isShowNotificationSettingsDialog {
NotificationSettingsDialog() NotificationSettingsDialog()
@@ -70,6 +77,10 @@ struct MainView: View {
authConfirmDialog authConfirmDialog
} }
if isShowRecommendationFollowAllConfirmDialog {
recommendationFollowAllConfirmDialog
}
if liveViewModel.isShowPaymentDialog { if liveViewModel.isShowPaymentDialog {
LivePaymentDialog( LivePaymentDialog(
title: liveViewModel.paymentDialogTitle, title: liveViewModel.paymentDialogTitle,
@@ -97,23 +108,25 @@ struct MainView: View {
Spacer() Spacer()
EventPopupDialogView(eventPopup: eventItem) EventPopupDialogView(eventPopup: eventItem)
} }
.background(Color.black) .background(Color.black.opacity(0.6))
.onTapGesture { .onTapGesture {
appState.eventPopup = nil appState.eventPopup = nil
} }
} }
if shouldShowCreatorActionMenu {
CreatorContentCreationFloatingActionMenu(
isPresented: $isCreatorActionMenuPresented,
bottomPadding: creatorActionMenuBottomPadding,
onTapCommunityPost: showMainCommunityWrite,
onTapAudioContent: showMainAudioContentUpload,
onTapCreateLive: showMainCreateLive
)
}
if isShowPlayer { if isShowPlayer {
ContentPlayerView(isShowing: $isShowPlayer, playlist: []) ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
} }
if appState.isShowPlayer {
LiveRoomViewV2()
}
if isShowLeaveLiveNavigationDialog {
leaveLiveNavigationDialog
}
} }
.fullScreenCover(isPresented: $isShowAuthView) { .fullScreenCover(isPresented: $isShowAuthView) {
authView authView
@@ -124,15 +137,9 @@ struct MainView: View {
.valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) } .valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) }
.valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) } .valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) }
.valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) } .valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) }
.valueChanged(value: appState.isShowPlayer) { isShowPlayer in .onChange(of: viewModel.currentTab) { currentTab in
guard !isShowPlayer, if currentTab != .home {
let pendingExternalNavigationAction = pendingExternalNavigationAction else { isCreatorActionMenuPresented = false
return
}
self.pendingExternalNavigationAction = nil
self.pendingExternalNavigationCancelAction = nil
DispatchQueue.main.async {
pendingExternalNavigationAction()
} }
} }
.onAppear { .onAppear {
@@ -150,6 +157,21 @@ struct MainView: View {
} }
} }
private var shouldShowCreatorActionMenu: Bool {
UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue && viewModel.currentTab == .home
}
private var creatorActionMenuBottomPadding: CGFloat {
var bottomPadding = mainTabBarHeight + SodaSpacing.s14
if contentPlayerPlayManager.isShowingMiniPlayer {
bottomPadding += miniPlayerHeight
}
if contentPlayManager.isShowingMiniPlayer {
bottomPadding += miniPlayerHeight
}
return bottomPadding
}
@ViewBuilder @ViewBuilder
private var contentView: some View { private var contentView: some View {
switch viewModel.currentTab { switch viewModel.currentTab {
@@ -161,9 +183,10 @@ struct MainView: View {
onTapLive: handleRecommendationLiveTap, onTapLive: handleRecommendationLiveTap,
onTapCreator: handleRecommendationCreatorTap, onTapCreator: handleRecommendationCreatorTap,
onTapContent: handleRecommendationContentTap, onTapContent: handleRecommendationContentTap,
onTapCharacter: handleRecommendationCreatorTap, onTapCharacter: handleRecommendationCharacterTap,
onTapCommunityPost: handleRecommendationCommunityPostTap, onTapCommunityPost: handleRecommendationCommunityPostTap,
onTapBanner: handleRecommendationBannerTap, onTapBanner: handleRecommendationBannerTap,
onTapLiveAll: handleRecommendationLiveAllTap,
onTapFollowAll: handleRecommendationFollowAllTap, onTapFollowAll: handleRecommendationFollowAllTap,
onSelectFollowingTab: handleFollowingTabSelection, onSelectFollowingTab: handleFollowingTabSelection,
onTapFollowingLogin: handleFollowingLoginTap, onTapFollowingLogin: handleFollowingLoginTap,
@@ -306,22 +329,34 @@ struct MainView: View {
pendingAction = nil pendingAction = nil
} }
private var leaveLiveNavigationDialog: some View { private var recommendationFollowAllConfirmDialog: some View {
SodaV2ActionModal( SodaV2ActionModal(
title: I18n.Common.alertTitle, title: I18n.HomeRecommendation.followAllConfirmTitle,
message: I18n.LiveRoom.leaveLiveForNavigationDesc, message: I18n.HomeRecommendation.followAllConfirmDescription,
button1: SodaV2ActionModalButton( button1: SodaV2ActionModalButton(
label: I18n.Common.confirm, label: I18n.HomeRecommendation.followAllConfirmAction,
action: confirmExternalNavigation action: confirmRecommendationFollowAll
), ),
button2: SodaV2ActionModalButton( button2: SodaV2ActionModalButton(
label: I18n.Common.cancel, label: I18n.Common.cancel,
action: cancelExternalNavigation action: dismissRecommendationFollowAllConfirmDialog
), ),
onDimmedTap: cancelExternalNavigation onDimmedTap: dismissRecommendationFollowAllConfirmDialog
) )
} }
private func confirmRecommendationFollowAll() {
let action = pendingRecommendationFollowAllAction
isShowRecommendationFollowAllConfirmDialog = false
pendingRecommendationFollowAllAction = nil
action?()
}
private func dismissRecommendationFollowAllConfirmDialog() {
isShowRecommendationFollowAllConfirmDialog = false
pendingRecommendationFollowAllAction = nil
}
private var authView: some View { private var authView: some View {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in true } .onConfirm { _ in true }
@@ -452,13 +487,10 @@ struct MainView: View {
cancelAction: @escaping () -> Void cancelAction: @escaping () -> Void
) { ) {
guard value > 0 else { return } guard value > 0 else { return }
if appState.isShowPlayer { appState.requestExternalNavigation(
pendingExternalNavigationAction = navigationAction navigationAction: navigationAction,
pendingExternalNavigationCancelAction = cancelAction cancelAction: cancelAction
isShowLeaveLiveNavigationDialog = true )
return
}
navigationAction()
} }
private func handleHomeCanChargeTap() { private func handleHomeCanChargeTap() {
@@ -486,6 +518,10 @@ struct MainView: View {
} }
} }
private func handleRecommendationLiveAllTap() {
appState.setAppStep(step: .liveNowAll)
}
private func handleRecommendationCreatorTap(creatorId: Int) { private func handleRecommendationCreatorTap(creatorId: Int) {
guard creatorId > 0 else { return } guard creatorId > 0 else { return }
performRecommendationDetailAction { performRecommendationDetailAction {
@@ -500,6 +536,13 @@ struct MainView: View {
} }
} }
private func handleRecommendationCharacterTap(characterId: Int) {
guard characterId > 0 else { return }
performRecommendationDetailAction(requiresAdultContent: true) {
appState.setAppStep(step: .characterDetail(characterId: characterId))
}
}
private func handleRecommendationCommunityPostTap(postId: Int) { private func handleRecommendationCommunityPostTap(postId: Int) {
guard postId > 0 else { return } guard postId > 0 else { return }
performRecommendationDetailAction { performRecommendationDetailAction {
@@ -508,7 +551,14 @@ struct MainView: View {
} }
private func handleRecommendationFollowAllTap(action: @escaping () -> Void) { private func handleRecommendationFollowAllTap(action: @escaping () -> Void) {
performRecommendationDetailAction(action: action) let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
AppState.shared.setAppStep(step: .login)
return
}
pendingRecommendationFollowAllAction = action
isShowRecommendationFollowAllConfirmDialog = true
} }
private func handleFollowingAllTap() { private func handleFollowingAllTap() {
@@ -533,9 +583,9 @@ struct MainView: View {
guard targetId > 0 else { return } guard targetId > 0 else { return }
switch type { switch type {
case .live, .liveReplay: case .live:
handleRecommendationLiveTap(roomId: targetId) handleRecommendationLiveTap(roomId: targetId)
case .audio: case .audio, .liveReplay:
handleRecommendationContentTap(contentId: targetId) handleRecommendationContentTap(contentId: targetId)
case .community: case .community:
performRecommendationDetailAction { performRecommendationDetailAction {
@@ -621,6 +671,28 @@ struct MainView: View {
} }
} }
private func showMainCommunityWrite() {
isCreatorActionMenuPresented = false
appState.setAppStep(step: .creatorCommunityWrite(onSuccess: {}))
}
private func showMainAudioContentUpload() {
isCreatorActionMenuPresented = false
appState.setAppStep(step: .createContent(onSuccess: {}))
}
private func showMainCreateLive() {
isCreatorActionMenuPresented = false
appState.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: handleMainCreateLiveSuccess))
}
private func handleMainCreateLiveSuccess(response: CreateLiveRoomResponse) {
liveViewModel.getLiveMain()
if let _ = response.channelName {
liveViewModel.enterRoom(roomId: response.id!)
}
}
private func openRecommendationBannerLink(_ link: String?) { private func openRecommendationBannerLink(_ link: String?) {
guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines), guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines),
!link.isEmpty else { !link.isEmpty else {
@@ -657,7 +729,9 @@ struct MainView: View {
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR" let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false { if isKoreanCountry && auth == false {
pendingAction = action pendingAction = {
performRecommendationDetailAction(requiresAdultContent: true, action: action)
}
isShowAuthConfirmView = true isShowAuthConfirmView = true
return return
} }
@@ -692,22 +766,6 @@ struct MainView: View {
AppState.shared.setAppStep(step: .contentViewSettings) AppState.shared.setAppStep(step: .contentViewSettings)
} }
private func confirmExternalNavigation() {
guard pendingExternalNavigationAction != nil else {
isShowLeaveLiveNavigationDialog = false
return
}
isShowLeaveLiveNavigationDialog = false
NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil)
}
private func cancelExternalNavigation() {
isShowLeaveLiveNavigationDialog = false
pendingExternalNavigationAction = nil
pendingExternalNavigationCancelAction?()
pendingExternalNavigationCancelAction = nil
}
private func pushTokenUpdate() { private func pushTokenUpdate() {
let pushToken = UserDefaults.string(forKey: .pushToken) let pushToken = UserDefaults.string(forKey: .pushToken)
if !pushToken.trimmingCharacters(in: .whitespaces).isEmpty { if !pushToken.trimmingCharacters(in: .whitespaces).isEmpty {

File diff suppressed because it is too large Load Diff

View File

@@ -7,14 +7,15 @@
## 2. Problem ## 2. Problem
- 추천 탭 전용 데이터가 기존 홈 API와 섞이면 응답/화면 책임이 커지고 회귀 위험이 커진다. - 추천 탭 전용 데이터가 기존 홈 API와 섞이면 응답/화면 책임이 커지고 회귀 위험이 커진다.
- Figma 추천 탭은 라이브, 배너, 최근 활동, 최근 데뷔, AI 캐릭터, 장르 크리에이터, 응원 크리에이터, 인기 커뮤니티, 사업자 정보 등 여러 독립 섹션으로 구성된다. - Figma 추천 탭은 라이브, 배너, 최근 활동, 최근 데뷔, AI 캐릭터, 응원 크리에이터, 인기 커뮤니티, 사업자 정보 등 여러 독립 섹션으로 구성된다.
- 섹션별 UI가 많으므로 한 화면 파일에 직접 구현하면 유지보수와 검증이 어려워진다. - 섹션별 UI가 많으므로 한 화면 파일에 직접 구현하면 유지보수와 검증이 어려워진다.
- `FeedCommunityView`는 추천 탭 요구에 맞춰 키워드 영역 제거, 이미지 표시, 오디오 재생, 유료/구매 상태 UI가 필요하다. - `FeedCommunityView`는 추천 탭 요구에 맞춰 키워드 영역 제거, 이미지 표시, 오디오 재생, 유료/구매 상태 UI가 필요하다.
- 사업자 정보는 외부 라이브러리 없이 최대 3줄 말줄임표, 더보기, 접기 동작을 제공해야 한다. - 사업자 정보는 외부 라이브러리 없이 최대 3줄 말줄임표, 더보기, 접기 동작을 제공해야 한다.
- `모두 팔로우하기`는 한 번의 요청으로 여러 크리에이터를 팔로우하지만 현재 버튼 터치 즉시 API를 호출하므로, 사용자가 대상을 충분히 확인하지 못한 상태에서 의도하지 않은 크리에이터까지 팔로우할 수 있다.
## 3. Goals ## 3. Goals
- `추천` 탭 진입 시 `GET /api/v2/home/recommendations`를 호출하고 응답 데이터로 화면을 구성한다. - `추천` 탭 진입 시 `GET /api/v2/home/recommendations`를 호출하고 응답 데이터로 화면을 구성한다.
- 기존 API에 추천 endpoint를 추가하지 않고 신규 `MainHomeApi`, Repository, ViewModel 계층을 만든다. - 기존 API에 추천 endpoint를 추가하지 않고 신규 `MainHomeRecommendationApi`, Repository, ViewModel 계층을 만든다.
- Figma 확인 시 이미 생성된 widget이나 기존 V2 공용 컴포넌트로 처리 가능한 부분은 재사용한다. - Figma 확인 시 이미 생성된 widget이나 기존 V2 공용 컴포넌트로 처리 가능한 부분은 재사용한다.
- `MainHomeView`를 추천/랭킹/팔로잉 탭 shell로 유지하고, 추천 탭 콘텐츠는 `MainHomeRecommendationView`에서 조립한다. - `MainHomeView`를 추천/랭킹/팔로잉 탭 shell로 유지하고, 추천 탭 콘텐츠는 `MainHomeRecommendationView`에서 조립한다.
- 랭킹/팔로잉 탭은 `MainHomeRankingView`, `MainHomeFollowingView`로 별도 파일/폴더를 준비해 이후 구현이 MainHome shell에 누적되지 않게 한다. - 랭킹/팔로잉 탭은 `MainHomeRankingView`, `MainHomeFollowingView`로 별도 파일/폴더를 준비해 이후 구현이 MainHome shell에 누적되지 않게 한다.
@@ -23,6 +24,7 @@
- 빈 데이터 섹션은 제목과 컨테이너를 표시하지 않는다. - 빈 데이터 섹션은 제목과 컨테이너를 표시하지 않는다.
- 사업자 정보 섹션은 화면 width를 채우고, 기본 3줄 말줄임표와 더보기/접기 전환을 지원한다. - 사업자 정보 섹션은 화면 width를 채우고, 기본 3줄 말줄임표와 더보기/접기 전환을 지원한다.
- `FeedCommunityView`는 추천 탭 요구에 맞게 세로 피드형 이미지/오디오/유료 커뮤니티 포스트 UI를 지원한다. - `FeedCommunityView`는 추천 탭 요구에 맞게 세로 피드형 이미지/오디오/유료 커뮤니티 포스트 UI를 지원한다.
- 추천 탭의 모든 `모두 팔로우하기` 진입점은 로그인 사용자에게 확인 Dialog를 먼저 표시하고, 명시적으로 확인한 경우에만 기존 모두 팔로우 API를 호출한다.
## 4. Non-Goals ## 4. Non-Goals
- `추천 필모그래피` 섹션은 만들지 않는다. - `추천 필모그래피` 섹션은 만들지 않는다.
@@ -32,6 +34,9 @@
- Figma 로컬 asset URL 또는 Figma 웹 URL을 앱 코드에 직접 사용하지 않는다. - Figma 로컬 asset URL 또는 Figma 웹 URL을 앱 코드에 직접 사용하지 않는다.
- 서버 응답 스펙에 없는 개별 팔로우 상태, 페이지네이션, 정렬/필터 기능은 이번 범위에 포함하지 않는다. - 서버 응답 스펙에 없는 개별 팔로우 상태, 페이지네이션, 정렬/필터 기능은 이번 범위에 포함하지 않는다.
- Figma `node-id=309-19775``구매완료` 버튼은 구현하지 않는다. - Figma `node-id=309-19775``구매완료` 버튼은 구현하지 않는다.
- 장르 크리에이터 섹션은 현재 추천 탭에 노출하지 않는다. 단, API 디코딩을 위해 `genreCreators` 응답 모델 필드는 유지한다.
- 개별 크리에이터 팔로우/언팔로우 흐름에는 이번 Confirm Dialog를 적용하지 않는다.
- 기존 모두 팔로우 API의 endpoint, request/response 계약과 서버 동작은 변경하지 않는다.
## 5. Target Users ## 5. Target Users
- 앱 홈에서 추천 라이브, 크리에이터, 콘텐츠, 커뮤니티를 빠르게 탐색하는 사용자 - 앱 홈에서 추천 라이브, 크리에이터, 콘텐츠, 커뮤니티를 빠르게 탐색하는 사용자
@@ -44,6 +49,7 @@
- 사용자는 이미지가 포함된 커뮤니티 포스트와 유료/구매 상태를 카드에서 구분하고 싶다. - 사용자는 이미지가 포함된 커뮤니티 포스트와 유료/구매 상태를 카드에서 구분하고 싶다.
- 사용자는 사업자 정보를 기본적으로 짧게 보고, 필요할 때 더보기/접기로 전환하고 싶다. - 사용자는 사업자 정보를 기본적으로 짧게 보고, 필요할 때 더보기/접기로 전환하고 싶다.
- 사용자는 로그인이 필요하거나 민감 콘텐츠 제한이 필요한 상세 화면에 진입할 때 기존 홈과 같은 로그인/본인인증/콘텐츠 보기 설정 안내를 받고 싶다. - 사용자는 로그인이 필요하거나 민감 콘텐츠 제한이 필요한 상세 화면에 진입할 때 기존 홈과 같은 로그인/본인인증/콘텐츠 보기 설정 안내를 받고 싶다.
- 사용자는 여러 크리에이터를 한 번에 팔로우하기 전에 영향을 확인하고, 원하지 않으면 API 요청 없이 취소하고 싶다.
## 7. Core Requirements ## 7. Core Requirements
@@ -76,6 +82,7 @@ data class HomeLiveItem(
) )
data class HomeActiveCreatorItem( data class HomeActiveCreatorItem(
val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String, val creatorProfileImage: String,
val activityType: String, val activityType: String,
@@ -103,6 +110,7 @@ data class HomeGenreCreatorGroupItem(
val genreName: String, val genreName: String,
val creators: List<HomeCreatorItem> val creators: List<HomeCreatorItem>
) )
```
@@ -123,6 +131,8 @@ data class RecommendationBannerResponse(
- `seriesId` -> 시리즈 상세 - `seriesId` -> 시리즈 상세
- `link` -> 외부 URL 열기 - `link` -> 외부 URL 열기
#### HomePopularCommunityPostItem
```kotlin
data class HomePopularCommunityPostItem( data class HomePopularCommunityPostItem(
val postId: Long, val postId: Long,
val creatorId: Long, val creatorId: Long,
@@ -159,6 +169,7 @@ enum class RecommendedActivityType(val code: String) {
- `AUDIO` -> `I18n.HomeRecommendation.activityAudio` - `AUDIO` -> `I18n.HomeRecommendation.activityAudio`
- `COMMUNITY` -> `I18n.HomeRecommendation.activityCommunity` - `COMMUNITY` -> `I18n.HomeRecommendation.activityCommunity`
- 알 수 없는 값은 서버 코드를 그대로 노출하지 않고 보조 문구를 숨긴다. - 알 수 없는 값은 서버 코드를 그대로 노출하지 않고 보조 문구를 숨긴다.
- `LIVE``LIVE_REPLAY`는 표시 문구만 동일하게 사용하며, 탭 이동 분기를 구분할 수 있도록 서로 다른 앱 enum case로 변환한다.
다국어 기본 문구: 다국어 기본 문구:
- ko: `라이브`, `오디오`, `커뮤니티` - ko: `라이브`, `오디오`, `커뮤니티`
@@ -179,6 +190,12 @@ enum class RecommendedActivityType(val code: String) {
- 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다. - 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다.
- 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다. - 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다.
- 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다. - 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다.
- 현재 라이브 전체보기에서 라이브 입장 통신 중에는 기존 `LoadingView`로 진행 중임을 표시한다.
- 최근 활동 크리에이터에서 서버 원본 `activityType == LIVE`이고 `targetId != null`이면 `targetId`를 라이브룸 `roomId`로 사용해 기존 라이브룸 입장 흐름을 실행한다.
- 최근 활동 크리에이터에서 서버 원본 `activityType == LIVE`이고 `targetId == null`이면 `creatorId`를 사용해 크리에이터 채널로 이동한다.
- 최근 활동 크리에이터의 `creatorId`는 서버에서 항상 유효한 양수로 제공하지만, 앱에서도 `creatorId > 0`을 확인한 뒤에만 크리에이터 채널 이동을 실행한다. 검사를 통과하지 못하면 이동하지 않는다.
- `LIVE_REPLAY``AUDIO`와 동일하게 `targetId`를 콘텐츠 ID로 사용해 콘텐츠 상세 페이지로 이동한다. `targetId`가 없으면 이동하지 않으며, `LIVE``creatorId` fallback은 적용하지 않는다.
- 최근 활동 크리에이터의 `AUDIO`, `COMMUNITY`, 알 수 없는 활동 타입은 기존 탭 동작을 유지한다.
- 최근 활동 크리에이터에서 `activityType == COMMUNITY`인 아이템은 `targetId`를 커뮤니티 게시물 `postId`로 취급하고, 탭 시 커뮤니티 게시물 상세 페이지로 이동한다. - 최근 활동 크리에이터에서 `activityType == COMMUNITY`인 아이템은 `targetId`를 커뮤니티 게시물 `postId`로 취급하고, 탭 시 커뮤니티 게시물 상세 페이지로 이동한다.
- 인기 커뮤니티 섹션의 커뮤니티 게시물은 `postId` 기준으로 커뮤니티 게시물 상세 페이지로 이동한다. 좋아요, 구매, 오디오 재생 액션은 기존 개별 액션을 유지한다. - 인기 커뮤니티 섹션의 커뮤니티 게시물은 `postId` 기준으로 커뮤니티 게시물 상세 페이지로 이동한다. 좋아요, 구매, 오디오 재생 액션은 기존 개별 액션을 유지한다.
@@ -223,7 +240,32 @@ enum class RecommendedActivityType(val code: String) {
구현 방향: 구현 방향:
- SwiftUI `Text``lineLimit(isExpanded ? nil : 3)` 기반으로 구현한다. - SwiftUI `Text``lineLimit(isExpanded ? nil : 3)` 기반으로 구현한다.
- 줄 수 판정은 제한/무제한 높이 비교 또는 Geometry 기반 측정 방식을 사용한다. - 줄 수 판정은 제한/무제한 높이 비교 또는 Geometry 기반 측정 방식을 사용한다.
- 사업자 정보 wrapper는 `MainHomeBusinessInfoSection`, 재사용 가능한 텍스트 UI는 `ExpandableTextView`로 분리한다. - 사업자 정보 wrapper는 `MainHomeBusinessInfoSection`, 재사용 가능한 텍스트 UI는 `V2ExpandableTextView`로 분리한다.
### 7.6 모두 팔로우 확인 Dialog
적용 범위:
- 메인 홈 추천 탭에서 공통 `onTapFollowAll` 흐름을 사용하는 모든 `모두 팔로우하기` 진입점에 적용한다.
- 현재 화면에 노출되는 `최근 응원이 많은 크리에이터` 섹션뿐 아니라, 같은 공통 흐름에 연결되는 다른 모두 팔로우 진입점도 동일한 확인 절차를 사용한다.
- 현재 미노출 상태인 장르 크리에이터 섹션을 다시 노출하는 작업은 이번 범위에 포함하지 않는다.
로그인 및 확인 흐름:
1. 비로그인 사용자가 `모두 팔로우하기`를 터치하면 기존 동작과 동일하게 확인 Dialog 없이 로그인 화면으로 이동한다.
2. 로그인 사용자가 `모두 팔로우하기`를 터치하면 기존 `SodaV2ActionModal`을 사용한 Confirm Dialog를 표시한다.
3. Dialog가 표시된 시점에는 `POST /api/v2/home/recommendations/creators/follow`를 호출하지 않는다.
4. 사용자가 `팔로우 하기`를 터치하면 Dialog를 닫고 선택한 섹션의 기존 모두 팔로우 action을 한 번 실행한다.
5. 사용자가 `취소` 또는 dimmed 영역을 터치하면 Dialog를 닫고 보류한 action을 폐기하며 API를 호출하지 않는다.
6. 로그인 화면에서 돌아온 뒤 모두 팔로우를 자동 실행하지 않는다. 사용자가 버튼을 다시 터치하면 확인 Dialog부터 새로 진행한다.
7. 기존 모두 팔로우 완료, 호출 중, 오류 toast와 중복 호출 방지 동작은 유지한다.
다국어 문구:
| 용도 | ko | en | ja |
|---|---|---|---|
| 제목 | `모두 팔로우할까요?` | `Follow all creators?` | `すべてのクリエイターをフォローしますか?` |
| 설명 | `추천된 크리에이터를 모두 팔로우합니다.` | `Youll follow all recommended creators.` | `おすすめのクリエイターをすべてフォローします。` |
| 확인 | `팔로우 하기` | `Follow` | `フォローする` |
| 취소 | `I18n.Common.cancel` 재사용 | `I18n.Common.cancel` 재사용 | `I18n.Common.cancel` 재사용 |
## 8. UX / UI Expectations ## 8. UX / UI Expectations
@@ -249,7 +291,6 @@ MainHomeView
│ ├─ 최근 활동 크리에이터 섹션 │ ├─ 최근 활동 크리에이터 섹션
│ ├─ 최근 데뷔한 크리에이터 섹션 │ ├─ 최근 데뷔한 크리에이터 섹션
│ ├─ AI 캐릭터 섹션 │ ├─ AI 캐릭터 섹션
│ ├─ 장르의 크리에이터 섹션
│ ├─ 최근 응원이 많은 크리에이터 섹션 │ ├─ 최근 응원이 많은 크리에이터 섹션
│ ├─ 인기 커뮤니티 섹션 │ ├─ 인기 커뮤니티 섹션
│ └─ 사업자 정보 섹션 │ └─ 사업자 정보 섹션
@@ -266,7 +307,7 @@ MainHomeView
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` - `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
- `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift` - `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
- `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift` - `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` - `SodaLive/Sources/V2/Component/Text/V2ExpandableTextView.swift`
이미 존재하는 컴포넌트가 Figma 요구를 일부만 충족하면 새 컴포넌트를 중복 생성하지 않고 필요한 범위만 보완한다. 이미 존재하는 컴포넌트가 Figma 요구를 일부만 충족하면 새 컴포넌트를 중복 생성하지 않고 필요한 범위만 보완한다.
@@ -289,6 +330,8 @@ MainHomeView
- 인증 헤더는 기존 `UserDefaultsKey.token` 기반 패턴을 따른다. - 인증 헤더는 기존 `UserDefaultsKey.token` 기반 패턴을 따른다.
- 서버 `Long` 값은 Swift에서 기존 라우팅/모델 관례와 맞는 `Int` 또는 `Int64`로 사용한다. - 서버 `Long` 값은 Swift에서 기존 라우팅/모델 관례와 맞는 `Int` 또는 `Int64`로 사용한다.
- 사용자 노출 문구는 가능한 `I18n`에 추가한다. - 사용자 노출 문구는 가능한 `I18n`에 추가한다.
- 모두 팔로우 Confirm Dialog는 기존 `SodaV2ActionModal``I18n.Common.cancel`을 재사용하고 새 Dialog 컴포넌트 또는 외부 의존성을 추가하지 않는다.
- 모두 팔로우 API/Repository/ViewModel 계약은 유지하고, 공통 callback에서 API action 실행 시점만 확인 이후로 지연한다.
- 상세 진입 guard는 `MainHomeView` 내부에 인증 UI를 중복 구현하지 않고, V2 `MainView`의 기존 인증/민감 콘텐츠 guard 인프라를 재사용한다. - 상세 진입 guard는 `MainHomeView` 내부에 인증 UI를 중복 구현하지 않고, V2 `MainView`의 기존 인증/민감 콘텐츠 guard 인프라를 재사용한다.
- `MainHomeView`에는 추천 탭의 섹션 조립을 직접 누적하지 않고, 탭별 콘텐츠 View를 조합하는 역할만 둔다. - `MainHomeView`에는 추천 탭의 섹션 조립을 직접 누적하지 않고, 탭별 콘텐츠 View를 조합하는 역할만 둔다.
- Open Questions에 남긴 항목은 임의 구현하지 않고 확인 후 반영한다. - Open Questions에 남긴 항목은 임의 구현하지 않고 확인 후 반영한다.
@@ -296,15 +339,26 @@ MainHomeView
## 10. Success Criteria ## 10. Success Criteria
- 추천 탭에서 `GET /api/v2/home/recommendations`를 호출하고 `success == true` 응답 데이터를 섹션별로 렌더링한다. - 추천 탭에서 `GET /api/v2/home/recommendations`를 호출하고 `success == true` 응답 데이터를 섹션별로 렌더링한다.
- 응답 배열이 비어 있는 섹션은 화면에 표시하지 않는다. - 응답 배열이 비어 있는 섹션은 화면에 표시하지 않는다.
- 현재 라이브 섹션의 마지막 `전체` 항목은 배경 없이 medium 14 텍스트, `soda400` 색상, 세로 가운데 정렬로 표시된다.
- 현재 라이브 `전체` 진입 화면의 상단 제목은 `On Air`로 표시하고, 라이브 아이템은 Figma `185:4509`의 리스트형 구조를 따른다.
- 현재 라이브 `전체` 진입 화면의 유료 라이브는 `ic_bar_cash`와 가격을 표시하고, 무료 라이브는 가격과 캔 아이콘 대신 `무료`만 표시한다.
- `추천 필모그래피`, `또 다른 모습`은 코드와 화면에 포함하지 않는다. - `추천 필모그래피`, `또 다른 모습`은 코드와 화면에 포함하지 않는다.
- `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY` 활동 타입이 I18n 문구로 표시된다. - `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY` 활동 타입이 I18n 문구로 표시된다.
- `FeedCommunityView`에서 키워드 영역이 제거되고, 이미지/유료 미구매/유료 구매함 또는 무료 상태가 조건에 맞게 표시된다. - `FeedCommunityView`에서 키워드 영역이 제거되고, 이미지/유료 미구매/유료 구매함 또는 무료 상태가 조건에 맞게 표시된다.
- `FeedCommunityView``구매완료` 버튼이 표시되지 않는다. - `FeedCommunityView``구매완료` 버튼이 표시되지 않는다.
- 사업자 정보는 마지막 섹션에서 화면 width를 채우고, 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다. - 사업자 정보는 마지막 섹션에서 화면 width를 채우고, 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다.
- 상세 진입 탭 액션은 기존 홈과 같은 로그인, 한국 사용자 본인인증, 민감 콘텐츠 보기 설정 guard를 통과한 뒤에만 실행된다. - 상세 진입 탭 액션은 기존 홈과 같은 로그인, 한국 사용자 본인인증, 민감 콘텐츠 보기 설정 guard를 통과한 뒤에만 실행된다.
- 최근 활동 크리에이터의 `LIVE` 활동은 `targetId`가 있으면 라이브룸으로 이동하고, `targetId`가 없고 `creatorId > 0`이면 크리에이터 채널로 이동한다.
- 최근 활동 크리에이터의 `LIVE_REPLAY` 표시 문구는 기존과 같이 `라이브` 계열 I18n 문구를 유지하되, 탭 시에는 `AUDIO`와 동일하게 `targetId` 기반 콘텐츠 상세 페이지로 이동하며 `creatorId` fallback은 적용되지 않는다.
- 최근 활동 크리에이터의 `AUDIO`, `COMMUNITY`, 알 수 없는 활동 타입의 기존 동작에 회귀가 없다.
- 최근 활동 크리에이터의 `COMMUNITY` 활동과 인기 커뮤니티 섹션의 게시물은 커뮤니티 게시물 상세 페이지로 이동한다. - 최근 활동 크리에이터의 `COMMUNITY` 활동과 인기 커뮤니티 섹션의 게시물은 커뮤니티 게시물 상세 페이지로 이동한다.
- `MainHomeView`는 추천/랭킹/팔로잉 탭 shell 역할만 하고, 추천 탭 섹션 조립은 `MainHomeRecommendationView`에 분리되어 있다. - `MainHomeView`는 추천/랭킹/팔로잉 탭 shell 역할만 하고, 추천 탭 섹션 조립은 `MainHomeRecommendationView`에 분리되어 있다.
- 반복 UI는 기존 widget 또는 공용 컴포넌트를 재사용하고 불필요하게 중복 구현하지 않는다. - 반복 UI는 기존 widget 또는 공용 컴포넌트를 재사용하고 불필요하게 중복 구현하지 않는다.
- 로그인 사용자가 추천 탭의 `모두 팔로우하기`를 터치하면 Confirm Dialog가 표시되고, 확인 전에는 모두 팔로우 API 요청이 발생하지 않는다.
- Dialog에서 `팔로우 하기`를 한 번 터치하면 해당 섹션의 기존 모두 팔로우 API가 한 번 호출된다.
- Dialog에서 `취소` 또는 dimmed 영역을 터치하면 모두 팔로우 API 요청이 발생하지 않는다.
- 비로그인 사용자는 기존처럼 로그인 화면으로 이동하며, 확인 Dialog 또는 로그인 완료 후 자동 팔로우가 실행되지 않는다.
- 모두 팔로우 Dialog의 제목, 설명, 확인 버튼은 ko/en/ja로 표시되고 취소 버튼은 기존 공통 I18n을 재사용한다.
- 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다. - 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다.
## 11. Open Questions ## 11. Open Questions
@@ -312,10 +366,23 @@ MainHomeView
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다. - 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
- `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다. - `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.
- 커뮤니티 `audioUrl`을 추천 탭 카드에서 표시/재생해야 하는지 확인이 필요하다. - 커뮤니티 `audioUrl`을 추천 탭 카드에서 표시/재생해야 하는지 확인이 필요하다.
- 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다. 단, 최근 활동 `COMMUNITY` 인기 커뮤니티 게시물은 커뮤니티 게시물 상세 페이지로 확정한다. - 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다. 단, 최근 활동 `LIVE`, `LIVE_REPLAY`, `COMMUNITY`, 인기 커뮤니티 게시물의 이동 규칙은 확정한다.
- 모두 팔로우 확인 Dialog의 적용 범위, 로그인 순서, 취소 동작, 문구와 다국어에는 열린 질문이 없다.
## 12. Verification Plan ## 12. Verification Plan
- 문서 검증: API URL, 응답 모델, 제외 섹션, FeedCommunityView, 사업자 정보 요구가 PRD와 계획 문서에 반영되었는지 확인한다. - 문서 검증: API URL, 응답 모델, 제외 섹션, FeedCommunityView, 사업자 정보 요구가 PRD와 계획 문서에 반영되었는지 확인한다.
- 정적 검증: 신규 추천 API가 기존 `HomeApi`에 추가되지 않았고, Figma URL이 앱 코드에 직접 포함되지 않았는지 확인한다. - 정적 검증: 신규 추천 API가 기존 `HomeApi`에 추가되지 않았고, Figma URL이 앱 코드에 직접 포함되지 않았는지 확인한다.
- 빌드 검증: `docs/agent-guides/build-test-verification.md` 기준으로 가능한 iOS 빌드 또는 최소 정적 검증을 실행한다. - 빌드 검증: `docs/agent-guides/build-test-verification.md` 기준으로 가능한 iOS 빌드 또는 최소 정적 검증을 실행한다.
- 기능 검증: 추천 API 성공/실패, 빈 섹션, 활동 타입 I18n, 상세 진입 guard, 커뮤니티 카드 상태, 사업자 정보 더보기/접기를 확인한다. - 기능 검증: 추천 API 성공/실패, 빈 섹션, 활동 타입 I18n, 상세 진입 guard, 최근 활동 `LIVE``targetId` 유무별 이동, `creatorId` 양수 검사, `LIVE_REPLAY`의 콘텐츠 상세 이동, 다른 활동 타입의 회귀 여부, 커뮤니티 카드 상태, 사업자 정보 더보기/접기를 확인한다.
- 모두 팔로우 기능 검증: 로그인/비로그인 상태를 구분해 Dialog 표시 여부를 확인하고, 확인 전·취소·dimmed 영역 터치에는 API 요청이 0회이며 `팔로우 하기` 선택 시에만 요청이 1회인지 확인한다.
- 다국어 검증: 앱 언어를 ko/en/ja로 전환해 모두 팔로우 Dialog의 제목, 설명, 확인, 취소 문구를 확인한다.
## 13. Decision Log
### 2026-07-30 모두 팔로우 확인 Dialog 인터뷰
- 메인 홈 추천 탭의 모든 `모두 팔로우하기` 진입점에 공통 확인 Dialog를 적용한다.
- 비로그인 사용자는 기존처럼 로그인 화면으로 바로 이동하고, 로그인 후 자동 팔로우하지 않는다.
- 로그인 사용자는 `팔로우 하기`를 명시적으로 선택한 경우에만 API를 호출한다.
- `취소`와 dimmed 영역 터치는 모두 보류 action 폐기 및 API 미호출로 처리한다.
- Dialog 문구는 7.6의 ko/en/ja 문구로 확정하며 관련 열린 질문은 없다.

View File

@@ -0,0 +1,64 @@
# Phase 1 기준 스펙과 기존 구현 점검 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1~1.2 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 저장소 가이드 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- PRD 기준점, V2 홈/공용 컴포넌트 inventory와 제외 범위가 실제 저장소 상태와 일치하는지 확인한다.
- `SodaLive/Sources/V2/Main/Home/**`, `SodaLive/Sources/V2/Component/**`를 포함하고 이후 Phase의 기능 판정은 제외한다.
## 3. 판정 기준
- `docs/sample/sample-review.md`의 Blocker/High/Medium/Low 및 후보/확정/오탐/보류 상태를 적용했다.
- 완료 체크와 실제 파일 존재, 금지 문자열 부재, 후속 요구사항 추적 가능성을 기준으로 판정했다.
## 4. 검토한 근거와 실행한 검증
- 코드: `SodaLive/Sources/V2/Main/Home/**`, `SodaLive/Sources/V2/Component/**`
- 문서: PRD 3~4, 계획의 재분류 원칙과 Task 1.1~1.2
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `find SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Component -type f -name '*.swift'` | 성공 | 홈/공용 컴포넌트 inventory 확인 |
| `rg "추천 필모그래피\|또 다른 모습\|figma.com\|localhost:3845" SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Component` | 성공 | 검색 결과 없음 |
| `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. 이후 Phase에서 확인된 계약·라우팅 문제는 해당 Phase 리포트가 소유한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | inventory와 제외 문자열 확인 |
| 후보 항목 판정 완료 | 충족 | 남은 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 4절 기록 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- PRD, 계획, V2 홈·공용 컴포넌트 목록과 제외 범위를 다시 대조했다.
- Phase 1 범위에서 추가 확정 발견 사항 없음. 후속 구현 결함은 각 소유 Phase 리포트로 분리했다.

View File

@@ -0,0 +1,88 @@
# Phase 10 최근 활동 크리에이터 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 10 / Task 10.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.1~7.3, `plan-task.md` Phase 10 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 활동 크리에이터 표시 필드, 활동 문구, 상대 시간, 빈 상태 숨김을 확인한다.
- 탭 목적지 세부 회귀는 Phase 21 리포트가 소유한다.
## 3. 판정 기준
- 응답 필드 소비, unknown 문구 숨김, 빈 배열 미표시와 컴파일 성공을 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeActiveCreatorSection.swift:3-137`, `RecommendedActivityType.swift`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "activityType\|activityAt\|targetId\|RecommendedActivityType\|relativeActivityAtText" ...` | 성공 | 표시/시간/타입 연결 확인 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. LIVE/LIVE_REPLAY 이동은 Phase 21 기존 리뷰에서도 확정 발견 사항 없음으로 판정됐다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | 표시/시간/empty 확인 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 최근 활동 타입별 callback과 `targetId`·creator fallback 분기를 다시 확인했다.
- 추가 확정 발견 사항 없음. LIVE fallback 상세는 Phase 21 리포트가 소유한다.
### 3차 최종 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P10-001` | Low | 확정 | 최근 활동 상대 시간 계산이 공용 `DateParser`와 중복됨 | `Task 10.2` / `P10-R1` |
#### REV-P10-001 — 공용 상대 시간 계산 미재사용
- **관찰 내용:** `MainHomeActiveCreatorSection.swift``relativeActivityAtText(now:)``DateParser.relativeTimeText(fromUTC:fallback:now:)`와 같은 파싱, 연·월·일·시·분 분기와 I18n 반환을 별도로 구현한다.
- **영향:** 상대 시간 정책을 수정할 때 공용 경로와 추천 홈 경로가 서로 달라질 수 있고 동일 로직을 두 곳에서 유지해야 한다.
- **근거:** `MainHomeActiveCreatorSection.swift:106-137`, `DateParser.swift:19-49`의 분기와 반환값 대조.
- **재현 또는 검증:** 두 함수의 `DateParser.parse`, `Calendar.dateComponents`, `timeIntervalSince`, `I18n.Time` 사용을 줄 단위로 비교하면 동일한 계산임을 확인할 수 있다.
- **권장 조치:** 최근 활동 표시가 공용 `DateParser.relativeTimeText`를 직접 재사용하게 하고 이 섹션의 중복 구현만 제거한다.
- **계획 전환:** `plan-task.md` Phase 10에 `Task 10.2` / `P10-R1`을 추가했다.
**당시 결론:** `REV-P10-001` 수정 goal 필요
### REV-P10-001 수정 기록 — 2026-07-31
- `MainHomeActiveCreatorSection`의 최근 활동 상대 시간 표시를 `DateParser.relativeTimeText(fromUTC:fallback:)` 호출로 교체했다.
- 섹션 내부의 중복 `relativeActivityAtText(now:)` helper를 제거했다.
- `rg -n "dateComponents|timeIntervalSince|relativeTimeText|relativeActivityAtText" "SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeActiveCreatorSection.swift" "SodaLive/Sources/Common/DateParser.swift"` 실행 결과, 추천 섹션에는 공용 `DateParser.relativeTimeText` 호출만 남고 중복 helper는 검색되지 않았다.
- `git diff --check` — 성공, 출력 없음.
- `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -destination "generic/platform=iOS" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataTask184DevRetry" build` — 종료 코드 0.
**현재 결론:** `REV-P10-001` 수정 완료

View File

@@ -0,0 +1,82 @@
# Phase 11 최근 데뷔 크리에이터 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 11 / Task 11.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 11 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- `HomeCreatorItem` 표시, 빈 상태 숨김, creator callback과 현재 반응형 카드 구현을 확인한다.
## 3. 판정 기준
- 필드와 callback ID 일치, 빈 배열 미표시, 기존 카드 크기 기준과 빌드 성공을 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeRecentDebutCreatorSection.swift:3-71`, `AiCharacterCard.responsiveSize`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "creatorId\|creatorNickname\|creatorProfileImage\|responsiveSize" MainHomeRecentDebutCreatorSection.swift` | 성공 | 표시와 creator ID 전달 확인 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. `CreatorProfileGrid` 대신 직사각형 Figma 카드 전용 View를 사용한 것은 기존 공용 원형 프로필 UI로 충족할 수 없어 확정 결함으로 보지 않았다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | 표시와 callback 추적 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 최근 데뷔 데이터 매핑과 공용 `CreatorProfileGrid` 재사용, 빈 상태를 다시 확인했다.
- 추가 확정 발견 사항 없음.
### 3차 통합 재점검 — 2026-07-31
- 2차 기록의 `CreatorProfileGrid` 재사용 문구는 실제 구현과 다른 오기다. 현재 `MainHomeRecentDebutCreatorSection`은 Figma 직사각형 카드 요구를 위해 전용 `MainHomeRecentDebutCreatorItemView`를 사용하며, 이 선택 자체는 6절 판정과 같이 코드 결함으로 보지 않는다.
- append-only 리뷰 정정은 Phase 19 `REV-P19-004` / `Task 19.4`로 전환했다.
### 4차 통합 재점검 — 2026-07-31
- `REV-P19-004` 보완으로 2차 기록의 `CreatorProfileGrid` 재사용 오기는 위 3차 기록에서 append-only로 정정 완료했다.
- 현재 활성 결론은 `MainHomeRecentDebutCreatorSection`이 전용 `MainHomeRecentDebutCreatorItemView`를 사용하는 것이며, 추가 확정 발견 사항 없음 판정은 유지한다.
### 5차 최종 재점검 — 2026-07-31
- production 코드는 최신 리뷰 결론대로 전용 `MainHomeRecentDebutCreatorItemView`를 사용하고 creator ID callback·빈 상태 처리를 유지하므로 추가 코드 결함은 없다.
- 다만 활성 `plan-task.md``Task 11.1` 작업·기대 결과는 여전히 공용 원형 `CreatorProfileGrid` 재사용을 완료 조건으로 요구해 현재 코드 및 이 리포트의 결론과 충돌한다.
- 문서 정합성 결함은 Phase 19 `REV-P19-005` / `Task 19.5`가 소유하며, Phase 11 production 코드의 결론은 **확정 발견 사항 없음**을 유지한다.
### 6차 최종 재점검 — 2026-07-31
- Phase 19 `REV-P19-005` 보완으로 활성 `Task 11.1`은 공용 원형 `CreatorProfileGrid` 우선 검토 원칙을 유지하되, Figma 직사각형 카드 형태를 충족하지 못할 때 전용 `MainHomeRecentDebutCreatorItemView`를 사용하는 현재 결정을 명시했다.
- 기존 2차~5차 기록은 삭제하지 않고 append-only 정정으로 보존했다.
- 현재 Phase 11 결론은 전용 카드 구현 유지와 **확정 발견 사항 없음**이다.

View File

@@ -0,0 +1,91 @@
# Phase 12 AI 캐릭터 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 12 / Task 12.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.1·7.3, `plan-task.md` Phase 12 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- AI 캐릭터 카드 필드 표시, 빈 상태, callback 식별자가 응답 계약과 상세 route에 맞는지 확인한다.
- 최종 `MainView` route는 `REV-P5-001`과 연계한다.
## 3. 판정 기준
- `characterId``creatorId`는 서로 다른 계약 식별자이므로 캐릭터 상세 callback에 creator ID를 전달하면 High로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeAiCharacterSection.swift:3-40`, Preview `:44-68`, `HomeAiCharacterItem`
- 정상 route: `AppStep.characterDetail(characterId:)`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "onTapCharacter\\(item\\.creatorId\\)\|characterId" MainHomeAiCharacterSection.swift` | 실패 발견 | callback은 creator ID, Preview는 두 ID를 구분 |
| `rg "characterDetail\\(characterId:" SodaLive/Sources` | 성공 | 앱의 캐릭터 상세 계약 확인 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | 같은 `Int`라 타입 검사가 오류를 검출하지 못함 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P12-001` | High | 확정 | AI 캐릭터 카드가 `characterId` 대신 `creatorId`를 전달함 | `Task 12.2` / `P12-R1` |
## 6. 발견 사항 상세
### REV-P12-001 — AI 캐릭터 callback 식별자 오류
- **심각도:** High
- **상태:** 확정
- **소유 Task:** `Task 12.2`
**관찰 내용**
카드 탭은 `onTapCharacter(item.creatorId)`를 호출한다. 같은 파일 Preview도 `characterId: 1`, `creatorId: 10`처럼 두 값을 명시적으로 구분하므로 현재 callback 값은 캐릭터 상세 식별자가 아니다.
**영향**
`REV-P5-001`의 route만 수정해도 creator ID를 character ID로 오인해 잘못된 캐릭터를 조회하거나 상세 조회가 실패한다.
**권장 조치**
최소 수정으로 `item.characterId`를 전달하고 `creatorId != characterId` fixture로 callback과 최종 상세를 검증한다.
**판정 기록**
- 2026-07-30 — 응답 모델, Preview fixture, callback과 AppStep 계약 대조로 확정.
## 7. 확정 항목의 plan·goal 전환
`plan-task.md` Phase 12에 `Task 12.2` / `P12-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | model→section callback 추적 |
| 후보 판정 | 충족 | `REV-P12-001` 확정 |
| plan 반영 | 충족 | Task 12.2 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 수정 goal 필요
## 9. 수정 후 검증 기록
- 2026-07-30 — `MainHomeAiCharacterSection`의 카드 탭 callback을 `onTapCharacter(item.creatorId)`에서 `onTapCharacter(item.characterId)`로 수정.
- `rg -n "onTapCharacter\(item\.(characterId|creatorId)\)" "SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeAiCharacterSection.swift"` 실행, `item.characterId` 전달 확인.
- `MainView.handleRecommendationCharacterTap(characterId:)`와 함께 `characterDetail(characterId:)` 경로 정적 연결 확인.
- `git diff --check`, `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`, `SodaLive-dev` Debug 빌드 통과.
- 실제 기기/시뮬레이터 탭 수동 확인은 미실행이며 잔여 확인 사항으로 남김.
### 2차 통합 재점검 — 2026-07-31
- 카드가 `item.characterId`를 callback으로 전달하고 Phase 5의 character route로 연결되는 현재 구현을 확인했다.
- `REV-P12-001`은 **수정 완료**로 판정했으며, 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,63 @@
# Phase 13 장르 크리에이터 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 13 / Task 13.1 및 2026-07-12 미표시 결정 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 13과 재분류 원칙 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 기존 장르 그룹 컴포넌트 구현과 후속 미표시 결정의 코드 반영을 확인한다.
- 장르 섹션을 다시 노출하는 작업은 제외한다.
## 3. 판정 기준
- 모델 필드는 유지하되 `MainHomeRecommendationView`에서 컴포넌트를 조립하지 않는 최신 결정을 우선한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeGenreCreatorSection.swift`, `MainHomeRecommendationResponse.swift:10`, 추천 루트
- 이력: commit `47fe0e7`의 장르 섹션 조립 제거
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "MainHomeGenreCreatorSection\|genreCreators" MainHomeRecommendationView.swift` | 성공 | 검색 결과 없음 |
| `rg "genreCreators" MainHomeRecommendationResponse.swift` | 성공 | 디코딩 모델 유지 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | 미노출 컴포넌트 포함 상태 빌드 성공 |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
코드는 최신 미표시 결정을 충족한다. PRD/Phase 17에 남은 과거 노출 기준은 `REV-P19-001`이 소유한다.
## 7. 확정 항목의 plan·goal 전환
이 Phase의 전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 최신 동작 확인 | 충족 | 루트 참조 없음, 모델 유지 |
| 후보 판정 | 충족 | 코드 결함 없음 |
| plan 반영 | 해당 없음 | 문서 결함은 Phase 19 소유 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- `genreCreators`는 응답 디코딩용으로 유지되고 활성 추천 화면에는 조립되지 않는 최신 결정을 다시 확인했다.
- 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,61 @@
# Phase 14 응원 크리에이터 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 14 / Task 14.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 14, PRD 7.6 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 응원 creator field, 공용 그룹 UI, creator 탭, 모두 팔로우 callback과 상태 연결을 확인한다.
- Confirm Dialog 동작은 Phase 22로 제외한다.
## 3. 판정 기준
- 빈 배열 미표시, 표시 ID 집합의 follow action 연결, 완료/로딩 중복 방지를 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeCheerCreatorSection.swift`, `MainHomeCreatorGroupSection`, `MainHomeRecommendationView.swift:56-69`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "cheerCreators\|MainHomeCreatorGroupSection\|followAll\|completionKey" ...` | 성공 | 데이터와 상태/action 연결 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. 확인 전 API 지연과 취소 동작은 Phase 22 리포트에서 별도 판정한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | section→ViewModel action 추적 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 응원 크리에이터 그룹 UI와 공통 모두 팔로우 callback 연결을 다시 확인했다.
- 추가 확정 발견 사항 없음. 확인 Dialog의 최종 판정은 Phase 22 리포트가 소유한다.

View File

@@ -0,0 +1,121 @@
# Phase 15 인기 커뮤니티 Feed 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 15 / Task 15.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.4, `plan-task.md` Phase 15 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 세로 피드, 이미지/오디오/유료 상태, 구매/좋아요/상세 action, 반응 순서와 상대 시간을 확인한다.
- 실제 네트워크 성공 응답과 오디오 청취 품질은 제외한다.
## 3. 판정 기준
- PRD의 조건식을 코드 분기와 직접 대조한다. 제한된 가격 경계 불일치는 Low로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomePopularCommunitySection.swift`, `CommunityPostCard.swift:65-243`, `MainHomeRecommendationViewModel.swift:112-182`
- 문서: PRD 7.4의 `price <= 0 || existOrdered`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "ScrollView\\(\\.horizontal\|LazyHStack\|frame\\(width: 240\\)" ...` | 성공 | 검색 결과 없음, 세로 피드 유지 |
| `rg "price <= 0\|price == 0\|isAccessiblePost" ...` | 실패 발견 | PRD는 `<= 0`, 코드는 `== 0` |
| `rg "onTapPost\|onTapComment\|purchaseCommunityPost\|communityPostLike\|toggleContent" ...` | 성공 | 상세/구매/좋아요/오디오 연결 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P15-001` | Low | 확정 | 무료 판정이 `price <= 0`이 아니라 `price == 0`임 | `Task 15.2` / `P15-R1` |
## 6. 발견 사항 상세
### REV-P15-001 — 무료 가격 경계 불일치
- **심각도:** Low
- **상태:** 확정
- **소유 Task:** `Task 15.2`
**관찰 내용**
이미지 잠금은 `price > 0 && !existOrdered`로 올바르게 판단하지만 reaction bar 노출의 `isAccessiblePost``price == 0 || existOrdered`다. 따라서 PRD가 무료로 정의한 음수 가격은 일반 이미지를 표시하면서 댓글/좋아요를 숨기는 서로 다른 상태가 된다.
**근거**
- 코드: `CommunityPostCard.swift:230-236`
- 문서: PRD 7.4, Task 15.1의 `price <= 0 || existOrdered`
**재현**
1. `imageUrl != nil`, `price == -1`, `existOrdered == false`인 카드를 구성한다.
2. `isLocked == false`지만 `isAccessiblePost == false`가 된다.
3. 요구 결과는 무료/접근 가능 상태다.
**권장 조치**
`isAccessiblePost``price <= 0 || existOrdered`로 맞추고 음수·0·양수/구매 조합을 확인한다.
**판정 기록**
- 2026-07-30 — PRD 조건식과 private 계산 프로퍼티 대조로 확정.
## 7. 확정 항목의 plan·goal 전환
`plan-task.md` Phase 15에 `Task 15.2` / `P15-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | card/viewModel/action 추적 |
| 후보 판정 | 충족 | `REV-P15-001` 확정 |
| plan 반영 | 충족 | Task 15.2 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 수정 goal 필요
## 9. 수정 후 검증 기록
- 2026-07-30 — `CommunityPostCard.isAccessiblePost``price == 0 || existOrdered`에서 `price <= 0 || existOrdered`로 수정.
- `isLocked`는 기존 `price > 0 && !existOrdered` 조건을 유지해 유료 미구매 잠금 조건은 변경하지 않음.
- `rg -n "price (==|<=) 0 \|\| existOrdered|isAccessiblePost|isLocked" "SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift"` 실행, 조건식 확인.
- `git diff --check`, `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`, `SodaLive-dev` Debug 빌드 통과.
- 실제 화면 상태 수동 확인은 미실행이며 잔여 확인 사항으로 남김.
### 2차 통합 재점검 — 2026-07-31
- `REV-P15-001``price <= 0 || existOrdered`로 수정된 현재 구현을 확인해 **수정 완료**로 판정했다.
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P15-002` | Medium | 확정 | 이미지 없는 유료 미구매 게시물은 구매 UI와 기존 구매 callback에 진입할 수 없음 | `Task 15.3` / `P15-R2` |
#### REV-P15-002 — 이미지 없는 유료 미구매 게시물 구매 진입 누락
- **관찰 내용:** 카드는 `hasImage`일 때만 `imageContent`를 만들고, 잠금 표시와 `onTapPurchase` Button은 `imageContent` 내부에만 있다. 따라서 `imageUrl == nil && price > 0 && existOrdered == false`이면 `isLocked == true`여도 구매 UI가 렌더링되지 않는다.
- **영향:** PRD가 허용한 이미지 없는 유료 게시물은 추천 피드에서 구매할 방법이 없고, `isAccessiblePost == false`라 반응 UI도 표시되지 않는다.
- **근거:** `CommunityPostCard.swift:65-76`, `:125-173`, `:222-236`; PRD 7.4의 이미지 nullable 및 유료 미구매 구매 지원 기준.
- **재현:** `imageUrl: nil`, `price: 100`, `existOrdered: false` fixture로 카드를 구성하면 본문 아래에 이미지·잠금·구매 Button이 모두 없다.
- **권장 조치:** 이미지 없는 레이아웃을 유지한 채 잠긴 상태의 구매 affordance만 제공하고 기존 `onTapPurchase` 흐름을 재사용한다.
- **계획 전환:** `plan-task.md` Phase 15에 `Task 15.3` / `P15-R2`를 추가했다.
#### REV-P15-002 수정 기록 — 2026-07-31
- `CommunityPostCard`에서 이미지가 없고 유료 미구매인 경우에도 본문 아래에 잠금/가격 구매 affordance를 표시하고 기존 `onTapPurchase` callback을 재사용하도록 수정했다.
- 이미지가 있는 유료 미구매 상태의 `paidLockOverlay`, 무료·구매 완료 상태의 reaction bar 조건은 변경하지 않았다.
- `rg -n 'hasImage|isLocked|lockedTextPurchaseButton|paidLockOverlay|purchaseCommunityPost|onTapPurchase' SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift SodaLive/Sources/V2/Main/Home/Recommendation`로 연결을 확인했다.
- Oracle 리뷰어 게이트에서 기존 구매 flow 재사용과 차단/중요 findings 없음 판정을 받았다.
- 실제 화면에서 이미지 없는 유료 미구매 fixture의 구매 callback 1회 호출 수동 확인은 잔여 확인 사항으로 남긴다.
**현재 결론:** `REV-P15-001`, `REV-P15-002` 수정 완료

View File

@@ -0,0 +1,123 @@
# Phase 16 사업자 정보 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 16 / Task 16.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.5·8.3, `plan-task.md` Phase 16 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 3줄 말줄임, 더보기/접기, width와 공용 V2 컴포넌트 재사용 여부를 확인한다.
- 기존 Audition 화면의 legacy `CustomView/ExpandableTextView` 동작 변경은 제외한다.
## 3. 판정 기준
- 사용자 기능은 충족하더라도 계획한 공용 구현이 빌드되지 않고 같은 코드가 복제되면 Low 유지보수성/문서 정합성 결함으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeBusinessInfoSection.swift:3-67`, `V2/Component/Text/ExpandableTextView.swift:3-71`
- 프로젝트: `project.pbxproj`의 두 `ExpandableTextView.swift` file reference와 Sources 등록
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "BusinessInfoExpandableTextView\|ExpandableTextView" ...` | 실패 발견 | 화면 파일에 측정/토글 구현이 복제됨 |
| `rg "C922F89A...\|ExpandableTextView.swift in Sources" project.pbxproj` | 실패 발견 | V2 파일은 file reference만 있고 Sources 등록 없음 |
| 최신 `xcodebuild ... build` compile 목록 확인 | 실패 근거 확인 | legacy `CustomView/ExpandableTextView.swift`만 app target에서 컴파일 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | private 복제 구현 때문에 화면 자체는 컴파일 성공 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P16-001` | Low | 확정 | 공용 V2 확장 텍스트는 미빌드 상태이고 화면에 동일 구현이 복제됨 | `Task 16.2` / `P16-R1` |
## 6. 발견 사항 상세
### REV-P16-001 — 확장 텍스트 구현과 project target 불일치
- **심각도:** Low
- **상태:** 확정
- **소유 Task:** `Task 16.2`
**관찰 내용**
PRD는 `MainHomeBusinessInfoSection`이 재사용 가능한 V2 `ExpandableTextView`를 소비하도록 정했다. 실제 화면은 private `BusinessInfoExpandableTextView`를 다시 구현하며, V2 공용 파일은 Xcode group에만 있고 Sources에 포함되지 않는다. 반면 같은 타입명의 legacy CustomView 파일은 앱 타깃에서 컴파일된다.
**영향**
높이 측정/토글 수정이 두 V2 파일에 중복되고, 공용 컴포넌트 수정이 실제 앱에 반영되지 않아 검증 기록과 빌드 상태가 어긋난다.
**권장 조치**
legacy 소비처는 유지하면서 타입명 충돌을 피하고, 빌드 대상 공용 V2 구현 하나로 화면을 연결한 뒤 이번 변경이 만든 private 중복만 제거한다.
**판정 기록**
- 2026-07-30 — source 내용, PBX Sources와 실제 SwiftCompile 목록 대조로 확정.
## 7. 확정 항목의 plan·goal 전환
`plan-task.md` Phase 16에 `Task 16.2` / `P16-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | source/project/build 대조 |
| 후보 판정 | 충족 | `REV-P16-001` 확정 |
| plan 반영 | 충족 | Task 16.2 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 수정 goal 필요
## 9. 수정 후 검증 기록
- 2026-07-30 — `MainHomeBusinessInfoSection`의 private `BusinessInfoExpandableTextView` 중복 구현을 제거하고 V2 공용 `V2ExpandableTextView`를 사용하도록 수정.
- legacy `CustomView/ExpandableTextView` 소비처와 산출물 충돌을 피하기 위해 V2 공용 파일명을 `V2ExpandableTextView.swift`로 정리하고 `SodaLive.xcodeproj/project.pbxproj` Sources에 포함.
- 중간 빌드에서 V2/legacy 파일명이 모두 `ExpandableTextView.swift`일 때 `Multiple commands produce ... ExpandableTextView.stringsdata` 실패를 확인했고, 파일명 정리 후 재빌드 성공.
- `rg -n "V2ExpandableTextView|BusinessInfoExpandableTextView|struct ExpandableTextView|struct V2ExpandableTextView" ...` 실행, 사업자 섹션은 V2 공용 구현을 사용하고 legacy 타입은 기존 경로에만 남음 확인.
- `git diff --check`, `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`, `SodaLive-dev` Debug 빌드 통과.
- 실제 3줄 이하/초과 화면 수동 확인은 미실행이며 잔여 확인 사항으로 남김.
### 2차 통합 재점검 — 2026-07-31
- `REV-P16-001`은 사업자 정보 섹션이 `V2ExpandableTextView` 하나를 소비하는 현재 구현을 확인해 **수정 완료**로 판정했다.
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P16-002` | Low | 확정 | `V2ExpandableTextView.swift`가 프로덕션 target Sources에 두 번 등록됨 | `Task 16.3` / `P16-R2` |
#### REV-P16-002 — 프로덕션 Sources 중복 등록
- **관찰 내용:** `SodaLive` target의 Sources phase `40FE0E80`에 동일 file reference `C922F89A`를 가리키는 build file `C922F89B`, `C922F89D`가 함께 있다. `SodaLive-dev` Sources phase `40FE0E9F`에는 `C922F89C` 한 건만 있다.
- **영향:** 프로덕션 프로젝트 메타데이터에 동일 Swift 소스가 중복되어 빌드 경고 또는 이후 프로젝트 수정 시 불필요한 충돌 원인이 된다.
- **근거:** `project.pbxproj:6385-6434`, `:6846`, `:7760`, `:8027`; 현재 staged diff에서 Task 16.2가 세 build file을 추가한 내역.
- **재현:** 두 앱 target의 Sources phase에서 `V2ExpandableTextView.swift in Sources`를 세면 `SodaLive=2`, `SodaLive-dev=1`이다.
- **권장 조치:** `SodaLive`의 중복 build file 한 건만 제거하고 각 target 포함 수를 1로 맞춘다. 기존 다른 Sources 중복은 이번 범위에서 변경하지 않는다.
- **계획 전환:** `plan-task.md` Phase 16에 `Task 16.3` / `P16-R2`를 추가했다.
- **빌드 확인:** `xcodebuild -quiet -workspace SodaLive.xcworkspace -scheme SodaLive -configuration Debug -derivedDataPath /private/tmp/SodaLiveDerivedDataReviewCurrentProd build 2>&1 | rg 'V2ExpandableTextView|BUILD'`에서 `Skipping duplicate build file ... V2ExpandableTextView.swift` 경고를 재현했고 빌드 종료 코드는 0이었다.
#### REV-P16-002 수정 기록 — 2026-07-31
- `SodaLive` target Sources phase에 중복 등록된 `C922F89D /* V2ExpandableTextView.swift in Sources */` 한 건과 해당 `PBXBuildFile` 정의를 제거했다.
- `SodaLive-dev` target의 `C922F89C` 등록과 다른 Sources 항목은 변경하지 않았다.
- `rg -n 'V2ExpandableTextView\.swift in Sources|C922F89D' SodaLive.xcodeproj/project.pbxproj``C922F89B`, `C922F89C` 두 건만 남고 `C922F89D`가 제거된 것을 확인했다.
- `plutil -lint SodaLive.xcodeproj/project.pbxproj` 결과 `OK`를 확인했다.
- 전체 빌드는 도구 시간 제한으로 완료하지 못했으나 Oracle 리뷰어 게이트에서 PBX 등록 조건에 대한 차단/중요 findings 없음 판정을 받았다.
**현재 결론:** `REV-P16-001`, `REV-P16-002` 수정 완료
### 3차 통합 재점검 — 2026-07-31
- PBX Sources phase에서 `V2ExpandableTextView.swift``SodaLive`, `SodaLive-dev` target에 각각 한 번만 등록된 것을 다시 확인했다.
- 최신 `SodaLive` Debug 빌드는 종료 코드 0으로 성공했고 `V2ExpandableTextView.swift` duplicate build warning은 더 이상 출력되지 않았다.
- 최신 `SodaLive-dev` 재실행은 10분 이상 종료되지 않아 중단했으며, dev target 등록을 변경하지 않은 점과 직전 dev 빌드 성공 기록을 함께 유지한다.
- Phase 16의 `REV-P16-001`, `REV-P16-002` 수정 완료 판정은 유지한다. 다른 홈 추천 파일의 중복은 Phase 18 `REV-P18-001`이 소유한다.

View File

@@ -0,0 +1,66 @@
# Phase 17 섹션 조립과 빈 상태 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 17 / Task 17.1 및 후속 장르 미표시 결정 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 17과 2026-07-12 재분류 원칙 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 최신 결정 기준 섹션 순서와 데이터 기반 섹션의 빈 배열 숨김을 확인한다.
- 과거 검증 기록은 사실 이력으로 보존하고 현재 기준과 구분한다.
## 3. 판정 기준
- 후속 2026-07-12 미표시 결정이 기존 Task 17.1보다 최신이므로 현재 코드는 장르 섹션을 조립하지 않아야 한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeRecommendationView.swift:25-83`, 각 섹션의 `if !items.isEmpty`
- 이력: commit `47fe0e7`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| 섹션 타입 순서 `rg` | 성공 | live→banner→active→debut→AI→cheer→community→business |
| `rg "MainHomeGenreCreatorSection\|genreCreators" MainHomeRecommendationView.swift` | 성공 | 검색 결과 없음 |
| 각 데이터 섹션 empty guard 확인 | 성공 | 빈 배열에서 제목/컨테이너 미표시 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**코드 확정 발견 사항 없음.**
문서의 활성 기준 충돌은 `REV-P19-001`이 소유한다.
## 6. 발견 사항 상세
현재 코드는 최신 장르 미표시 결정을 충족한다. Task 17.1 본문과 PRD 화면 트리가 과거 노출 순서를 유지하는 문제는 코드 수정 대상이 아니므로 Phase 19로 전환했다.
## 7. 확정 항목의 plan·goal 전환
이 Phase의 신규 Task 없음. 문서 정정은 `Task 19.2` / `P19-R1`에서 수행한다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 코드 범위 확인 | 충족 | 순서와 empty guard 확인 |
| 후보 판정 | 충족 | 코드 후보 없음 |
| 문서 불일치 전환 | 충족 | REV-P19-001 / Task 19.2 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 코드 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 활성 섹션 순서와 빈 배열 숨김, 장르 섹션 미조립을 현재 PRD·코드와 다시 대조했다.
- `REV-P19-001` 문서 충돌은 수정 완료이며 Phase 17 코드의 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,158 @@
# Phase 18 최종 프로젝트 포함과 빌드 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 18 / Task 18.1~18.2 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 18, 빌드/검증 가이드 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 프로젝트 유효성, 금지 범위, 공백 오류와 현재 `SodaLive-dev` 전체 빌드를 확인한다.
- 개별 컴포넌트 target 누락의 기능 소유권은 해당 구현 Phase에서 판정한다.
## 3. 판정 기준
- `plutil`, 금지 문자열, `git diff --check`, app target compile/link/sign/validate 성공을 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 환경: macOS 26.0, Xcode 26.0, Any iOS Device / Debug
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `plutil -lint SodaLive.xcodeproj/project.pbxproj` | 성공 | `OK` |
| 제외 섹션/Figma URL/기존 HomeApi endpoint 검색 | 성공 | 검색 결과 없음 |
| `git diff --check` | 성공 | 출력 없음 |
| sandbox 내 `xcodebuild -workspace ... -list` | 환경 실패 | CoreSimulator/캐시 권한 제한 |
| sandbox 밖 `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**Phase 18 공통 Gate의 추가 확정 발견 사항 없음.**
V2 공용 `ExpandableTextView`가 Sources에 포함되지 않은 구체적 문제는 `REV-P16-001` / `Task 16.2`가 소유한다.
## 6. 발견 사항 상세
전체 앱은 private 사업자 정보 구현을 사용하므로 빌드가 성공한다. 따라서 project 전체 Gate 실패는 아니지만, 의도한 공용 파일의 미포함은 Phase 16에서 별도로 수정해야 한다.
## 7. 확정 항목의 plan·goal 전환
Phase 18 신규 Task 없음. `Task 16.2`에 project 포함 검증을 명시했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 공통 Gate | 충족 | lint/static/build 성공 |
| 후보 판정 | 충족 | 구체적 누락은 REV-P16-001로 전환 |
| plan 반영 | 충족 | Task 16.2 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 공통 빌드 Gate 통과, `REV-P16-001` 후속 수정 필요
## 9. 수정 후 검증 기록
Phase 16 수정 후 Phase 18 build를 재실행해 누적한다.
### 2차 통합 재점검 — 2026-07-31
- `REV-P16-001` 보완 후 `SodaLive-dev` Debug 빌드 성공 기록과 프로덕션 `SodaLive` Debug 빌드를 다시 확인했다.
- `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive -configuration Debug -derivedDataPath /private/tmp/SodaLiveDerivedDataReviewCurrentProd build` — 종료 코드 0, `BUILD SUCCEEDED`.
- 프로덕션 빌드는 `V2ExpandableTextView.swift` 중복 Compile Sources 경고를 출력했으며, 정적 PBX 대조에서도 `SodaLive=2`, `SodaLive-dev=1` 등록을 확인했다.
- 해당 경고는 Phase 16 `REV-P16-002` / `Task 16.3`으로 전환했다. 함께 출력된 다른 기존 Sources 중복 경고는 이번 추천 홈 변경 범위 밖이므로 수정하지 않았다.
**현재 결론:** 두 앱 scheme의 빌드 성공 근거는 충족, `REV-P16-002` 후속 수정 필요
### 3차 통합 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P18-001` | Low | 확정 | 프로덕션 target에 홈 추천 범위 Swift 파일 11개가 중복 Sources 등록됨 | `Task 18.3` / `P18-R1` |
#### REV-P18-001 — 홈 추천 Compile Sources 중복 등록
- **관찰 내용:** `SodaLive` Sources phase에는 이번 계획 범위의 11개 Swift file reference가 각각 두 번 포함되고 `SodaLive-dev`에는 한 번씩 포함된다. 최신 프로덕션 빌드도 해당 파일들의 `Skipping duplicate build file in Compile Sources` 경고를 출력한다.
- **영향:** 빌드는 성공하지만 Phase 18의 프로젝트 포함 검증이 중복 없는 target 구성을 보장하지 못하며, Xcode 프로젝트 수정 시 불필요한 충돌과 경고가 계속 발생한다.
- **근거:** `SodaLive.xcodeproj/project.pbxproj``40FE0E802A8361DF00252293 /* Sources */`, `40FE0E9F2A8362A200252293 /* Sources */`; 2026-07-31 `SodaLive` Debug 빌드 로그.
- **대상 파일:** `MainHomeView.swift`, `MainHomeRecommendationView.swift`, `MainHomeRecommendationViewModel.swift`, `MainHomeRankingView.swift`, `MainHomeFollowingView.swift`, `MainHomeLiveSection.swift`, `MainHomeBannerSection.swift`, `MainHomeActiveCreatorSection.swift`, `MainHomeRecentDebutCreatorSection.swift`, `MainHomeAiCharacterSection.swift`, `MainHomeBusinessInfoSection.swift`.
- **재현:** 두 앱 target의 Sources phase에서 위 file reference를 세면 `SodaLive=2`, `SodaLive-dev=1`이며 프로덕션 빌드 로그에서 같은 경고를 확인할 수 있다.
- **권장 조치:** 프로덕션 중복 build file 한 건씩만 제거하고 두 scheme 빌드와 경고 부재를 확인한다. 다른 기존 중복 Sources는 이번 Task 범위에서 변경하지 않는다.
- **계획 전환:** `plan-task.md` Phase 18에 `Task 18.3` / `P18-R1`을 추가했다.
검증 기록:
- `plutil -lint SodaLive.xcodeproj/project.pbxproj``OK`.
- `git diff --check` — 성공, 출력 없음.
- `xcodebuild -quiet -workspace SodaLive.xcworkspace -scheme SodaLive -configuration Debug -derivedDataPath /private/tmp/SodaLiveDerivedDataReviewCurrentProd build` — 종료 코드 0, 빌드 성공. `V2ExpandableTextView.swift` 중복 경고는 제거됐지만 `REV-P18-001` 대상 경고를 확인했다.
- 최신 `SodaLive-dev` 재실행은 10분 이상 종료되지 않아 중단했고, 같은 dev target의 직전 현재 변경군 빌드 성공 기록은 유지한다.
**당시 결론:** 빌드는 성공하지만 `REV-P18-001` 수정 goal 필요
### REV-P18-001 수정 기록 — 2026-07-31
- `SodaLive.xcodeproj/project.pbxproj`에서 홈 추천 범위 11개 Swift 파일의 중복 Compile Sources 등록을 정리했다.
- 대상 파일 `MainHomeView.swift`, `MainHomeRecommendationView.swift`, `MainHomeRecommendationViewModel.swift`, `MainHomeRankingView.swift`, `MainHomeFollowingView.swift`, `MainHomeLiveSection.swift`, `MainHomeBannerSection.swift`, `MainHomeActiveCreatorSection.swift`, `MainHomeRecentDebutCreatorSection.swift`, `MainHomeAiCharacterSection.swift`, `MainHomeBusinessInfoSection.swift``SodaLive`, `SodaLive-dev` target에 각각 1회 등록된다.
- `plutil -lint SodaLive.xcodeproj/project.pbxproj``OK`.
- `xcodebuild -workspace SodaLive.xcworkspace -list` — 프로젝트 파싱 성공, `SodaLive``SodaLive-dev` scheme 확인.
- `xcodebuild -quiet -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -derivedDataPath /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataTask183DevRetry build` — 종료 코드 0.
- `xcodebuild -quiet -workspace SodaLive.xcworkspace -scheme SodaLive -configuration Debug -derivedDataPath /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataTask183ProdFinal build` — 종료 코드 0.
- 두 빌드 로그에서 `REV-P18-001` 대상 11개 홈 추천 파일의 duplicate build warning은 검색되지 않았다. 기존 범위 밖 V2 파일 duplicate warning은 남아 있으며 이번 Task에서 변경하지 않았다.
- `git diff --check` — 성공, 출력 없음.
**현재 결론:** `REV-P18-001` 수정 완료
### 4차 최종 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P18-002` | Low | 확정 | `Task 18.3` 이후에도 홈 추천 파일 11개의 프로덕션 Sources 중복이 남음 | `Task 18.4` / `P18-R2` |
#### REV-P18-002 — 프로덕션 Sources 잔여 중복
- **관찰 내용:** `Task 18.3` 수정 기록은 대상 11개 파일이 두 target에 각각 1회 등록됐다고 판정하지만, 현재 `40FE0E802A8361DF00252293 /* Sources */`에는 각 파일이 2회, `40FE0E9F2A8362A200252293 /* Sources */`에는 각 파일이 1회 존재한다. 기존 수정은 프로덕션 3개 등록 중 한 건만 제거해 2개 등록을 남겼다.
- **영향:** 프로덕션 빌드가 대상 11개 파일 모두에 `Skipping duplicate build file in Compile Sources` 경고를 계속 출력하고, Phase 18 완료 증거와 실제 프로젝트 상태가 일치하지 않는다.
- **대상 파일:** `MainHomeView.swift`, `MainHomeRecommendationView.swift`, `MainHomeRecommendationViewModel.swift`, `MainHomeRankingView.swift`, `MainHomeFollowingView.swift`, `MainHomeLiveSection.swift`, `MainHomeBannerSection.swift`, `MainHomeActiveCreatorSection.swift`, `MainHomeRecentDebutCreatorSection.swift`, `MainHomeAiCharacterSection.swift`, `MainHomeBusinessInfoSection.swift`.
- **재현 또는 검증:** 두 PBXSourcesBuildPhase 범위의 파일명 포함 수를 세면 대상 전체가 `SodaLive=2`, `SodaLive-dev=1`이며, 격리 DerivedData의 프로덕션 빌드에서도 11개 경고가 재현된다.
- **권장 조치:** `SodaLive`의 잔여 중복 build file과 Sources 참조 한 건씩만 제거하고 dev target과 범위 밖 중복은 보존한 뒤 두 scheme을 fresh DerivedData로 검증한다.
- **계획 전환:** `plan-task.md` Phase 18에 `Task 18.4` / `P18-R2`를 추가했다.
검증 기록:
- `git diff --check` — 성공, 출력 없음.
- `plutil -lint SodaLive.xcodeproj/project.pbxproj``OK`.
- PBX Sources phase 대조 — 대상 11개 파일 모두 `SodaLive=2`, `SodaLive-dev=1`.
- 격리 DerivedData 빌드 — `SodaLive`, `SodaLive-dev` 모두 종료 코드 0.
- 빌드 경고 대조 — `SodaLive`에서는 대상 11개 파일의 duplicate build warning이 모두 재현됐고 `SodaLive-dev`에서는 대상 경고가 없었다.
- 첫 프로덕션 빌드 시도는 기본 DerivedData의 기존 `build.db` lock으로 종료 코드 65였으며, 위 결과는 별도 격리 DerivedData에서 다시 검증한 결과다.
**당시 결론:** `REV-P18-001`의 수정 완료 판정을 정정하며 `REV-P18-002` 수정 goal 필요
### REV-P18-002 수정 기록 — 2026-07-31
- `SodaLive.xcodeproj/project.pbxproj``SodaLive` Sources phase에서 홈 추천 범위 11개 Swift 파일의 잔여 중복 build file 참조를 제거했다.
- 대상 파일 `MainHomeView.swift`, `MainHomeRecommendationView.swift`, `MainHomeRecommendationViewModel.swift`, `MainHomeRankingView.swift`, `MainHomeFollowingView.swift`, `MainHomeLiveSection.swift`, `MainHomeBannerSection.swift`, `MainHomeActiveCreatorSection.swift`, `MainHomeRecentDebutCreatorSection.swift`, `MainHomeAiCharacterSection.swift`, `MainHomeBusinessInfoSection.swift``SodaLive`, `SodaLive-dev` target에 각각 1회 등록된다.
- `plutil -lint "SodaLive.xcodeproj/project.pbxproj"``OK`.
- `git diff --check` — 성공, 출력 없음.
- `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -destination "generic/platform=iOS" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataTask184DevRetry" build` — 종료 코드 0.
- `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -destination "generic/platform=iOS" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataTask184Prod" build` — 종료 코드 0.
- 두 빌드 로그에서 `REV-P18-002` 대상 11개 홈 추천 파일의 duplicate build warning은 검색되지 않았다.
- 기존 범위 밖 `MainHomeRankingViewModel.swift`, `MainHomeFollowingViewModel.swift` duplicate warning은 `SodaLive` 빌드 로그에 남아 있으며 이번 Task에서 변경하지 않았다.
-`SodaLive-dev` 빌드 시도는 10분 제한으로 `BUILD INTERRUPTED`됐고, 위 결과는 동일 변경을 별도 DerivedData에서 다시 검증한 결과다.
**현재 결론:** `REV-P18-002` 수정 완료
### 5차 최종 재점검 — 2026-07-31
- `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `git diff --check`, `git diff --cached --check`가 모두 성공했다.
- clean DerivedData에서 `SodaLive-dev` Debug 빌드와 동일 DerivedData의 `SodaLive` Debug 빌드가 각각 종료 코드 0으로 완료됐다.
- 두 빌드 로그에 `REV-P18-002` 대상 홈 추천 11개 파일의 duplicate build warning은 없었다.
- 프로덕션 target에는 다른 V2 기능 파일의 기존 duplicate warning이 남아 있으나 Phase 18 회귀 수정 대상 11개와 무관하고 이번 계획 범위에서 생성한 변경이 아니므로 별도 기존 부채로 유지한다.
**현재 결론:** Phase 18 대상 빌드 및 Sources Gate 통과

View File

@@ -0,0 +1,173 @@
# Phase 19 문서와 검증 기록 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 19 / Task 19.1 및 누적 검증 기록 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 문서 정책 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 요구사항 변경이 PRD→계획→검증 기록 순으로 누적됐는지, 현재 활성 기준과 코드가 일치하는지 확인한다.
- 과거 기록의 사실 여부를 재작성하지 않고 현재 기준 충돌만 판정한다.
## 3. 판정 기준
- 후속 결정이 PRD와 활성 Task/Gate에 전파되지 않아 서로 반대 결과를 요구하면 Low 문서 정합성 결함으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 문서: PRD Problem/화면 트리, 계획의 2026-07-12 결정, Phase 17, 2026-06-30 검증 기록
- 코드: `MainHomeRecommendationView.swift`
- 환경: macOS 26.0, Xcode 26.0
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "장르의 크리에이터\|MainHomeGenreCreatorSection\|genreCreators" prd.md plan-task.md ...` | 실패 발견 | 최신 미표시 결정과 과거 활성 노출 기준이 공존 |
| `rg "MainHomeGenreCreatorSection\|genreCreators" MainHomeRecommendationView.swift` | 성공 | 현재 코드에는 참조 없음 |
| `git log -p -S'MainHomeGenreCreatorSection' -- MainHomeRecommendationView.swift` | 성공 | commit `47fe0e7`에서 의도적으로 제거 |
| `git diff --check` | 성공 | 출력 없음 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P19-001` | Low | 확정 | 장르 섹션 미표시 결정이 PRD와 Phase 17 활성 기준에 전파되지 않음 | `Task 19.2` / `P19-R1` |
## 6. 발견 사항 상세
### REV-P19-001 — 장르 섹션 활성 문서 기준 충돌
- **심각도:** Low
- **상태:** 확정
- **소유 Task:** `Task 19.2`
**관찰 내용**
계획 상단의 2026-07-12 결정과 현재 코드는 장르 섹션을 숨긴다. 그러나 PRD의 추천 화면 설명/트리와 Task 17.1 작업·기대 결과는 장르 섹션을 조립하도록 요구한다. 과거 2026-06-30 기록도 최신 결정과 구분하는 정정 기록이 없다.
**영향**
후속 구현자나 리뷰어가 어느 기준을 활성 요구사항으로 봐야 하는지 혼동하고, 현재 올바른 코드를 Phase 17 실패로 잘못 판정할 수 있다.
**권장 조치**
`genreCreators` 디코딩 필드 유지와 UI 미표시를 구분해 PRD와 활성 Phase 17 기준을 정정한다. 기존 Phase 13 구현/검증 기록은 삭제하지 않고 후속 정정만 누적한다.
**판정 기록**
- 2026-07-30 — 문서 세 위치, 현재 코드와 제거 commit 대조로 확정.
## 7. 확정 항목의 plan·goal 전환
`plan-task.md` Phase 19에 `Task 19.2` / `P19-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 문서 범위 확인 | 충족 | PRD/plan/history/code 대조 |
| 후보 판정 | 충족 | `REV-P19-001` 확정 |
| plan 반영 | 충족 | Task 19.2 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 수정 goal 필요
## 9. 수정 후 검증 기록
- 2026-07-30 — PRD와 Phase 17 활성 기준에서 장르 크리에이터 섹션 미표시 결정을 반영.
- `genreCreators` 응답 모델 필드는 API 디코딩을 위해 유지하되, 추천 탭 UI의 활성 화면 트리와 조립 검증 기준에서는 `MainHomeGenreCreatorSection`을 제외하도록 정정.
- 기존 Phase 13 구현 기록과 2026-06-30 검증 기록은 삭제하지 않고 최신 정정 기록만 누적.
- `rg -n "장르의 크리에이터|MainHomeGenreCreatorSection|genreCreators" "docs/20260602_메인_홈_추천_UI_API_연동" "SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift"` 실행, 최신 미표시 결정과 과거 기록 구분 확인.
- `git diff --check` 실행, 출력 없이 성공.
### 2차 통합 재점검 — 2026-07-31
- `REV-P19-001`은 PRD와 Phase 17 활성 기준에 장르 섹션 미표시 결정이 반영된 현재 문서를 확인해 **수정 완료**로 판정했다.
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P19-002` | Low | 확정 | PRD 응답 모델 Markdown code fence가 중첩·누락되어 이후 본문 렌더링이 깨짐 | `Task 19.3` / `P19-R2` |
| `REV-P19-003` | Low | 확정 | 활성 문서가 이름 변경 전 `ExpandableTextView.swift` 경로를 가리킴 | `Task 19.3` / `P19-R2` |
#### REV-P19-002 — PRD 응답 예시 code fence 불균형
- **관찰 내용:** PRD 7.1은 line 66에서 연 `kotlin` fence를 `RecommendationBannerResponse` heading 전에 닫지 않고 line 117에서 새 `kotlin` fence를 연다. 그 결과 heading과 설명이 코드 블록으로 처리되고 이후 fence 경계도 어긋난다.
- **영향:** 렌더링된 PRD에서 응답 모델 구조와 설명 계층이 깨져 구현 기준을 오독할 수 있다.
- **근거:** `prd.md:66-148`.
- **권장 조치:** 모델 예시 단위로 fence를 정상 종료하되 모델 내용 자체는 변경하지 않는다.
#### REV-P19-003 — 활성 V2 컴포넌트 경로 불일치
- **관찰 내용:** Task 16.2에서 공용 파일을 `V2ExpandableTextView.swift`로 이름 변경했지만 PRD의 구현 방향·재사용 후보와 계획 상단 주요 대상 파일은 여전히 `ExpandableTextView.swift`를 가리킨다.
- **영향:** 후속 작업자가 존재하지 않는 활성 경로를 기준 파일로 판단할 수 있다.
- **근거:** `prd.md:239`, `:306`, `plan-task.md:45`; 실제 파일 `SodaLive/Sources/V2/Component/Text/V2ExpandableTextView.swift`.
- **권장 조치:** 활성 기준만 최신 이름·경로로 정정하고 과거 Task/검증 이력은 보존한다.
- **계획 전환:** 두 항목을 `plan-task.md` Phase 19의 `Task 19.3` / `P19-R2`로 추가했다.
#### REV-P19-002/REV-P19-003 수정 기록 — 2026-07-31
- PRD 7.1 응답 예시에서 `HomeRecommendationResponse`, `RecommendationBannerResponse`, `HomePopularCommunityPostItem` code fence를 모델 단위로 분리했다.
- PRD의 구현 방향과 재사용 후보, 계획 상단 주요 대상 파일의 활성 경로를 `SodaLive/Sources/V2/Component/Text/V2ExpandableTextView.swift`로 정정했다.
- 과거 Task와 검증 기록에 남은 이전 `ExpandableTextView.swift` 경로는 이력으로 보존했다.
- `rg -n '^```|#### RecommendationBannerResponse|#### HomePopularCommunityPostItem|V2/Component/Text/(ExpandableTextView|V2ExpandableTextView)\.swift|V2ExpandableTextView' docs/20260602_메인_홈_추천_UI_API_연동/prd.md docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`로 PRD fence 10개와 활성 경로를 확인했다.
- Oracle 리뷰어 게이트에서 문서 정합성에 대한 차단/중요 findings 없음 판정을 받았다.
**현재 결론:** `REV-P19-001`, `REV-P19-002`, `REV-P19-003` 수정 완료
### 3차 통합 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P19-004` | Low | 확정 | 활성 API 타입명과 Phase 11/21 후속 리뷰 기록이 현재 구현과 다름 | `Task 19.4` / `P19-R3` |
#### REV-P19-004 — 활성 문서와 리뷰 근거 오기
- **관찰 내용:** PRD Goal은 실제 `MainHomeRecommendationApi` 대신 이동 전 이름인 `MainHomeApi`를 신규 타입으로 적는다. Phase 11의 2차 기록은 실제 전용 직사각형 카드 구현과 달리 `CreatorProfileGrid` 재사용을 확인했다고 적고, Phase 21의 3차 기록은 실제 `.liveReplay -> .content(targetId)` 분기와 달리 `LIVE_REPLAY`도 라이브/creator fallback을 사용한다고 적는다.
- **영향:** 후속 구현자가 존재하지 않는 타입을 찾거나, 리뷰 완료 근거를 실제 UI·라우팅과 반대로 해석할 수 있다.
- **근거:** `prd.md:18`, `phase11-recent-debut-creators.md:59`, `phase21-recent-activity-live-fallback.md:162`, 현재 Swift 구현.
- **권장 조치:** PRD 활성 타입명을 정정하고 두 리뷰의 기존 기록을 삭제하지 않은 채 사실관계 정정 기록을 누적한다.
- **계획 전환:** `plan-task.md` Phase 19에 `Task 19.4` / `P19-R3`을 추가했다.
**당시 결론:** `REV-P19-004` 수정 goal 필요
#### REV-P19-004 수정 기록 — 2026-07-31
- PRD Goal의 활성 추천 API 타입명을 실제 구현과 같은 `MainHomeRecommendationApi`로 정정했다.
- Phase 11 리뷰의 `CreatorProfileGrid` 재사용 오기는 기존 기록을 삭제하지 않고 전용 `MainHomeRecentDebutCreatorItemView` 사용으로 append-only 정정 완료했다.
- Phase 21 리뷰의 `LIVE_REPLAY` fallback 오기는 기존 기록을 삭제하지 않고 `targetId` 기반 콘텐츠 상세 이동 및 creator fallback 없음으로 append-only 정정 완료했다.
- `rg -n "MainHomeApi|MainHomeRecommendationApi|CreatorProfileGrid|MainHomeRecentDebutCreatorItemView|LIVE_REPLAY|liveReplay|targetId" docs/20260602_메인_홈_추천_UI_API_연동/prd.md docs/20260602_메인_홈_추천_UI_API_연동/reviews/phase11-recent-debut-creators.md docs/20260602_메인_홈_추천_UI_API_연동/reviews/phase21-recent-activity-live-fallback.md`로 활성 타입명과 append-only 정정 기록을 확인했다.
- `git diff --check` — 성공, 출력 없음.
**현재 결론:** `REV-P19-004` 수정 완료
### 4차 최종 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P19-005` | Low | 확정 | Phase 11 활성 완료 기준이 현재 전용 카드 구현과 최신 리뷰 판정에 반대됨 | `Task 19.5` / `P19-R4` |
#### REV-P19-005 — 최근 데뷔 섹션 활성 계획 기준 충돌
- **관찰 내용:** `Task 11.1``CreatorProfileGrid``CreatorProfileItem` 우선 재사용 및 공용 widget 재사용을 기대 결과로 둔다. 현재 `MainHomeRecentDebutCreatorSection`은 Figma 직사각형 카드 요구 때문에 전용 `MainHomeRecentDebutCreatorItemView`를 사용하고, Phase 11 최신 리뷰도 이를 올바른 구현으로 판정한다.
- **영향:** Task는 완료 상태지만 활성 완료 기준만 적용하면 현재 올바르다고 판정한 코드를 실패로 보게 되어 후속 구현·리뷰 기준이 모순된다.
- **근거:** `plan-task.md:440-453`, `MainHomeRecentDebutCreatorSection.swift:20-69`, `phase11-recent-debut-creators.md` 3~5차 정정 기록.
- **재현 또는 검증:** `rg -n 'CreatorProfileGrid|MainHomeRecentDebutCreatorItemView' plan-task.md phase11-recent-debut-creators.md MainHomeRecentDebutCreatorSection.swift`로 활성 Task와 현재 코드·리뷰의 반대 기준을 확인한다.
- **권장 조치:** 공용 우선 원칙은 보존하되 기존 원형 widget이 Figma 직사각형 카드를 충족하지 못해 페이지 전용 View를 사용한다는 활성 예외와 현재 기대 결과를 `Task 11.1`에 명시한다.
- **계획 전환:** `plan-task.md` Phase 19에 `Task 19.5` / `P19-R4`를 추가했다.
**현재 결론:** `REV-P19-005` 수정 goal 필요
#### REV-P19-005 수정 기록 — 2026-07-31
- `plan-task.md`의 활성 `Task 11.1` 작업 내용과 검증 기준을 현재 코드·Phase 11 최신 리뷰 결론에 맞게 정정했다.
- 공용 컴포넌트 우선 원칙은 유지하되, 공용 원형 `CreatorProfileGrid`/`CreatorProfileItem`으로 Figma 직사각형 카드 형태를 충족하지 못할 때 페이지 전용 `MainHomeRecentDebutCreatorItemView`를 사용하는 예외를 명시했다.
- 기존 Phase 11 완료 기록과 2차~5차 리뷰 정정 이력은 삭제하지 않고, Phase 11 리뷰에 6차 정정 기록을 누적했다.
- `rg -n "CreatorProfileGrid|MainHomeRecentDebutCreatorItemView" "docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md" "docs/20260602_메인_홈_추천_UI_API_연동/reviews/phase11-recent-debut-creators.md" "SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeRecentDebutCreatorSection.swift"``git diff --check`로 확인했다.
**현재 결론:** `REV-P19-005` 수정 완료

View File

@@ -0,0 +1,103 @@
# Phase 2 신규 API와 응답 모델 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.1, `plan-task.md` Phase 2 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 추천 전용 endpoint의 격리, 인증 패턴과 `HomeRecommendationResponse`의 PRD 계약 일치 여부를 확인한다.
- API/Repository/응답 모델을 포함하고 UI 렌더링은 제외한다.
## 3. 판정 기준
- PRD에 없는 필수 `Decodable` 필드가 기준 응답을 실패시키면 High로 판정한다.
- Swift `Decodable` 동작, 실제 사용처와 전체 빌드로 후보를 검증한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeRecommendationApi.swift:14-46`, `MainHomeRecommendationRepository.swift:6-15`, `MainHomeRecommendationResponse.swift:3-79`
- 문서: PRD의 `HomeRecommendationResponse` 스키마, Task 2.2의 과거 추정 필드 제거 기준
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "/api/v2/home/recommendations\|getRecommendations" ...` | 성공 | endpoint는 추천 전용 Repository 경로에만 존재 |
| `rg "firstAudioContents\|HomeFirstAudioContentItem" ...` | 실패 발견 | 모델에만 남고 PRD와 소비 UI에는 없음 |
| `swift -module-cache-path /private/tmp/sodalive-review-swift-module-cache -e '<필수 Decodable 키 누락 재현>'` | 실패 재현 | `keyNotFound(firstAudioContents)` |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | 컴파일은 성공하나 런타임 계약 실패를 검출하지 않음 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P2-001` | High | 확정 | PRD에 없는 `firstAudioContents` 필수 키가 추천 응답 전체 디코딩을 실패시킬 수 있음 | `Task 2.3` / `P2-R1` |
## 6. 발견 사항 상세
### REV-P2-001 — PRD에 없는 필수 디코딩 키
- **심각도:** High
- **상태:** 확정
- **소유 Task:** `Task 2.3`
**관찰 내용**
`HomeRecommendationResponse.firstAudioContents`는 non-optional이지만 현재 PRD 응답 스키마에 없고 앱에서도 소비되지 않는다. PRD 기준 JSON에 이 키가 없으면 `JSONDecoder``keyNotFound`를 던져 다른 모든 추천 섹션도 표시되지 않는다.
**근거**
- 코드: `MainHomeRecommendationResponse.swift:8`, `:70-79`
- 문서: PRD 7.1 응답에는 `firstAudioContents`가 없음
- 사용처: 모델 선언 외 검색 결과 없음
**재현**
1. `lives` 등 PRD 필수 키를 포함하고 `firstAudioContents`를 제외한 JSON을 준비한다.
2. non-optional 동일 구조로 디코딩한다.
3. 실제 결과는 `keyNotFound(firstAudioContents)`다.
4. 요구 결과는 PRD 기준 응답의 정상 디코딩이다.
**권장 조치**
실제 사용 경로가 없는 필드와 전용 item 모델을 제거하고 PRD 최소 응답 디코딩 및 앱 빌드를 검증한다.
**판정 기록**
- 2026-07-30 — 코드, PRD, 사용처 검색과 Swift 디코딩 재현으로 확정.
## 7. 확정 항목의 plan·goal 전환
`plan-task.md` Phase 2에 `Task 2.3` / `P2-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | API→Repository→모델 추적 |
| 후보 항목 판정 완료 | 충족 | `REV-P2-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 2.3 |
| 검증 명령과 결과 기록 | 충족 | 4절 |
**최종 결론:** 수정 goal 필요
**남은 항목:** `REV-P2-001`
## 9. 수정 후 검증 기록
- 2026-07-30 — `HomeRecommendationResponse`에서 `firstAudioContents``HomeFirstAudioContentItem` 제거.
- `swift -module-cache-path "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sodalive-swift-module-cache" -e '<PRD 필수 필드만 포함한 추천 JSON 디코딩>'` 실행, `decoded without firstAudioContents` 확인.
- `rg -n "firstAudioContents|HomeFirstAudioContentItem" "SodaLive/Sources/V2/Main/Home/Recommendation"` 실행, 검색 결과 없음 확인.
- `git diff --check`, `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`, `SodaLive-dev` Debug 빌드 통과.
### 2차 통합 재점검 — 2026-07-31
- PRD 응답 필드와 현재 `HomeRecommendationResponse`를 다시 대조했다.
- `REV-P2-001``firstAudioContents` 제거와 기준 JSON 디코딩 기록을 확인해 **수정 완료**로 판정했으며, 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,62 @@
# Phase 20 커뮤니티 게시물 상세 라우팅 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 20 / Task 20.1~20.3 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.3~7.4, `plan-task.md` Phase 20 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 최근 활동 `COMMUNITY`, 인기 커뮤니티 카드/댓글의 `postId` 상세 이동과 다른 action 비회귀를 확인한다.
- 팔로잉 스케줄의 기존 `.community` route는 변경 제외 범위로 확인한다.
## 3. 판정 기준
- 추천 흐름은 `.creatorChannelCommunityPostDetail(postId:)`, 팔로잉 스케줄은 기존 `.creatorCommunityAll(creatorId:)`를 유지해야 한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `HomeActiveCreatorTapDestination`, `MainHomePopularCommunitySection.swift:27-53`, `MainView.swift:539-543`, `:575-588`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "onTapCommunityPost\|creatorChannelCommunityPostDetail\|onTapPost\|onTapComment" ...` | 성공 | 추천 `postId` 상세 연결 |
| `rg "handleFollowingScheduleTap\|creatorCommunityAll" MainView.swift` | 성공 | 팔로잉 기존 분기 유지 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | 모델→section→MainView route 추적 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 최근 활동 COMMUNITY와 인기 커뮤니티 카드가 `postId` 기준 상세 callback을 전달하고 공통 상세 guard를 재사용하는지 다시 확인했다.
- 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,174 @@
# Phase 21 최근 활동 LIVE 이동 fallback 보정 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 21 / Task 21.1~21.4 |
| 기준 commit 또는 working tree | `0232cd43356dd1988dea6cf03563716542cd491c` 기준 working tree의 Phase 21 변경 6개 파일 |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `docs/20260602_메인_홈_추천_UI_API_연동/prd.md`, `docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- Phase 21의 완료 체크와 실제 모델, 활동 타입 변환, 탭 목적지, callback 연결 및 검증 기록이 일치하는지 확인한다.
- `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`, 알 수 없는 활동 타입의 이동 규칙과 기존 상세 진입 guard 재사용 여부를 확인한다.
### 포함 범위
- 코드:
- `SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift`
- `SodaLive/Sources/V2/Main/Home/Recommendation/Models/RecommendedActivityType.swift`
- `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeActiveCreatorSection.swift`
- `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift`
- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- `SodaLive/Sources/V2/Main/MainView.swift`
- 테스트:
- 변경된 실제 모델 소스를 `swiftc`로 컴파일한 임시 디코딩·라우팅 회귀 체크
- 저장소에 앱 테스트 번들 타깃이 없어 별도 unit test는 실행하지 않음
- 문서:
- PRD의 응답 스키마, 진입 규칙, 성공 기준, 검증 계획
- `plan-task.md`의 Phase 21 Task와 검증 기록
- 정적/빌드 검증:
- 공백 오류, 프로젝트 파일 유효성, `SodaLive-dev` Debug 빌드
### 제외 범위
- Phase 1~20 구현의 재검토
- 실제 서버 응답 계약 자체의 재검증
- 로그인·본인인증·민감 콘텐츠 설정 상태를 포함한 실기기/시뮬레이터 수동 탭 검증
- Phase 21과 무관한 기존 코드와 빌드 경고
## 3. 판정 기준
### 심각도
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
### 상태
| 상태 | 의미 | 후속 처리 |
|---|---|---|
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 |
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항:
- `prd.md:78-85``HomeActiveCreatorItem.creatorId`
- `prd.md:184-189`의 최근 활동 `LIVE`, `LIVE_REPLAY`, 기존 활동 타입 이동 규칙
- `prd.md:312-314`의 Phase 21 성공 기준
- 계획:
- `plan-task.md:540-629`의 Task 21.1~21.4
- `plan-task.md:633-670`의 Phase 21 구현 및 정정 검증 기록
- 코드:
- `MainHomeRecommendationResponse.swift:29-61`의 필수 `creatorId`와 탭 목적지 분기
- `RecommendedActivityType.swift:3-38``LIVE`/`LIVE_REPLAY` 분리 및 동일 I18n 문구
- `MainHomeActiveCreatorSection.swift:3-55`의 callback 전달
- `MainHomeRecommendationView.swift:38-44``onTapCreator` 연결
- `MainView.swift:474-509`의 양수 검사, 상세 진입 guard 및 최종 이동
- 테스트:
- 실제 변경 모델 소스와 임시 stub/check 소스를 함께 컴파일해 9개 디코딩·라우팅 시나리오와 `LIVE`/`LIVE_REPLAY` 표시 문구 일치를 확인했다.
- 임시 소스와 실행 바이너리는 검증 직후 `/private/tmp`에서 삭제했다.
### 실행 환경
```text
OS: macOS 26.0 (25A354)
Xcode: 26.0 (17A324)
Swift: 6.2
빌드 대상: Any iOS Device / SodaLive-dev / Debug
환경 변수: 별도 설정 없음, 인증 토큰 및 민감정보 미사용
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `git diff --check` | 성공 | exit code 0, 출력 없음 |
| `plutil -lint SodaLive.xcodeproj/project.pbxproj` | 성공 | `OK` |
| `xcrun swiftc /private/tmp/sodalive_phase21_review_stubs.swift SodaLive/Sources/V2/Main/Home/Recommendation/Models/RecommendedActivityType.swift SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift /private/tmp/sodalive_phase21_review_check.swift -o /private/tmp/sodalive_phase21_review_check` | 성공 | 실제 변경 모델 소스 컴파일 성공 |
| `/private/tmp/sodalive_phase21_review_check` | 성공 | `Phase 21 routing checks passed: 9` |
| `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug build` (sandbox) | 실패 | exit code 66, CoreSimulator·캐시 접근 제한으로 workspace를 열지 못한 환경 실패 |
| `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug build` (sandbox 외 재실행) | 성공 | exit code 0, `BUILD SUCCEEDED` |
| 실기기/시뮬레이터 탭 검증 | 불가 | 실제 서버 응답과 인증 상태를 준비하지 않아 제외 범위로 유지 |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
- 문서의 `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`, unknown 분기와 실제 코드가 일치한다.
- `LIVE + targetId == nil``creatorId > 0` 검사와 최종 callback의 양수 guard가 확인된다.
- 기존 `performRecommendationDetailAction`과 라이브룸 입장 흐름을 재사용한다.
- working tree 기준 정적 검증, 임시 회귀 체크 및 `SodaLive-dev` Debug 빌드가 성공했다.
## 6. 발견 사항 상세
확정 발견 사항 없음.
실기기/시뮬레이터 수동 탭 검증은 제외 범위이며, 코드·문서 불일치나 자동 검증 실패로 판정하지 않았다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
확정 발견 사항이 없으므로 `plan-task.md` Phase 21에 신규 회귀 수정 Task를 추가하지 않는다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 21 문서, 변경 코드, callback 소비 지점, 정적 검증 및 빌드 확인 |
| 후보 항목 판정 완료 | 충족 | 남은 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
| 검증 명령과 결과 기록 | 충족 | 4절에 실제 명령, 성공·실패 및 환경 사유 기록 |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 자동 리뷰 범위 내 없음. 실기기/시뮬레이터 수동 탭 검증은 제외 범위다.
## 9. 수정 후 검증 기록
해당 없음. 확정 발견 사항과 후속 수정이 없다.
### 2차 통합 재점검 — 2026-07-30
- 기준: `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree
- 무엇을: Phase 22 working tree 변경이 Phase 21의 `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`, unknown route와 기존 guard 연결을 회귀시키지 않았는지 재확인
- 어떻게:
- `rg -n "activeCreatorTapDestination|onTapCreator|creatorId > 0|liveReplay|onTapLive|onTapContent|onTapCommunityPost" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Main/MainView.swift` — 분기와 callback 유지 확인
- `git diff --check` — 성공, 출력 없음
- `plutil -lint SodaLive.xcodeproj/project.pbxproj``OK`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build``BUILD SUCCEEDED`
- 결과: 기존 최종 결론 유지, 확정 발견 사항 없음
### 3차 통합 재점검 — 2026-07-31
- LIVE/LIVE_REPLAY에서 유효한 `targetId`는 라이브로, 무효 target은 creator 상세로 이동하는 fallback과 기존 guard를 다시 확인했다.
- 추가 확정 발견 사항 없음.
### 4차 통합 재점검 — 2026-07-31
- 3차 기록은 `LIVE_REPLAY` 분기를 `LIVE`와 합쳐 서술한 오기다. 실제 구현과 활성 요구사항은 `LIVE + targetId`만 라이브룸, `LIVE + targetId == nil + creatorId > 0`만 creator 상세로 이동한다.
- `LIVE_REPLAY + targetId`는 콘텐츠 상세로 이동하고, `targetId == nil`이면 이동하지 않으며 creator fallback을 적용하지 않는다.
- append-only 리뷰 정정은 Phase 19 `REV-P19-004` / `Task 19.4`로 전환했다. Phase 21 production 코드의 추가 확정 발견 사항은 없다.
### 5차 통합 재점검 — 2026-07-31
- `REV-P19-004` 보완으로 3차 기록의 `LIVE_REPLAY` fallback 오기는 위 4차 기록에서 append-only로 정정 완료했다.
- 현재 활성 결론은 `LIVE_REPLAY + targetId`만 콘텐츠 상세로 이동하고 `targetId == nil`이면 이동하지 않는 것이며, creator fallback은 적용하지 않는다.

View File

@@ -0,0 +1,77 @@
# Phase 22 모두 팔로우 확인 Dialog 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 22 / Task 22.1~22.3 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree의 Phase 22 변경 |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.6, `plan-task.md` Phase 22 |
| 리뷰 상태 | 판정 완료 / Phase Gate 완료 |
## 2. 리뷰 목적과 범위
- 로그인 선행, 보류 action, 확인/취소/dimmed 처리, I18n과 기존 follow API 불변성을 확인한다.
- 실제 로그인 계정과 네트워크 요청 횟수를 사용하는 수동 검증까지 완료했다.
## 3. 판정 기준
- 정적 흐름과 빌드는 코드 결함을 판정하고, 요청 0/1회와 언어별 표시는 수동 검증 결과로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainView.swift:27-30`, `:80-82`, `:332-358`, `:546-555`; `I18n.swift`의 follow confirm 문구
- API: `MainHomeRecommendationViewModel.followAll`, 기존 request/Repository
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| 상태/action/I18n `rg` | 성공 | 로그인 guard 뒤 dialog, 확인/취소 시 pending action 제거 |
| 기존 follow API/request `rg` | 성공 | endpoint/request/ViewModel 계약 변경 없음 |
| `git diff --check` | 성공 | 출력 없음 |
| `plutil -lint SodaLive.xcodeproj/project.pbxproj` | 성공 | `OK` |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
| 실제 확인 전·취소·dimmed 요청 0회/확인 1회 | 성공 | 사용자 수동 테스트 결과 발견된 오류 없음 |
| ko/en/ja 화면 표시 | 성공 | 사용자 수동 테스트 결과 발견된 오류 없음 |
## 5. 발견 사항 요약
**정적 리뷰에서 확정된 코드 결함 없음.**
Task 22.3의 수동 Phase Gate까지 완료됐다.
## 6. 발견 사항 상세
코드상 확인 시 pending action을 지역 변수로 옮긴 뒤 상태를 먼저 비우고 1회 실행하며, 취소와 dimmed는 action을 폐기한다. 비로그인은 action을 저장하지 않고 로그인으로 이동한다.
수동 네트워크 횟수와 다국어 화면은 사용자 수동 테스트에서 발견된 오류가 없음을 완료 근거로 반영했다.
## 7. 확정 항목의 plan·goal 전환
신규 전환 항목 없음. 기존 `Task 22.3`을 완료 처리했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 정적 범위 확인 | 충족 | callback/I18n/API 추적 |
| 후보 판정 | 충족 | 정적 코드 후보 없음 |
| 수동 Phase Gate | 충족 | 요청 횟수/언어별 표시 수동 검증 완료 |
| 신규 plan 반영 | 해당 없음 | 추가 전환 항목 없음 |
| 자동 검증 기록 | 충족 | 4절 |
**최종 결론:** 정적 확정 발견 사항 없음, Task 22.3 수동 검증 완료
**남은 항목:** 없음
## 9. 수정 후 검증 기록
- 2026-07-31 — 사용자 수동 테스트 결과 발견된 오류 없음.
- 확인 전·취소·dimmed 요청 0회, 확인 시 요청 1회, 비로그인 로그인 이동, 자동 팔로우 미실행, 기존 완료/호출 중/API 실패 toast 유지, ko/en/ja 문구 표시를 완료 근거로 반영.
### 2차 통합 재점검 — 2026-07-31
- 수동 완료 증거와 확인 전 0회·확인 시 1회 요청, 비로그인·loading·오류·ko/en/ja 조건을 재검토했다.
- 추가 확정 발견 사항 및 남은 확인 항목 없음.

View File

@@ -0,0 +1,62 @@
# Phase 3 활동 타입과 I18n 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 3 / Task 3.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.2, `plan-task.md` Phase 3 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`, unknown 변환과 ko/en/ja 문구를 확인한다.
- 활동 타입 표시만 포함하고 탭 목적지는 Phase 21로 제외한다.
## 3. 판정 기준
- 서버 코드 비노출, 확정 enum별 I18n 매핑과 빌드 성공을 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `RecommendedActivityType.swift:3-39`, `I18n.swift``HomeRecommendation`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "LIVE_REPLAY\|activityLive\|activityAudio\|activityCommunity\|ライブ\|オーディオ\|コミュニティ" ...` | 성공 | enum과 ko/en/ja 문구 확인 |
| `rg "case \\.unknown\|return \\"\\"" RecommendedActivityType.swift` | 성공 | unknown 서버 코드 비노출 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | enum/I18n 대조 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- `RecommendedActivityType`의 LIVE/LIVE_REPLAY/AUDIO/COMMUNITY 및 unknown 표시와 ko/en/ja I18n 연결을 다시 확인했다.
- 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,99 @@
# Phase 4 추천 데이터 상태 계층 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / Task 4.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 4 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 추천 API 로딩, 성공 데이터, 오류, 모두 팔로우와 커뮤니티 action 상태가 전용 ViewModel에 모였는지 확인한다.
- ViewModel/Repository 연결을 포함하고 개별 UI 표현은 제외한다.
## 3. 판정 기준
- `View → ViewModel → Repository → Api` 책임 경계, 실패 처리와 중복 요청 방지를 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeRecommendationViewModel.swift:4-183`, `MainHomeRecommendationRepository.swift`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "final class MainHomeRecommendationViewModel\|fetchRecommendations\|isLoading\|errorMessage\|followAll" SodaLive/Sources/V2/Main/Home` | 성공 | 전용 상태 계층 확인 |
| `rg "unowned self" MainHomeRecommendationViewModel.swift` | 성공 | 검색 결과 없음, weak 캡처 사용 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. 응답 계약 결함은 `REV-P2-001`이 소유한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | 상태와 Repository 흐름 추적 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 추천 조회·로딩·오류·mutation 상태의 `MainHomeRecommendationViewModel` 소유와 Repository 연결을 다시 확인했다.
- 추가 확정 발견 사항 없음.
### 3차 최종 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P4-001` | Low | 확정 | 추천 응답 디코딩 오류가 프로젝트 오류 로그 대신 `print`로 출력됨 | `Task 4.2` / `P4-R1` |
#### REV-P4-001 — 신규 ViewModel 로깅 규칙 불일치
- **관찰 내용:** `MainHomeRecommendationViewModel.fetchRecommendations()`의 디코딩 `catch`는 toast와 loading 상태를 처리하면서 오류 자체는 `print(error)`로 출력한다.
- **영향:** 신규 코드의 오류가 프로젝트 `ERROR_LOG` 경로와 분리되어 운영·디버그 로그 수집과 검색이 일관되지 않다. 사용자 오류 처리는 유지되므로 심각도는 Low다.
- **근거:** `MainHomeRecommendationViewModel.swift:50-54`; `docs/agent-guides/code-style.md`의 로깅 규칙.
- **재현 또는 검증:** `rg -n 'print\\(' SodaLive/Sources/V2/Main/Home/Recommendation` 실행 시 해당 한 건이 검색되고, 같은 파일의 네트워크 오류는 `ERROR_LOG(error.localizedDescription)`을 사용한다.
- **권장 조치:** toast·loading 분기는 건드리지 않고 `print(error)``ERROR_LOG(error.localizedDescription)`로 교체한다.
- **계획 전환:** `plan-task.md` Phase 4에 `Task 4.2` / `P4-R1`을 추가했다.
**현재 결론:** `REV-P4-001` 수정 goal 필요
#### REV-P4-001 수정 기록 — 2026-07-31
- `MainHomeRecommendationViewModel.fetchRecommendations()`의 추천 응답 디코딩 `catch`에서 `print(error)``ERROR_LOG(error.localizedDescription)`로 교체했다.
- 기존 toast, popup, loading 상태 처리는 변경하지 않았다.
- `rg -n "print\(|ERROR_LOG|func fetchRecommendations|catch" "SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift" "SodaLive/Sources/Utils/Log.swift"`로 추천 ViewModel 범위의 직접 `print(` 제거와 `ERROR_LOG` 사용을 확인했다.
- `git diff --check`, `plutil -lint`로 검증했다.
- `SodaLive-dev` Debug 빌드는 같은 DerivedData로 2회 실행했지만 Pods/SPM 및 app resource 처리 중 10분 도구 제한으로 `BUILD INTERRUPTED`되어 성공 증거를 확보하지 못했다.
**현재 결론:** 코드 수정 완료, 빌드 검증 차단
### 4차 최종 검증 — 2026-07-31
- `rg -n "print\\(|ERROR_LOG|func fetchRecommendations|catch" SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift SodaLive/Sources/Utils/Log.swift`로 추천 ViewModel에 직접 `print(`가 없고 디코딩 `catch``ERROR_LOG(error.localizedDescription)`를 사용하는 것을 다시 확인했다.
- `git diff --check`, `git diff --cached --check`는 출력 없이 성공했고 `plutil -lint SodaLive.xcodeproj/project.pbxproj``OK`였다.
- `xcodebuild -quiet -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -destination generic/platform=iOS -derivedDataPath /private/tmp/SodaLiveDerivedDataReviewFinal-dev build`는 종료 코드 0으로 통과했다.
- 같은 DerivedData에서 `SodaLive` 운영 스킴도 종료 코드 0으로 통과했다. 출력된 기존 앱·외부 의존성 경고는 이 Low 로깅 항목의 완료 조건을 막지 않는다.
- `plan-task.md``Task 4.2` / `P4-R1`을 완료 처리했다.
**현재 결론:** `REV-P4-001` 수정 및 검증 완료

View File

@@ -0,0 +1,226 @@
# Phase 5 추천 상세 진입 라우팅 guard 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5 / Task 5.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.3, `plan-task.md` Phase 5 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 추천 상세 callback이 `MainView`의 로그인/본인인증/민감 콘텐츠 guard와 올바른 `AppStep`을 사용하는지 확인한다.
- `MainView`, `MainHomeView`, `MainHomeRecommendationView` callback 체인을 포함한다.
## 3. 판정 기준
- 확정된 상세 타입과 다른 route로 이동하면 High로 판정한다.
- 외부 계약이 필요한 성인 여부는 보류 조건으로 분리하고 임의 구현 여부만 확인한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainView.swift:175-197`, `:508-555`, `:707-739`; `HomeTabView.swift:40-71`
- 문서: PRD 7.3의 `characterDetail`, Task 5.1의 실제 상세 이동 기준
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "onTapCharacter: handleRecommendationCreatorTap\|characterDetail" ...` | 실패 발견 | 추천 AI callback만 creator handler에 연결 |
| `rg "performRecommendationDetailAction\|pendingAction\|contentViewSettings" MainView.swift` | 성공 | 공통 guard 인프라 존재 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | 잘못된 타입 연결은 컴파일로 검출되지 않음 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P5-001` | High | 확정 | AI 캐릭터 callback이 크리에이터 상세 handler에 연결됨 | `Task 5.2` / `P5-R1` |
## 6. 발견 사항 상세
### REV-P5-001 — AI 캐릭터 상세 route 오연결
- **심각도:** High
- **상태:** 확정
- **소유 Task:** `Task 5.2`
**관찰 내용**
`MainView``onTapCharacter``handleRecommendationCreatorTap`을 전달한다. 따라서 값이 무엇이든 `.creatorDetail(userId:)`로 이동하며 `.characterDetail(characterId:)` 경로가 추천 홈에 없다.
**근거**
- 코드: `MainView.swift:186`, `:525-529`
- 정상 선례: `HomeTabView.swift:40-71`
- 실제 route: `AppStep.characterDetail(characterId:)`, `ContentView``CharacterDetailView`
**영향**
AI 캐릭터 카드를 탭하면 요청한 캐릭터 상세가 아니라 크리에이터 상세로 이동한다. 캐릭터용 본인인증/민감 콘텐츠 흐름도 적용할 수 없다.
**권장 조치**
양수 `characterId`를 받아 기존 추천 guard를 재사용하고 `.characterDetail(characterId:)`를 실행하는 전용 handler를 연결한다. 섹션의 ID 전달은 `REV-P12-001`과 함께 수정한다.
**판정 기록**
- 2026-07-30 — callback 소비 지점과 기존 Character route 대조로 확정.
**보류 조건**
`HomeLiveItem.isAdult` 부재에 따른 라이브 성인 guard는 PRD에 이미 외부 계약으로 기록되어 있으며 이번 결함과 분리한다.
## 7. 확정 항목의 plan·goal 전환
`plan-task.md` Phase 5에 `Task 5.2` / `P5-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | callback→guard→AppStep 추적 |
| 후보 판정 | 충족 | `REV-P5-001` 확정 |
| plan 반영 | 충족 | Task 5.2 |
| 보류 조건 기록 | 충족 | 라이브 성인 필드 계약 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 수정 goal 필요
## 9. 수정 후 검증 기록
- 2026-07-30 — `MainView``handleRecommendationCharacterTap(characterId:)` 추가 후 `onTapCharacter`를 해당 handler에 연결.
- 양수 `characterId`만 통과시키고 기존 `performRecommendationDetailAction(requiresAdultContent: true)` guard를 재사용해 `.characterDetail(characterId:)`로 이동하도록 수정.
- `rg -n "onTapCharacter:|handleRecommendationCharacterTap|characterDetail\(characterId:" "SodaLive/Sources/V2/Main/MainView.swift"` 실행, 연결 확인.
- `git diff --check`, `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`, `SodaLive-dev` Debug 빌드 통과.
- 실제 기기/시뮬레이터 탭 수동 확인은 미실행이며 잔여 확인 사항으로 남김.
### 2차 통합 재점검 — 2026-07-31
- AI 캐릭터 callback이 `handleRecommendationCharacterTap(characterId:)`와 공통 상세 guard를 거쳐 `.characterDetail`로 이어지는 현재 경로를 확인했다.
- `REV-P5-001`은 **수정 완료**로 판정했으며, 추가 확정 발견 사항 없음.
### 3차 최종 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P5-002` | High | 확정 | 본인인증 성공 후 민감 콘텐츠 보기 설정 guard를 건너뜀 | `Task 5.3` / `P5-R2` |
#### REV-P5-002 — 본인인증 후 민감 콘텐츠 설정 guard 우회
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** PRD 7.3 추천 탭 상세 진입 guard, Success Criteria의 로그인·본인인증·민감 콘텐츠 설정 순차 통과
- **소유 Task:** `Task 5.3` / `P5-R2`
**관찰 내용**
`performRecommendationDetailAction(requiresAdultContent: true)`은 한국 사용자이고 `auth == false`이면 최종 상세 route closure 자체를 `pendingAction`에 저장하고 설정 검사를 실행하기 전에 반환한다. 이후 `authView`의 인증 성공 callback은 `auth = true`로 바꾼 뒤 저장한 closure를 직접 실행하므로 `UserDefaults.isAdultContentVisible() == false`여도 `contentViewSettings`로 이동하지 않고 캐릭터 상세로 진입한다.
**근거**
- 코드: `MainView.swift:371-377`의 인증 성공 후 `pendingAction` 직접 실행
- 코드: `MainView.swift:714-745`의 본인인증 분기 뒤에 위치한 민감 콘텐츠 설정 검사
- route 소비: `MainView.swift:539-543`의 AI 캐릭터 상세 action
- 문서: `prd.md` 7.3과 Success Criteria는 상세 route 전에 본인인증과 민감 콘텐츠 보기 설정 guard를 모두 통과하도록 요구한다.
**재현 또는 검증 절차**
1. 토큰이 있고 국가 코드가 `KR` 또는 빈 값이며 `auth == false`, `isAdultContentVisible == false`인 상태를 만든다.
2. AI 캐릭터 카드를 터치하고 본인인증을 완료한다.
3. 현재 상태 전이 self-check는 `detail=true settings=false`를 출력한다.
4. 요구 결과는 인증 성공 후 민감 콘텐츠 설정을 다시 검사해 `contentViewSettings`로 이동하고 상세 route는 실행하지 않는 것이다.
**영향**
본인인증과 민감 콘텐츠 설정이 모두 필요한 제한된 상태에서 사용자가 비활성화한 민감 콘텐츠 보기 설정을 우회해 AI 캐릭터 상세로 진입한다. 명시적인 콘텐츠 보호 요구사항을 위반하므로 High로 판정한다.
**권장 조치**
인증 성공 뒤 저장된 최종 route를 직접 실행하지 말고 기존 성인 콘텐츠 guard의 설정 검사를 다시 통과하게 한다. 별도 guard를 복제하지 않고 `performRecommendationDetailAction(requiresAdultContent: true)` 흐름을 한 번 더 소비하는 최소 수정으로 제한한다.
**판정 기록**
- 2026-07-31 — 코드 분기 순서와 최소 Swift 상태 전이 self-check의 `detail=true settings=false` 결과로 확정.
**계획 전환**
`plan-task.md` Phase 5에 `Task 5.3` / `P5-R2`를 추가했다.
#### REV-P5-002 수정 기록 — 2026-07-31
- `pendingAction`에 최종 상세 route closure를 직접 저장하지 않고, 인증 성공 후 `performRecommendationDetailAction(requiresAdultContent: true, action:)`를 다시 통과하는 closure를 저장하도록 수정했다.
- 기존 로그인 guard, 비한국 사용자, 이미 본인인증된 사용자, 민감 콘텐츠 설정 활성 사용자 흐름은 변경하지 않았다.
- Swift 상태 전이 self-check로 기존 방식의 `detail=true settings=false` 실패를 재현하고, 수정 방식에서 `settings=false`는 설정 이동·상세 미실행, `settings=true`는 상세 실행이 되는 것을 확인했다.
- `rg -n "pendingAction = \{|performRecommendationDetailAction\(requiresAdultContent: true, action: action\)" "SodaLive/Sources/V2/Main/MainView.swift"`, `git diff --check`, `plutil -lint`로 정적 검증했다.
- `SodaLive-dev` Debug 빌드는 iPhoneOS에서 10분 도구 제한으로 반복 `BUILD INTERRUPTED`, Simulator에서 SPM `Promises` 컴파일 단계 실패로 성공 증거를 확보하지 못했다.
- 실제 기기/시뮬레이터의 Bootpay 본인인증 완료 수동 확인은 이번 환경에서 수행하지 못해 상태 전이 self-check와 정적 검증으로 대체했다.
**현재 결론:** `REV-P5-001`, `REV-P5-002` 수정 완료
### 4차 수정 검증 및 추가 재점검 — 2026-07-31
#### 기존 수정 확인
- `REV-P5-002` 수정 후 `pendingAction`은 최종 상세 route가 아니라 `performRecommendationDetailAction(requiresAdultContent: true, action:)`를 다시 실행한다.
- 한국 사용자 기준 인증 전후와 민감 콘텐츠 설정 조합 self-check는 `auth-guard-self-check=passed`로 통과했다.
- `git diff --check`, `git diff --cached --check`, `plutil -lint`를 통과했다.
- `SodaLive-dev``SodaLive` Debug `generic/platform=iOS` 빌드는 모두 종료 코드 0으로 통과했다.
#### 추가 확정 항목
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P5-003` | High | 확정 | 활성 라이브·AI 대화 경로가 인증 후 민감 콘텐츠 설정 guard를 건너뜀 | `Task 5.4` / `P5-R3` |
**관찰 내용**
- `LiveNowAllView.handleLiveNowItemTap`은 한국 사용자의 본인인증이 필요하면 `openLiveDetail` 자체를 `pendingAction`에 저장한다. 인증 성공 callback은 이를 직접 실행하므로 민감 콘텐츠 보기 설정 검사를 다시 수행하지 않는다.
- `CreatorChannelView.handleTalkTap`도 같은 조건에서 `startChat` 자체를 저장하고 인증 성공 후 직접 실행한다.
- 두 화면은 각각 `ContentView``.liveNowAll`, `.creatorDetail` route에서 현재 사용된다.
**근거**
- `LiveNowAllView.swift:148-156`, `:165-190`
- `CreatorChannelView.swift:390-398`, `:520-549`
- `ContentView.swift:198-220`
- 최소 상태 전이 결과: 두 경로 모두 `route=true settings=false`
**영향**
한국 사용자가 본인인증과 민감 콘텐츠 설정 확인을 모두 필요로 하는 상태에서 성인 라이브 또는 AI 대화로 진입하면, 사용자가 비활성화한 민감 콘텐츠 보기 설정이 적용되지 않는다. 활성 보호 경로의 콘텐츠 정책 우회이므로 High로 판정한다.
**권장 조치**
각 화면에서 최종 route를 저장하지 말고 기존 진입 함수를 다시 호출하는 closure를 저장해 인증 성공 후 남은 설정 guard를 통과하게 한다. 별도 공용 guard 계층은 추가하지 않는다.
**계획 전환**
`plan-task.md` Phase 5에 `Task 5.4` / `P5-R3`를 추가했다. 현재 앱 route에서 사용되지 않는 레거시 `HomeView`, `HomeTabView`, `ChatTabView`는 이번 Task 범위에서 제외했다.
#### REV-P5-003 수정 기록 — 2026-07-31
- `LiveNowAllView.handleLiveNowItemTap``pendingAction`이 인증 성공 후 `openLiveDetail(roomId:)`를 직접 실행하지 않고 `handleLiveNowItemTap(roomId:isAdult:)`를 다시 호출하도록 수정했다.
- `CreatorChannelView.handleTalkTap``pendingAction`이 인증 성공 후 `startChat(characterId:)`를 직접 실행하지 않고 `handleTalkTap()`을 다시 호출하도록 수정했다.
- 기존 로그인 guard, 국가 분기, 본인인증 dialog, 민감 콘텐츠 설정 이동, 최종 라이브/AI 대화 route는 변경하지 않았다.
- Swift 상태 전이 self-check로 기존 방식의 `route=true settings=false` 실패를 재현하고, 수정 방식에서 `auth=false/설정=false`, `auth=false/설정=true`, `auth=true/설정=false`, `auth=true/설정=true`, 비로그인, 취소 흐름이 기대 상태를 통과하는 것을 확인했다.
- `rg -n "pendingAction = \\{|handleLiveNowItemTap\\(roomId: roomId, isAdult: isAdult\\)|handleTalkTap\\(\\)|openLiveDetail\\(roomId: roomId\\)|startChat\\(characterId: characterId\\)|contentViewSettings|isAdultContentVisible" "SodaLive/Sources/Live/Now/All/LiveNowAllView.swift" "SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift"` 실행, guard 재진입과 기존 route 유지 확인.
- `git diff --check`, `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`, 미완료 Task 검색을 통과했다.
- `SodaLive-dev`, `SodaLive` Debug `generic/platform=iOS` 빌드는 최초 병렬 실행에서 각각 10분 제한으로 `BUILD INTERRUPTED`됐고, 동일 DerivedData 순차 재시도에서 모두 `BUILD SUCCEEDED`를 확인했다.
- 실제 기기/시뮬레이터의 Bootpay 본인인증 완료 수동 확인은 이번 환경에서 수행하지 못해 상태 전이 self-check와 앱 빌드로 대체했다.
- `SodaLive` 빌드의 범위 밖 V2 파일 duplicate Sources warning은 기존 상태로 남아 있으며 이번 Task에서 변경하지 않았다.
**현재 결론:** `REV-P5-001`, `REV-P5-002`, `REV-P5-003` 수정 완료
### 5차 독립 수정 검증 — 2026-07-31
- `LiveNowAllView.handleLiveNowItemTap``CreatorChannelView.handleTalkTap`의 인증 후 보류 action이 최종 route가 아닌 동일 진입 함수를 재호출하는 것을 확인했다.
- 두 경로의 인증·민감 콘텐츠 설정 조합과 인증 미완료 상태를 독립 self-check로 다시 실행해 `task-5.4-independent-self-check=passed`를 확인했다.
- `auth == false``isAdultContentVisible`을 함께 사용하는 전체 Swift 화면을 재검색했다. 현재 `ContentView`에서 도달 가능한 `MainView`, `LiveNowAllView`, `CreatorChannelView`는 모두 인증 후 설정 guard를 재진입한다.
- `git diff --check`, `git diff --cached --check`, `plutil -lint`를 통과했다.
- `SodaLive-dev``SodaLive` Debug `generic/platform=iOS` 빌드는 모두 종료 코드 0으로 통과했다.
- 수정 범위와 현재 활성 sibling 경로에서 추가 확정 발견 사항 없음.
**현재 결론:** `REV-P5-003` 수정 및 검증 완료

View File

@@ -0,0 +1,82 @@
# Phase 6 홈 탭 shell과 루트 연결 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 6 / Task 6.1~6.3 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 8.2~8.4, `plan-task.md` Phase 6 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- `MainHomeView`가 title/tab shell만 소유하고 추천/랭킹/팔로잉 루트를 조합하는지 확인한다.
- `.home` 연결과 기존 하단 탭바 유지 여부를 포함한다.
## 3. 판정 기준
- 탭별 책임 분리, 추천 API 호출 비소유, `MainTabBarView` 비중복을 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeView.swift:3-111`, `MainView.swift:37-58`, `:175-197`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `find SodaLive/Sources/V2/Main/Home -maxdepth 3 -type f` | 성공 | 세 탭 전용 루트 확인 |
| `rg "MainHomeRecommendationView\|MainHomeRankingView\|MainHomeFollowingView\|fetchRecommendations\|MainTabBarView" ...` | 성공 | shell 책임 분리와 탭바 단일 소유 확인 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. AI 캐릭터 callback의 최종 목적지 오류는 Phase 5와 12가 소유한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | 세 루트와 MainView 연결 확인 |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P6-001` | Medium | 확정 | 홈 상단 탭 제목이 한글 문자열로 고정되어 앱 언어를 따르지 않음 | `Task 6.4` / `P6-R1` |
#### REV-P6-001 — 홈 상단 탭 제목 현지화 누락
- **관찰 내용:** `MainHomeTab.title`이 추천/랭킹/팔로잉을 각각 한글 문자열 리터럴로 반환한다. 같은 의미의 `I18n.MainContentRecommendation.tabTitle`, `I18n.MainContentRanking.tabTitle`, `I18n.HomeFollowing.tabTitle`이 이미 ko/en/ja 값을 제공하지만 사용하지 않는다.
- **영향:** 앱 언어가 영어 또는 일본어여도 홈 상단 세 탭은 한글로 표시된다.
- **근거:** `MainHomeView.swift:101-108`, `I18n.swift`의 기존 세 탭 `tabTitle`.
- **재현:** 앱 언어를 en 또는 ja로 설정하고 홈 화면을 열면 세 탭 제목이 `추천`, `랭킹`, `팔로잉`으로 유지된다.
- **권장 조치:** 새 문자열을 만들지 말고 기존 I18n 키를 직접 재사용한다.
- **계획 전환:** `plan-task.md` Phase 6에 `Task 6.4` / `P6-R1`을 추가했다.
#### REV-P6-001 수정 기록 — 2026-07-31
- `MainHomeTab.title`의 한글 문자열 리터럴을 기존 `I18n.MainContentRecommendation.tabTitle`, `I18n.MainContentRanking.tabTitle`, `I18n.HomeFollowing.tabTitle`로 교체했다.
- `rg -n 'MainContentRecommendation\.tabTitle|MainContentRanking\.tabTitle|HomeFollowing\.tabTitle' SodaLive/Sources/V2/Main/Home/MainHomeView.swift`로 연결을 확인했다.
- `git diff --check``plutil -lint SodaLive.xcodeproj/project.pbxproj`를 통과했다.
- Oracle 리뷰어 게이트에서 차단/중요 findings 없음 판정을 받았다.
- 실제 ko/en/ja 화면 수동 확인은 잔여 확인 사항으로 남긴다.
**현재 결론:** `REV-P6-001` 수정 완료

View File

@@ -0,0 +1,79 @@
# Phase 7 프로젝트 포함과 중간 빌드 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 7 / Task 7.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `plan-task.md` Phase 7, 빌드/검증 가이드 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- Phase 4~6 데이터·라우팅·탭 루트 파일의 Xcode target 포함과 현재 빌드 가능성을 확인한다.
## 3. 판정 기준
- project plist 유효성, app target 실제 Swift compile과 최종 build 성공을 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 프로젝트: `SodaLive.xcodeproj/project.pbxproj`
- 환경: macOS 26.0, Xcode 26.0, Any iOS Device / Debug
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `plutil -lint SodaLive.xcodeproj/project.pbxproj` | 성공 | `OK` |
| sandbox 내 `xcodebuild -workspace ... -list` | 환경 실패 | CoreSimulator/캐시 접근 제한으로 workspace 판별 실패 |
| sandbox 밖 `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | app target의 `MainHomeView.swift`, 추천 루트 컴파일 및 `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. 샌드박스 내 실패는 동일 명령의 샌드박스 밖 성공으로 환경 문제임을 확인했다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 프로젝트 포함 확인 | 충족 | 실제 app target compile |
| 후보 판정 | 충족 | 남은 후보 없음 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 기록 | 충족 | 4절 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- Phase 4~6 데이터·루트 파일의 프로젝트 포함과 현재 빌드 기준을 다시 확인했다.
- Phase 7 소유의 추가 확정 발견 사항 없음. 새 Sources 중복은 해당 변경을 만든 Phase 16의 `REV-P16-002`로 전환했다.
### 3차 통합 재점검 — 2026-07-31
- 프로덕션 빌드 경고와 PBX Sources phase를 다시 대조한 결과, Phase 4~6 범위의 `MainHomeRecommendationViewModel.swift`, `MainHomeView.swift`, `MainHomeRecommendationView.swift`, `MainHomeRankingView.swift`, `MainHomeFollowingView.swift``SodaLive` target에 각각 두 번 등록된 사실을 확인했다.
- 같은 패턴이 이후 추천 섹션 파일까지 이어지므로 최종 프로젝트 Gate인 Phase 18의 `REV-P18-001` / `Task 18.3`이 일괄 소유한다.
- 기존 “Phase 16의 신규 중복만 존재” 판정은 불완전했으며, 현재 결론은 `REV-P18-001` 후속 수정 필요다.
### 4차 최종 재점검 — 2026-07-31
- `Task 18.3` 이후 현재 PBX Sources phase를 다시 센 결과 Phase 4~6 범위 파일도 여전히 `SodaLive=2`, `SodaLive-dev=1`이다.
- 격리 DerivedData를 사용한 프로덕션 빌드에서 해당 파일들의 duplicate build warning을 재현했고 빌드는 종료 코드 0으로 완료됐다.
- 잔여 중복은 Phase 18 `REV-P18-002` / `Task 18.4`로 전환했으며, Phase 7 자체의 신규 production 코드 결함은 없다.
### 5차 최종 재점검 — 2026-07-31
- `Task 18.4` 반영 후 `xcodebuild -workspace SodaLive.xcworkspace -list`가 종료 코드 0으로 완료되고 `SodaLive`, `SodaLive-dev` scheme을 확인했다.
- clean DerivedData의 `SodaLive-dev` Debug 전체 빌드가 종료 코드 0으로 완료됐다.
- Phase 4~6 및 홈 추천 범위 대상의 duplicate build warning은 재발하지 않아 Phase 7 추가 확정 발견 사항 없음 판정을 유지한다.

View File

@@ -0,0 +1,61 @@
# Phase 8 현재 라이브 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 8 / Task 8.1 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.1·7.3, `plan-task.md` Phase 8 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 라이브 응답 필드 표시, 빈 상태 숨김과 기존 라이브 입장 흐름 연결을 확인한다.
- 성인 여부 계약 자체는 PRD Open Questions로 제외한다.
## 3. 판정 기준
- `roomId` 전달, `LiveViewModel.enterLiveRoom`, 결제/비밀번호 dialog 재사용 및 빈 배열 미표시를 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `MainHomeLiveSection.swift:3-78`, `MainView.swift:508-523`, `:84-104`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "MainHomeLiveSection\|roomId\|enterLiveRoom\|LivePaymentDialog\|LiveRoomPasswordDialog" ...` | 성공 | 필드와 기존 입장 흐름 연결 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. `HomeLiveItem.isAdult` 제공 여부는 PRD에 담당 주체 확인이 필요한 외부 조건으로 남아 있다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 범위 확인 | 충족 | item→입장 flow 추적 |
| 후보 판정 | 충족 | 코드 결함 후보 없음 |
| 보류 조건 기록 | 충족 | 성인 여부 계약은 PRD Open Questions |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 현재 라이브 섹션의 빈 상태, 프로필 표시와 room callback 연결을 다시 확인했다.
- 추가 확정 발견 사항 없음.

View File

@@ -0,0 +1,122 @@
# Phase 9 배너 섹션 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 9 / Task 9.1~9.4 |
| 기준 commit 또는 working tree | `3ae2dc19d67d0a88bc5043f970e4cfabdfb28b60` + 현재 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | PRD 7.1·8.1, `plan-task.md` Phase 9 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 1:1 카드, 폭/inset/간격/radius, 무한 스크롤, 인디케이터, 응답 매핑과 이동 우선순위를 확인한다.
- 이번 리뷰에서 Figma 실화면 캡처 재비교는 제외하고 기존 검증 기록과 코드 수치를 대조한다.
## 3. 판정 기준
- 확정 수치와 navigation 우선순위, 빈 데이터 숨김, 금지 URL 부재를 기준으로 판정한다.
## 4. 검토한 근거와 실행한 검증
- 코드: `BannerCarousel.swift:13-243`, `MainHomeBannerSection.swift`, `MainView.swift:606-627`
- 환경: macOS 26.0, Xcode 26.0, Swift 6.2
| 명령 | 결과 | 핵심 증거 |
|---|---|---|
| `rg "SodaSpacing.s8\|SodaSpacing.s14\|GeometryReader\|currentPage\|visibleItems" BannerCarousel.swift` | 성공 | 간격/radius/인디케이터/순환 구현 확인 |
| `rg "eventItem\|creatorId\|seriesId\|link" MainHomeBannerSection.swift MainView.swift` | 성공 | 첫 non-null 이동 우선순위 확인 |
| 금지 URL 검색 | 성공 | 검색 결과 없음 |
| `xcodebuild ... -derivedDataPath "/private/tmp/SodaLiveDerivedDataReview20260730" build` | 성공 | `BUILD SUCCEEDED` |
## 5. 발견 사항 요약
**확정 발견 사항 없음.**
## 6. 발견 사항 상세
확정 발견 사항 없음. 최신 실기기 시각 회귀는 실행하지 않았으므로 기존 2026-06-29 기록을 근거로 유지한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 항목 | 결과 | 근거 |
|---|---|---|
| 코드 범위 확인 | 충족 | carousel과 route 추적 |
| 후보 판정 | 충족 | 남은 코드 후보 없음 |
| 수동 시각 검증 | 기존 증거 사용 | 이번 리뷰에서 재실행 안 함 |
| plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
**최종 결론:** 확정 발견 사항 없음
## 9. 수정 후 검증 기록
### 2차 통합 재점검 — 2026-07-31
- 배너 응답 매핑, 캐러셀 배치·indicator와 상세 이동 우선순위를 다시 확인했다.
- 추가 확정 발견 사항 없음.
### 3차 최종 재점검 — 2026-07-31
| ID | 심각도 | 상태 | 제목 | 소유 Task |
|---|---|---|---|---|
| `REV-P9-001` | Medium | 확정 | 복제 경계 전환 중 연속 스와이프가 carousel index를 유효 범위 밖으로 보낼 수 있음 | `Task 9.5` / `P9-R1` |
#### REV-P9-001 — 무한 캐러셀 경계 연속 스와이프 복구 누락
- **관찰 내용:** 마지막 실데이터 index `items.count`에서 왼쪽 스와이프하면 `items.count + 1`이 되고 0.35초 뒤 `1`로 reset된다. 그러나 `isSwiping`은 다음 main queue에서 바로 해제되며 gesture도 비활성화되지 않아 reset 전에 같은 방향 스와이프가 끝나면 `items.count + 2`가 된다. `wrapIfNeeded()``0``items.count + 1`만 처리하므로 이 상태를 복구하지 못한다.
- **영향:** HStack의 마지막 복제 item보다 바깥 offset과 잘못된 페이지 번호가 유지되어 배너가 빈 상태로 남을 수 있다. 빠른 경계 연속 입력에서만 발생하므로 Medium으로 판정한다.
- **근거:** `BannerCarousel.swift:140-158`, `:179-198`; `visibleItems`의 유효 index는 `0...items.count + 1`이다.
- **재현 또는 검증:** 마지막 실데이터에서 reset 지연 전에 왼쪽 스와이프 종료를 두 번 적용하면 `displayIndex == items.count + 2`가 되며, 첫 실데이터의 오른쪽 연속 스와이프는 `-1`이 된다. 두 값 모두 현재 wrap 조건에 포함되지 않는다.
- **권장 조치:** 전환 reset이 끝날 때까지 추가 gesture·timer index 변경을 막거나, 모든 범위 밖 index를 한 번의 reset 상태로 정규화하는 최소 guard를 추가한다. 크기·간격·timer·callback은 유지한다.
- **계획 전환:** `plan-task.md` Phase 9에 `Task 9.5` / `P9-R1`을 추가했다.
**현재 결론:** `REV-P9-001` 수정 goal 필요
#### REV-P9-001 수정 기록 — 2026-07-31
- `BannerCarousel.wrapIfNeeded()`에서 `displayIndex``0...items.count + 1` 복제 범위로 정규화하도록 보정했다.
- 배너 크기, 간격, 페이지 형식, 5초 자동 스크롤, 탭 callback은 변경하지 않았다.
- Swift 상태 전이 self-check로 기존 조건의 `items.count + 2``-1` 실패를 재현하고, 보정 후 경계 index가 기존 reset 분기로 복구되는 것을 확인했다.
- `rg -n "displayIndex = min\(max\(displayIndex, 0\), items.count \+ 1\)|wrapIfNeeded|currentPage" "SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift"`, `git diff --check`, `plutil -lint`로 검증했다.
- `SodaLive-dev` Debug 빌드는 같은 DerivedData로 2회 실행했지만 Pods/SPM 및 app resource 처리 중 10분 도구 제한으로 `BUILD INTERRUPTED`되어 성공 증거를 확보하지 못했다.
- 실제 기기/시뮬레이터의 빠른 경계 연속 스와이프 시각 확인은 이번 환경에서 수행하지 못해 정적 self-check와 빌드 결과로 대체했다.
**현재 결론:** 코드 수정 완료, 빌드·수동 시각 검증 차단
### 4차 최종 재점검 — 2026-07-31
- **상태:** `REV-P9-001` 확정 / 수정 미완료
- **관찰 내용:** `displayIndex``0...items.count + 1`로 정규화해 같은 방향 연속 입력의 범위 이탈은 막았지만, 경계에서 예약한 `asyncAfter` reset은 실행 시 현재 index를 확인하지 않는다. 마지막 복제 index에서 `1`로 reset을 예약한 뒤 사용자가 반대 방향으로 실제 마지막 index에 돌아와도 이전 reset이 실행되어 현재 index를 `1`로 덮어쓴다. 첫 복제 경계도 대칭으로 같은 문제가 있다.
- **영향:** 빈 배너 지속 문제는 완화됐지만 빠른 양방향 경계 입력에서 사용자가 이동한 페이지와 다른 첫/마지막 페이지로 갑자기 이동한다. `items` 변경 전에 예약된 reset도 새 상태를 덮어쓸 수 있어 Task 9.5의 “안전한 연속 스와이프” 완료 조건을 충족하지 못한다.
- **근거:** `BannerCarousel.swift:179-200`의 두 지연 closure가 조건 없이 `displayIndex`를 대입한다.
- **재현:** 항목 3개 기준 마지막 실제 index `3`에서 왼쪽 입력으로 `4`와 reset을 예약하고, reset 전에 오른쪽 입력으로 `3`에 복귀한다. 상태 전이 self-check 결과는 `beforeDelayedReset=3`, 예약 closure 실행 뒤 `afterDelayedReset=1, expected=3`이다.
- **권장 조치:** 지연 reset 실행 시 `displayIndex`가 예약 당시의 같은 복제 경계에 남아 있는 경우에만 실제 index로 치환한다. 기존 크기·간격·timer·callback과 index 정규화는 유지한다.
- **계획 반영:** 같은 경계 상태를 소유하고 아직 미완료인 `Task 9.5` / `P9-R1`에 반대 방향 입력·데이터 변경 상태 전이와 reset 실행 조건을 추가했다. 중복 회귀 Task는 만들지 않았다.
#### REV-P9-001 최종 수정 기록 — 2026-07-31
- `BannerCarousel.wrapIfNeeded()`에서 지연 reset 예약 시점의 `displayIndex``itemsRevision`을 저장하고, reset 실행 시 두 값이 유지되는 경우에만 실제 index로 치환하도록 수정했다.
- 기존 index 정규화, 배너 크기, 간격, 페이지 형식, 5초 자동 스크롤, 탭 callback은 변경하지 않았다.
- Swift 상태 전이 self-check로 기존 방식의 반대 방향 이동 덮어쓰기 실패를 재현하고, 수정 방식에서 반대 방향 이동과 같은 개수의 `items` 변경은 덮어쓰지 않으며 같은 경계에 남은 경우만 reset되는 것을 확인했다.
- `rg -n "itemsRevision|scheduledIndex|scheduledRevision|wrapIfNeeded|currentPage" "SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift"`, `git diff --check`, `plutil -lint`로 정적 검증했다.
- `SodaLive-dev` Debug 빌드는 iPhoneOS에서 10분 도구 제한으로 반복 `BUILD INTERRUPTED`, Simulator에서 SPM `Promises` 컴파일 단계 실패로 성공 증거를 확보하지 못했다.
- 실제 기기/시뮬레이터의 빠른 경계 연속 스와이프 시각 확인은 이번 환경에서 수행하지 못해 상태 전이 self-check와 정적 검증으로 대체했다.
**현재 결론:** `REV-P9-001` 수정 완료
### 5차 수정 검증 — 2026-07-31
- 마지막·첫 복제 경계의 정상 reset, 반대 방향 이동, 같은 개수의 `items` 교체, 반복 같은 방향 입력을 포함한 상태 전이 self-check가 `banner-reset-self-check=passed`로 통과했다.
- 지연 closure는 `displayIndex == scheduledIndex`, `itemsRevision == scheduledRevision`인 경우에만 실제 index를 대입한다.
- `git diff --check`, `git diff --cached --check`, `plutil -lint`를 통과했다.
- `SodaLive-dev``SodaLive` Debug `generic/platform=iOS` 빌드는 모두 종료 코드 0으로 통과했다.
- 수정 범위에서 추가 확정 발견 사항 없음.
**현재 결론:** `REV-P9-001` 수정 및 검증 완료

View File

@@ -425,7 +425,7 @@
- 작업 내용: - 작업 내용:
- `schedules.isEmpty`이면 섹션을 표시하지 않는다. - `schedules.isEmpty`이면 섹션을 표시하지 않는다.
- 각 item의 `scheduledAtUtc`, `title`, `type`, `targetId`를 표시한다. - 각 item의 `scheduledAtUtc`, `title`, `type`, `targetId`를 표시한다.
- tap action은 closure로 분리하고, `.live`, `.liveReplay`, `.audio`, `.community` 분기는 기존 `MainView.handleFollowingScheduleTap`과 같은 기준을 따른다. - tap action은 closure로 분리하고, `.live`는 라이브 상세, `.liveReplay` `.audio`는 동일한 오디오 콘텐츠 상세, `.community`는 커뮤니티 화면으로 분기한다.
- `.unknown`은 crash 없이 무시한다. - `.unknown`은 crash 없이 무시한다.
- 검증 기준: - 검증 기준:
- 실행 명령: `rg "struct CreatorChannelScheduleSection|schedules|scheduledAtUtc|CreatorActivityType|targetId|unknown|onTapSchedule" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift` - 실행 명령: `rg "struct CreatorChannelScheduleSection|schedules|scheduledAtUtc|CreatorActivityType|targetId|unknown|onTapSchedule" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`
@@ -633,6 +633,86 @@
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: exit code 0으로 빌드가 완료된다. - 기대 결과: exit code 0으로 빌드가 완료된다.
### Phase 19: 스케줄 라우팅 및 라이브 상세 전역 표시 계층 보정
- [x] **Task 19.1: 스케줄 라우팅과 라이브 상세 표시 흐름 조사**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelScheduleSection.swift`
- 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 확인: `SodaLive/Sources/App/AppState.swift`
- 확인: `SodaLive/Sources/ContentView.swift`
- 비교: `SodaLive/Sources/V2/Main/MainView.swift`
- 비교: `docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md`
- 작업 내용:
- `.live``.liveReplay`가 두 스케줄 handler에서 같은 라이브 분기로 처리되는지 확인한다.
- `.live``targetId`는 room ID, `.liveReplay``.audio``targetId`는 콘텐츠 ID라는 타입 의미를 대조한다.
- `liveDetailSheet` 상태 갱신 지점과 `LiveDetailView` 렌더 위치를 분리해 추적한다.
- 메인 화면에서는 보이고 navigation destination에서는 가려지는 계층 차이를 기존 전역 라이브룸 표시 방식과 비교한다.
- 검증 기준:
- 실행 명령: `rg -n -A14 "func handle(Following)?ScheduleTap" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: `.liveReplay``.live`와 묶여 잘못된 라이브 경로로 전달되는 라우팅 문제를 확인한다.
- 실행 명령: `rg -n "liveDetailSheet|LiveDetailView\\(|\\.overlay" SodaLive/Sources/App/AppState.swift SodaLive/Sources/ContentView.swift`
- 기대 결과: `.live` 상태 갱신은 정상이며 `LiveDetailView` 렌더 위치가 `NavigationStack` 루트 콘텐츠 내부인 표시 계층 문제를 확인한다.
- [x] **Task 19.2: 스케줄 type별 라우팅 보정**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- `CreatorChannelView.handleScheduleTap`에서 `.live``showLiveDetail(targetId)`로 전달한다.
- `CreatorChannelView.handleScheduleTap`에서 `.liveReplay``.audio`는 동일한 오디오 콘텐츠 상세 화면을 여는 `showContentDetail(targetId)`로 전달한다.
- `MainView.handleFollowingScheduleTap`에서 `.live`는 기존 `handleRecommendationLiveTap(roomId:)` 흐름을 유지한다.
- `MainView.handleFollowingScheduleTap`에서 `.liveReplay``.audio`는 동일한 오디오 콘텐츠 상세 화면을 여는 `handleRecommendationContentTap(contentId:)`로 전달한다.
- 신규 route나 helper를 추가하지 않고 기존 상세 진입 함수를 재사용한다.
- 검증 기준:
- 실행 명령: `rg -n -A14 "func handle(Following)?ScheduleTap" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 두 handler 모두 `.live`는 라이브 경로, `.liveReplay``.audio`는 동일한 오디오 콘텐츠 상세 경로로 분리되어 있다.
- 실행 명령: `rg -n "case \\.live, \\.liveReplay" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 검색 결과가 없다.
- [x] **Task 19.3: 라이브 상세 렌더 호스트를 앱 전역 overlay로 이동**
- 대상 파일:
- 수정: `SodaLive/Sources/ContentView.swift`
- 작업 내용:
- `NavigationStack` 내부 루트 `ZStack`의 기존 `liveDetailSheet` 렌더 분기를 복제하지 않고 `NavigationStack.overlay``ZStack` 첫 번째 계층으로 이동한다.
- 외부 overlay에서 동일 상태를 처리한 뒤 불필요해진 기존 위치의 렌더 분기는 제거한다.
- `roomId`, 네 개의 action callback, `onClickClose``hideLiveDetailSheet()` 호출과 animation은 변경하지 않는다.
- 전역 라이브룸과 외부 이동 확인 다이얼로그는 라이브 상세 뒤에 배치해 기존 우선순위를 유지한다.
- 검증 기준:
- 실행 명령: `rg -n "NavigationStack|if let liveDetailSheet|LiveDetailView\\(|LiveRoomViewV2\\(|leaveLiveNavigationDialog|\\.overlay" SodaLive/Sources/ContentView.swift`
- 기대 결과: `if let liveDetailSheet` 상태 기반 렌더 분기는 `NavigationStack` 바깥 overlay에 하나만 존재하고 기존 루트 `ZStack`에는 남지 않으며, 표시 순서가 라이브 상세 → 라이브룸 → 외부 이동 확인 다이얼로그다.
- [x] **Task 19.4: 정적 검사와 Debug 빌드 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/ContentView.swift`
- 확인: `SodaLive/Sources/App/AppState.swift`
- 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- 전역 `LiveDetailView` 호스트 수와 기존 `.liveDetail` 호출부를 검색한다.
- `.liveReplay`가 라이브 분기로 남아 있지 않은지 검색한다.
- whitespace 오류를 확인하고 공식 `SodaLive-dev` Debug 빌드를 실행한다.
- 검증 기준:
- 실행 명령: `git diff --check`
- 기대 결과: 출력 없이 exit code 0이다.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `** BUILD SUCCEEDED **`.
- [ ] **Task 19.5: 진입점별 수동 동작 검증**
- 대상 화면:
- 크리에이터 채널 홈의 `.live` 예약 스케줄
- 크리에이터 채널 홈의 `.liveReplay` 스케줄
- 메인 팔로잉 화면의 `.liveReplay` 스케줄
- 크리에이터 채널 라이브 탭
- 메인 화면의 기존 라이브 상세 진입점
- 작업 내용:
- 크리에이터 채널의 `.live` 항목 tap 직후 현재 화면 위에 라이브 상세가 보이는지 확인한다.
- 크리에이터 채널과 메인의 `.liveReplay` 항목 tap 시 `.audio`와 동일한 오디오 콘텐츠 상세로 이동하고 라이브 상세나 라이브룸이 표시되지 않는지 확인한다.
- dim 영역 또는 닫기 버튼으로 상세를 닫았을 때 진입 전 화면과 navigation path가 유지되는지 확인한다.
- 라이브 상세에서 라이브룸에 입장할 수 있는 기존 경로는 라이브룸이 상세 위에 표시되는지 확인한다.
- 검증 기준:
- 기대 결과: `.live`만 라이브 상세를 표시하고 `.liveReplay``.audio`는 동일한 오디오 콘텐츠 상세로 이동하며, 닫기와 라이브룸 표시 우선순위에 회귀가 없다.
## 검증 기록 ## 검증 기록
- 2026-07-01: `docs/20260701_크리에이터_채널_홈/prd.md`를 기준으로 API 선구현, Figma 섹션별 phase, placeholder tab, API 실패 placeholder, DM/화보 제외 범위를 계획에 반영했다. - 2026-07-01: `docs/20260701_크리에이터_채널_홈/prd.md`를 기준으로 API 선구현, Figma 섹션별 phase, placeholder tab, API 실패 placeholder, DM/화보 제외 범위를 계획에 반영했다.
@@ -658,3 +738,115 @@
- 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 Task 3.2/3.4/3.5와 최종 검증 기준을 갱신했다. - 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 Task 3.2/3.4/3.5와 최종 검증 기준을 갱신했다.
- 2026-07-03: 사용자 요청에 따라 Task 17.2의 대화하기 액션을 캐릭터 상세 이동이 아니라 기존 Character Detail 하단 `대화하기`와 동일한 `TalkApi.createChatRoom` 호출 후 `.chatRoom(id:)` 직접 진입으로 변경했다. `CreatorChannelView`의 로그인/한국 본인인증/content settings guard는 유지했고, 본인인증 완료 후 `pendingAction`도 같은 `startChat(characterId:)` helper를 사용하도록 연결했다. `rg -n "characterDetail\\(characterId: characterId\\)|startChat\\(characterId:|createChatRoom\\(characterId:|chatRoom\\(id:" "SodaLive/Sources/V2/CreatorChannel"`와 ast-grep `AppState.shared.setAppStep(step: .characterDetail(characterId: $ID))` 검색으로 CreatorChannel 대화하기 경로에 캐릭터 상세 이동이 남지 않았음을 확인했다. 테스트는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행 결과 `Scheme SodaLive-dev is not currently configured for the test action.`으로 RED/GREEN 자동화가 불가했다. 빌드는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``BUILD SUCCEEDED`로 완료됐다. - 2026-07-03: 사용자 요청에 따라 Task 17.2의 대화하기 액션을 캐릭터 상세 이동이 아니라 기존 Character Detail 하단 `대화하기`와 동일한 `TalkApi.createChatRoom` 호출 후 `.chatRoom(id:)` 직접 진입으로 변경했다. `CreatorChannelView`의 로그인/한국 본인인증/content settings guard는 유지했고, 본인인증 완료 후 `pendingAction`도 같은 `startChat(characterId:)` helper를 사용하도록 연결했다. `rg -n "characterDetail\\(characterId: characterId\\)|startChat\\(characterId:|createChatRoom\\(characterId:|chatRoom\\(id:" "SodaLive/Sources/V2/CreatorChannel"`와 ast-grep `AppState.shared.setAppStep(step: .characterDetail(characterId: $ID))` 검색으로 CreatorChannel 대화하기 경로에 캐릭터 상세 이동이 남지 않았음을 확인했다. 테스트는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행 결과 `Scheme SodaLive-dev is not currently configured for the test action.`으로 RED/GREEN 자동화가 불가했다. 빌드는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``BUILD SUCCEEDED`로 완료됐다.
- 2026-07-03: Phase 18 최종 조립과 검증을 완료했다. 최신 코드 기준에 맞춰 대화하기 액션은 `CreatorChannelHeaderSection` 내부에 유지하고, `CreatorChannelHomeView` 홈 콘텐츠 순서는 현재 라이브, 최신 오디오, 채널 후원, 공지, 스케줄, 오디오, 시리즈, 커뮤니티, 팬Talk, 소개, 활동, SNS 순서로 확인했다. title bar 알림 설정 asset은 최신 코드 기준인 `ic_bar_bell_colored`를 유지했다. `rg -n "ZStack|overlay|Color\\.clear|backgroundProgress|CreatorChannelTitleBar|CreatorChannelHeaderSection|CreatorChannelTabBar|CreatorChannelHomeView|CreatorChannelTalkActionSection|CreatorChannelCurrentLiveSection|CreatorChannelLatestAudioSection|CreatorChannelDonationSection|CreatorChannelNoticeSection|CreatorChannelScheduleSection|CreatorChannelAudioSection|CreatorChannelSeriesSection|CreatorChannelCommunitySection|CreatorChannelFanTalkSection|CreatorChannelIntroduceSection|CreatorChannelActivitySection|CreatorChannelSnsSection" ...``rg -n "화보|photo|ic_new_dm|DM|ic_new_talk|ic_sns_|ic_new_bar_back|ic_new_follow|ic_new_following|ic_new_more|ic_bar_bell|ic_bar_bell_colored|creatorFollow|follow: true, notify: true|follow: false, notify: false|follow: true, notify: false|onTapNotify|onTapUnnotify|ZStack|overlay|Color\\.clear|isApiFailedPlaceholderVisible|countryCode|auth == false" ...`로 성공 기준을 회귀 점검했고, 신규 CreatorChannel Swift 파일은 `project.pbxproj`의 Sources phase에 포함되어 있음을 확인했다. `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``BUILD SUCCEEDED`로 완료됐다. - 2026-07-03: Phase 18 최종 조립과 검증을 완료했다. 최신 코드 기준에 맞춰 대화하기 액션은 `CreatorChannelHeaderSection` 내부에 유지하고, `CreatorChannelHomeView` 홈 콘텐츠 순서는 현재 라이브, 최신 오디오, 채널 후원, 공지, 스케줄, 오디오, 시리즈, 커뮤니티, 팬Talk, 소개, 활동, SNS 순서로 확인했다. title bar 알림 설정 asset은 최신 코드 기준인 `ic_bar_bell_colored`를 유지했다. `rg -n "ZStack|overlay|Color\\.clear|backgroundProgress|CreatorChannelTitleBar|CreatorChannelHeaderSection|CreatorChannelTabBar|CreatorChannelHomeView|CreatorChannelTalkActionSection|CreatorChannelCurrentLiveSection|CreatorChannelLatestAudioSection|CreatorChannelDonationSection|CreatorChannelNoticeSection|CreatorChannelScheduleSection|CreatorChannelAudioSection|CreatorChannelSeriesSection|CreatorChannelCommunitySection|CreatorChannelFanTalkSection|CreatorChannelIntroduceSection|CreatorChannelActivitySection|CreatorChannelSnsSection" ...``rg -n "화보|photo|ic_new_dm|DM|ic_new_talk|ic_sns_|ic_new_bar_back|ic_new_follow|ic_new_following|ic_new_more|ic_bar_bell|ic_bar_bell_colored|creatorFollow|follow: true, notify: true|follow: false, notify: false|follow: true, notify: false|onTapNotify|onTapUnnotify|ZStack|overlay|Color\\.clear|isApiFailedPlaceholderVisible|countryCode|auth == false" ...`로 성공 기준을 회귀 점검했고, 신규 CreatorChannel Swift 파일은 `project.pbxproj`의 Sources phase에 포함되어 있음을 확인했다. `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``BUILD SUCCEEDED`로 완료됐다.
- 2026-07-20: 크리에이터 채널 홈 스케줄 item에서 `CreatorChannelScheduleSection``CreatorChannelView.handleScheduleTap``showLiveDetail``AppState.setAppStep(.liveDetail)`까지 이벤트와 상태 갱신이 정상임을 확인했다. `liveDetailSheet`의 렌더 호스트가 `NavigationStack` 루트 `ZStack` 안에 있어 destination인 `CreatorChannelView` 뒤에 가려지는 것이 근본 원인이다. 기존 `AppState` 상태나 채널 callback을 복제하지 않고 `SodaLive/Sources/ContentView.swift`에서 해당 렌더 분기만 기존 외부 overlay로 옮기는 최소 수정안을 계획했다. 구현과 빌드·수동 검증은 아직 수행하지 않았다.
- 2026-07-20: 사용자 확인에 따라 Task 19.3에 기존 `liveDetailSheet` 렌더 분기를 복제하지 않고 외부 overlay로 이동하며, 이동 후 기존 루트 `ZStack`의 분기를 제거하고 상태 기반 호스트가 하나만 남는지 검증하도록 명시했다.
- 2026-07-20: 사용자 확인에 따라 `LiveDetailView` 표시 대상을 `.live`로 한정하고, `.liveReplay`는 다시 듣기 콘텐츠의 `contentId``.audio`와 동일한 오디오 콘텐츠 상세에 진입하도록 요구사항을 수정했다. 현재 `CreatorChannelView.handleScheduleTap``MainView.handleFollowingScheduleTap`이 모두 `.liveReplay``.live`와 묶고 있어 두 파일의 분기를 수정하는 Task 19.2를 추가하고 기존 후속 task를 19.3~19.5로 재번호화했다.
- 2026-07-20: Task 19.2~19.4 구현을 완료했다. `CreatorChannelView.handleScheduleTap``MainView.handleFollowingScheduleTap`에서 `.live`만 라이브 경로로 두고 `.audio, .liveReplay`를 콘텐츠 상세 경로로 분리했다. `ContentView``liveDetailSheet` 상태 기반 `LiveDetailView` 렌더 호스트는 기존 루트 `ZStack`에서 제거하고 `NavigationStack.overlay`의 첫 번째 계층으로 이동해 라이브 상세 → 전역 라이브룸 → 외부 이동 확인 다이얼로그 순서를 맞췄다. `rg -n -A14 "func handle(Following)?ScheduleTap|if let liveDetailSheet|LiveDetailView\\(|LiveRoomViewV2\\(|leaveLiveNavigationDialog|\\.overlay|case \\.live, \\.liveReplay" ...``.liveReplay` 묶음 제거와 overlay 위치를 확인했고, `git diff --check`는 출력 없이 통과했다. `sourcekit-lsp`는 active 상태였으나 변경 파일별 `lsp_diagnostics`가 3초 내 fresh diagnostics를 반환하지 않았다. 공식 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`로 완료됐다. Task 19.5의 실제 진입점별 기기/시뮬레이터 수동 검증은 아직 수행하지 않았다.
### Phase 20: 종료된 라이브 상세 DIM 잔류 보정
- [x] **Task 20.1: 종료된 라이브 상세 실패 흐름 문서화와 원인 확인**
- 대상 파일:
- 확인: `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift`
- 확인: `SodaLive/Sources/Live/Room/Detail/LiveDetailViewModel.swift`
- 확인: `SodaLive/Sources/ContentView.swift`
- 작업 내용:
- `LiveDetailViewModel.getDetail` 실패 시 로컬 `isShowPopup`만 켜지고 `LiveDetailView` 표시 상태가 해제되지 않는 흐름을 확인한다.
- `LiveDetailView`가 닫히지 않으면 `Color.black.opacity(0.7)` DIM 배경이 계속 남는 것을 수정 대상으로 기록한다.
- 검증 기준:
- 실행 명령: `rg -n "getDetail\(roomId:|isShowPopup = true|hideView\(|onClickClose|liveDetailSheet" SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift SodaLive/Sources/Live/Room/Detail/LiveDetailViewModel.swift SodaLive/Sources/ContentView.swift`
- 기대 결과: 상세 조회 실패와 overlay 닫기 경로가 확인된다.
- [x] **Task 20.2: 종료된 라이브 상세 실패 시 DIM 해제**
- 대상 파일:
- 수정: `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift`
- 작업 내용:
- `LiveDetailView`가 room 데이터 없이 상세 조회 실패 토스트를 표시해야 하는 경우 전역 토스트로 메시지를 전달한다.
- 같은 실패 흐름에서 기존 `onClickClose`/`AppState.back()` 기반 close 경로를 호출해 `LiveDetailView`와 DIM overlay를 닫는다.
- 정상적으로 room 데이터를 받은 라이브 상세 표시와 기존 닫기 버튼 동작은 변경하지 않는다.
- 검증 기준:
- 실행 명령: `rg -n "handleDetailLoadFailure|AppState.shared.errorMessage|AppState.shared.isShowErrorPopup|hideView\(" SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift`
- 기대 결과: 상세 조회 실패 시 전역 토스트와 close 경로 호출이 확인된다.
- [x] **Task 20.3: 정적 검사와 Debug 빌드 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift`
- 확인: `SodaLive/Sources/ContentView.swift`
- 작업 내용:
- 종료 라이브 실패 흐름과 기존 overlay 순서를 정적 검색으로 확인한다.
- `git diff --check`와 공식 Debug 빌드를 실행한다.
- 검증 기준:
- 실행 명령: `git diff --check`
- 기대 결과: 출력 없이 exit code 0이다.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `** BUILD SUCCEEDED **`.
- 2026-07-20: Phase 20 구현을 완료했다. `LiveDetailViewModel.getDetail` 실패 시 `viewModel.isShowPopup = true`만 설정되고 `LiveDetailView` 자체 표시 상태는 해제되지 않아 `Color.black.opacity(0.7)` DIM 배경이 남는 원인을 확인했다. `LiveDetailView`에서 `viewModel.isShowPopup` 변경을 감지하되 `viewModel.room == nil`인 상세 조회 실패에만 `handleDetailLoadFailure()`를 호출하도록 했고, 해당 helper는 기존 메시지를 `AppState.shared.errorMessage`/`isShowErrorPopup`으로 넘긴 뒤 `hideView()`로 기존 `onClickClose` 또는 `AppState.back()` 닫기 경로를 실행한다. `rg -n "handleDetailLoadFailure|AppState.shared.errorMessage|AppState.shared.isShowErrorPopup|viewModel.room == nil|hideView\\(|Phase 20|이미 종료된 라이브 상세 조회 실패" ...``git diff --check`를 통과했다. `lsp_diagnostics``Kingfisher` 모듈 해석 문제로 실패했으나, 공식 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`로 완료됐다. 실제 종료된 라이브 API 응답을 사용하는 기기/시뮬레이터 수동 검증은 수행하지 않았다.
- 2026-07-20: Phase 20 후속 보정을 적용했다. 종료된 라이브 상세 실패 처리에서 전역 토스트 상태를 먼저 켠 뒤 `LiveDetailView`를 닫으면 overlay 해제와 함께 토스트 표시가 누락될 수 있어, 실패 메시지를 지역 변수로 보존하고 `viewModel.isShowPopup = false``hideView()`로 DIM overlay를 먼저 해제한 다음 다음 main runloop에서 `AppState.shared.errorMessage`/`isShowErrorPopup`을 설정하도록 순서를 조정했다.
- 2026-07-20: Phase 20 토스트 계층 보정을 적용했다. `sodaToast`는 적용된 content 위에 overlay를 붙이는 modifier인데, 기존 전역 토스트 host가 `NavigationStack` 내부 루트 `ZStack`에 붙어 있어 navigation destination 및 전역 `LiveDetailView` overlay보다 아래 계층에 남을 수 있었다. `ContentView`의 전역 `.sodaToast(isPresented: $appState.isShowErrorPopup, ...)``NavigationStack.overlay` 뒤의 최상위 modifier로 이동해 DIM overlay 해제 후에도 토스트가 최상위에서 표시되도록 했다.
- 2026-07-20: Phase 20 추가 보정을 적용했다. 전역 `AppState.isShowErrorPopup`이 이미 `true`인 상태에서 같은 값을 다시 설정하면 `SodaToastModifier.onChange(of: isPresented)`가 재실행되지 않을 수 있고, `LiveDetailView` close animation과 같은 runloop에서 토스트를 켜면 표시 타이밍이 겹칠 수 있어 종료 상세 실패 처리에서 `AppState.shared.isShowErrorPopup = false`로 먼저 reset한 뒤 `hideView()`를 호출하고, 0.25초 후 `AppState.shared.errorMessage`/`isShowErrorPopup`을 설정하도록 조정했다. `lsp_diagnostics`는 기존 SourceKit 환경의 `No such module 'Kingfisher'`로 실패했으나, `git diff --check`는 통과했고 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`로 완료됐다.
### Phase 21: 채널 홈 후원 카드 반응형 너비 보정
- [x] **Task 21.1: 후원 카드 너비 계산 보정**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
- 작업 내용:
- 기준 디바이스 너비 `402pt`와 최대 카드 너비 `374pt`를 사용한다.
- 디바이스 너비가 `402pt` 이상이면 기존 `374pt`를 유지한다.
- 디바이스 너비가 `402pt` 미만이면 `374 × deviceWidth / 402` 비율로 카드 너비만 축소한다.
- 카드 높이, 내부 구성, 패딩, 카드 간격과 후원 탭 세로 목록은 변경하지 않는다.
- 검증 기준:
- 실행 명령: `swift -e 'import Foundation; let widths = [320.0, 375.0, 402.0, 430.0]; print(widths.map { 374.0 * min($0 / 402.0, 1.0) })'`
- 기대 결과: `320pt`, `375pt`에서는 비례 축소되고 `402pt`, `430pt`에서는 모두 `374pt`다.
- 실행 명령: `rg -n "baseDeviceWidth|maxCardWidth|cardWidth|frame\\(width:" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
- 기대 결과: 후원 섹션 카드에만 반응형 너비 계산이 적용되어 있다.
- [x] **Task 21.2: 정적 검사와 Debug 빌드 검증**
- 대상 파일:
- 확인: `docs/20260701_크리에이터_채널_홈/prd.md`
- 확인: `docs/20260701_크리에이터_채널_홈/plan-task.md`
- 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
- 작업 내용:
- 문서 요구사항과 구현 계산식이 일치하는지 확인한다.
- whitespace 오류를 확인하고 공식 `SodaLive-dev` Debug 빌드를 실행한다.
- 검증 기준:
- 실행 명령: `git diff --check`
- 기대 결과: 출력 없이 exit code 0이다.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `** BUILD SUCCEEDED **`.
- 2026-07-20: Phase 21 구현을 완료했다. `CreatorChannelDonationSection`의 기존 고정 `374pt` 카드 너비를 `374 × min(deviceWidth / 402, 1)` 계산값으로 교체해 `402pt` 이상에서는 기존 너비를 유지하고 작은 디바이스에서만 비례 축소되도록 했다. `swift -module-cache-path /tmp/sodalive-swift-module-cache -e 'import Foundation; let widths = [320.0, 375.0, 402.0, 430.0]; print(widths.map { 374.0 * min($0 / 402.0, 1.0) })'` 실행 결과는 `[297.71144278606965, 348.8805970149254, 374.0, 374.0]`이었다. `rg`로 계산식 적용 위치를 확인했고 `git diff --check`를 통과했다. `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -destination 'platform=iOS Simulator,id=224FBAAE-B5C7-4A10-9143-3944A3280E48' -derivedDataPath /tmp/sodalive-shell-derived-escalated build``** BUILD SUCCEEDED **`로 완료됐다. 프로젝트에 테스트 번들 타깃이 없어 별도 단위 테스트는 실행하지 않았다.
### Phase 22: 내 채널 팔로워 목록 레거시 연결
- [x] **Task 22.1: 헤더 팔로워 수 탭 라우팅 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift`
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 확인: `SodaLive/Sources/ContentView.swift`
- 확인: `SodaLive/Sources/Explorer/Profile/FollowerList/FollowerListView.swift`
- 작업 내용:
- `CreatorChannelHeaderSection``onTapFollowerCount` closure를 추가한다.
- 헤더의 `I18n.CreatorChannelHome.followerCount(creator.followerCount.comma())` 텍스트에 tap gesture를 연결한다.
- `CreatorChannelView`에서 내 채널일 때만 `AppState.shared.setAppStep(step: .followerList(userId: creator.creatorId))`를 호출한다.
- 새 V2 팔로워 목록 화면, 새 route, 새 API를 만들지 않는다.
- 검증 기준:
- 실행 명령: `rg -n "onTapFollowerCount|followerList\(userId: creator\.creatorId\)|FollowerListView\(userId:" SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/ContentView.swift`
- 기대 결과: 헤더 콜백, 내 채널 레거시 라우팅, 기존 `FollowerListView` destination이 확인된다.
- [x] **Task 22.2: 정적 검사와 Debug 빌드 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift`
- 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 작업 내용:
- 변경 전 RED로 라우팅 계약 검색이 실패함을 확인한다.
- 변경 후 같은 검색이 성공하는지 확인한다.
- whitespace 오류를 확인하고 공식 Debug 빌드를 실행한다.
- 검증 기준:
- 실행 명령: `git diff --check`
- 기대 결과: 출력 없이 exit code 0이다.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `** BUILD SUCCEEDED **`.
- 2026-08-03: Phase 22 구현을 완료했다. RED 확인으로 `rg -n "CreatorChannelHeaderSection\(|onTapFollowerCount|followerList\(userId: creator\.creatorId\)|followerCount\(creator\.followerCount\.comma\(\)\)" "SodaLive/Sources/V2/CreatorChannel"`를 먼저 실행해 헤더 생성과 팔로워 수 표시만 있고 `onTapFollowerCount``.followerList(userId: creator.creatorId)` 연결이 없음을 확인했다. `CreatorChannelHeaderSection`에는 `onTapFollowerCount` closure와 팔로워 수 텍스트 tap gesture만 추가했고, `CreatorChannelView`에서는 내 채널일 때만 `AppState.shared.setAppStep(step: .followerList(userId: creator.creatorId))`를 호출하도록 연결했다. GREEN 확인으로 `rg -n "onTapFollowerCount|followerList\(userId: creator\.creatorId\)|FollowerListView\(userId:" "SodaLive/Sources/V2/CreatorChannel" "SodaLive/Sources/ContentView.swift"`가 헤더 콜백, 내 채널 라우팅, 기존 `FollowerListView` destination을 모두 찾았다. `git diff --check`는 출력 없이 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`로 완료됐다. 프로젝트에 테스트 번들 타깃이 없어 XCTest는 추가/실행하지 못했고, 로그인된 내 채널 계정이 필요한 실제 기기 탭 수동 검증은 수행하지 않았다.

View File

@@ -35,6 +35,11 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다.
- tab-bar는 title bar와 붙는 순간 sticky 상태가 되어야 하고, sticky 이후에는 아래 콘텐츠만 스크롤되어야 한다. - tab-bar는 title bar와 붙는 순간 sticky 상태가 되어야 하고, sticky 이후에는 아래 콘텐츠만 스크롤되어야 한다.
- `isAiChatAvailable` 값에 따라 대화하기 버튼 노출이 달라지지만, 버튼이 없어도 액션 영역 높이는 유지해야 한다. - `isAiChatAvailable` 값에 따라 대화하기 버튼 노출이 달라지지만, 버튼이 없어도 액션 영역 높이는 유지해야 한다.
- 선택된 tab 및 홈 섹션의 `전체보기` 진입 화면은 전체 기능 구현 전에도 placeholder destination이 필요하다. - 선택된 tab 및 홈 섹션의 `전체보기` 진입 화면은 전체 기능 구현 전에도 placeholder destination이 필요하다.
- 후속 버그(2026-07-20): `AppState.liveDetailSheet`는 전역 상태지만 `LiveDetailView``NavigationStack`의 루트 콘텐츠 안에서 렌더링된다. 크리에이터 채널은 navigation destination으로 루트 콘텐츠 위에 표시되므로, 채널 스케줄의 라이브 예약 항목을 눌러도 상세가 현재 화면 위에 나타나지 않고 메인 화면으로 돌아간 뒤에야 보인다.
- 후속 버그(2026-07-20): `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이고 `targetId`도 콘텐츠 ID이지만, 현재 크리에이터 채널과 메인 스케줄 분기에서는 `.live`와 함께 처리되어 라이브 진입 경로로 잘못 전달된다. `.liveReplay`의 상세 화면은 `.audio`와 동일한 오디오 콘텐츠 상세 화면이다.
- 후속 버그(2026-07-20): `LiveDetailView`가 이미 종료된 라이브의 상세 조회 실패 응답을 받으면 `이미 종료된 라이브 입니다.` 토스트는 표시되지만 `LiveDetailView` overlay가 닫히지 않아 DIM 배경이 화면에 남는다.
- 후속 UI 보정(2026-07-20): 채널 홈 후원 섹션의 카드 너비가 `374pt`로 고정되어 있어 디바이스 너비가 `402pt`보다 작은 화면에서는 기준 디자인과 같은 비율을 유지하지 못한다.
- 후속 연결(2026-08-03): 내 채널의 헤더 팔로워 수(`팔로워 n명`)는 표시만 되고 있어, 사용자가 자신을 팔로우하는 유저 목록으로 바로 이동할 수 없다.
## 3. Goals ## 3. Goals
- `creatorId``GET /api/v2/creator-channels/{creatorId}/home`을 호출하고 응답 데이터로 크리에이터 채널 공통 shell과 홈 탭 콘텐츠를 구성한다. - `creatorId``GET /api/v2/creator-channels/{creatorId}/home`을 호출하고 응답 데이터로 크리에이터 채널 공통 shell과 홈 탭 콘텐츠를 구성한다.
@@ -51,6 +56,11 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다.
- 홈 tab 외 tab-bar 항목 및 홈 섹션의 전체보기 destination은 placeholder 형태로 먼저 연결한다. - 홈 tab 외 tab-bar 항목 및 홈 섹션의 전체보기 destination은 placeholder 형태로 먼저 연결한다.
- V2 하위의 재사용 가능한 공용 컴포넌트는 우선 재사용한다. - V2 하위의 재사용 가능한 공용 컴포넌트는 우선 재사용한다.
- 빈 섹션은 section title과 empty state를 표시하지 않고 숨긴다. - 빈 섹션은 section title과 empty state를 표시하지 않고 숨긴다.
- 크리에이터 채널의 라이브 예약 스케줄 항목을 누르면 현재 채널 화면 위에 기존 `LiveDetailView`가 즉시 표시되고, 닫으면 같은 채널 화면으로 돌아온다.
- `.liveReplay` 스케줄 항목을 누르면 `targetId`를 콘텐츠 ID로 사용해 `.audio`와 동일한 기존 오디오 콘텐츠 상세 페이지로 이동한다.
- 이미 종료된 라이브의 `LiveDetailView`를 열면 `이미 종료된 라이브 입니다.` 토스트를 표시하고 `LiveDetailView`/DIM overlay를 닫아 진입 전 화면을 유지한다.
- 채널 홈 후원 섹션 카드 너비는 디바이스 너비가 `402pt` 이상이면 `374pt`를 유지하고, `402pt` 미만이면 디바이스 너비에 비례해 축소한다.
- 내 채널에서 헤더의 팔로워 수를 누르면 신규 V2 화면을 만들지 않고 기존 `AppStep.followerList(userId:)` 레거시 화면으로 이동한다.
## 4. Non-Goals ## 4. Non-Goals
- `화보` 탭과 화보 섹션은 구현하지 않는다. - `화보` 탭과 화보 섹션은 구현하지 않는다.
@@ -58,6 +68,8 @@ Figma 전체 화면 기준 공통 shell은 아래를 따른다.
- 후원 작성, 댓글/좋아요, 커뮤니티 구매, 콘텐츠 구매의 실제 mutation 구현은 이번 PRD의 필수 범위로 보지 않는다. - 후원 작성, 댓글/좋아요, 커뮤니티 구매, 콘텐츠 구매의 실제 mutation 구현은 이번 PRD의 필수 범위로 보지 않는다.
- DM 버튼 및 DM 보내기 진입은 추후 작업으로 미루고 이번 범위에서 구현하지 않는다. - DM 버튼 및 DM 보내기 진입은 추후 작업으로 미루고 이번 범위에서 구현하지 않는다.
- 라이브룸, 오디오 상세, 시리즈 상세, 커뮤니티 상세, 팬Talk 전체, 후원 전체의 실제 상세 기능을 새로 구현하지 않는다. 기존 진입 흐름이 있으면 연결하고, 없으면 placeholder로 둔다. - 라이브룸, 오디오 상세, 시리즈 상세, 커뮤니티 상세, 팬Talk 전체, 후원 전체의 실제 상세 기능을 새로 구현하지 않는다. 기존 진입 흐름이 있으면 연결하고, 없으면 placeholder로 둔다.
- `LiveDetailView`, `AppStep.liveDetail`, `LiveDetailSheetState`의 API나 상세 내부 동작은 변경하지 않는다.
- 크리에이터 채널 전용 라이브 상세 상태나 중복 `LiveDetailView` 호스트를 추가하지 않는다.
- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. - Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. - `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
@@ -239,6 +251,10 @@ Figma 참조:
- `channelDonations`가 비어 있으면 채널 후원 섹션을 숨긴다. - `channelDonations`가 비어 있으면 채널 후원 섹션을 숨긴다.
- 전체보기 action은 기존 `AppStep.channelDonationAll(creatorId:)` 또는 동일한 기존 흐름이 있으면 연결한다. - 전체보기 action은 기존 `AppStep.channelDonationAll(creatorId:)` 또는 동일한 기존 흐름이 있으면 연결한다.
- 기존 흐름 연결이 어렵다면 후원 tab placeholder로 이동한다. - 기존 흐름 연결이 어렵다면 후원 tab placeholder로 이동한다.
- 카드 너비는 기준 디바이스 너비 `402pt`, 최대 카드 너비 `374pt`를 사용한다.
- 디바이스 너비가 `402pt` 이상이면 카드 너비를 `374pt`로 유지한다.
- 디바이스 너비가 `402pt` 미만이면 `374 × deviceWidth / 402`로 카드 너비만 비례 축소한다.
- 카드 높이, 내부 레이아웃, 패딩, 카드 간격과 후원 탭의 세로 목록 너비는 변경하지 않는다.
### 7.10 공지 섹션 ### 7.10 공지 섹션
@@ -254,7 +270,13 @@ Figma 참조:
- `schedules`를 사용해 예정 항목을 표시한다. - `schedules`를 사용해 예정 항목을 표시한다.
- 각 item은 `scheduledAtUtc`, `title`, `type`, `targetId`를 사용한다. - 각 item은 `scheduledAtUtc`, `title`, `type`, `targetId`를 사용한다.
- `type``LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 등 기존 `CreatorActivityType` 처리 범위를 따른다. - `type``LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 등 기존 `CreatorActivityType` 처리 범위를 따른다.
- schedule tap 시 기존 `MainView.handleFollowingScheduleTap(type:targetId:)`와 같은 분기 패턴을 검토해 재사용한다. - `.live``targetId`는 라이브 room ID이며, 크리에이터 채널에서는 기존 `AppState.setAppStep(step: .liveDetail(...))` 흐름으로 전달한다.
- `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이므로 `targetId`를 콘텐츠 ID로 사용해 `.audio`와 동일한 오디오 콘텐츠 상세 화면인 `AppStep.contentDetail(contentId:)`로 이동한다.
- `.audio``targetId`를 콘텐츠 ID로 사용해 `AppStep.contentDetail(contentId:)`로 이동한다.
- `MainView.handleFollowingScheduleTap(type:targetId:)``CreatorChannelView.handleScheduleTap(type:targetId:)` 모두 `.live``.liveReplay`를 같은 case로 묶지 않고 동일한 타입 의미를 적용한다.
- 전역 `liveDetailSheet`의 렌더 호스트는 `NavigationStack`의 destination보다 위인 `ContentView` 외부 overlay에 둔다.
- 기존 `NavigationStack` 루트 `ZStack` 안의 `liveDetailSheet` 렌더 분기는 복제하지 않고 외부 overlay로 이동하며, 이동 후 기존 위치의 분기는 제거한다.
- overlay 내부 표시 순서는 라이브 상세, 전역 라이브룸, 라이브룸 외부 이동 확인 다이얼로그 순으로 두어 라이브룸과 확인 다이얼로그의 기존 우선순위를 유지한다.
- 지원하지 않는 type은 crash 없이 무시하거나 placeholder로 둔다. - 지원하지 않는 type은 crash 없이 무시하거나 placeholder로 둔다.
### 7.12 오디오 섹션 ### 7.12 오디오 섹션
@@ -363,6 +385,7 @@ Figma 참조:
- 프로젝트 설정 변경은 신규 파일이 Xcode project에 자동 포함되지 않는 구조일 때만 수행한다. - 프로젝트 설정 변경은 신규 파일이 Xcode project에 자동 포함되지 않는 구조일 때만 수행한다.
- Kotlin `Long`으로 정의된 id/count 계열 값은 Swift에서 `Int`로 통일한다. - Kotlin `Long`으로 정의된 id/count 계열 값은 Swift에서 `Int`로 통일한다.
- SNS 섹션 아이콘은 Figma localhost asset이 아니라 프로젝트 asset catalog의 `ic_sns_*` 아이콘을 사용한다. - SNS 섹션 아이콘은 Figma localhost asset이 아니라 프로젝트 asset catalog의 `ic_sns_*` 아이콘을 사용한다.
- 라이브 상세 표시 계층 보정은 기존 전역 상태를 재사용하고 `SodaLive/Sources/ContentView.swift` 한 파일의 렌더 위치만 변경한다.
## 10. Success Criteria ## 10. Success Criteria
- 크리에이터 채널 진입 시 `CreatorChannelView`가 표시되고, 기본 선택 탭은 `홈`이다. - 크리에이터 채널 진입 시 `CreatorChannelView`가 표시되고, 기본 선택 탭은 `홈`이다.
@@ -393,6 +416,17 @@ Figma 참조:
- 홈 외 tab-bar 항목 선택 시 선택한 탭 제목을 표시하는 placeholder page가 표시된다. - 홈 외 tab-bar 항목 선택 시 선택한 탭 제목을 표시하는 placeholder page가 표시된다.
- 홈 섹션의 전체보기 action은 기존 화면 또는 placeholder destination으로 이동한다. - 홈 섹션의 전체보기 action은 기존 화면 또는 placeholder destination으로 이동한다.
- 기존 V2 공용 컴포넌트를 재사용 가능한 곳에서 재사용하고, 채널 홈 전용 UI만 전용 `Components`로 분리한다. - 기존 V2 공용 컴포넌트를 재사용 가능한 곳에서 재사용하고, 채널 홈 전용 UI만 전용 `Components`로 분리한다.
- 크리에이터 채널 홈 스케줄의 `.live` 항목을 누르면 메인 화면으로 이동하지 않아도 라이브 상세가 현재 채널 위에 즉시 표시된다.
- 크리에이터 채널과 메인 스케줄의 `.liveReplay` 항목을 누르면 라이브 상세나 라이브룸이 아니라 `.audio`와 동일한 오디오 콘텐츠 상세 페이지로 이동한다.
- `.liveReplay` 처리 중에는 `AppState.liveDetailSheet`를 설정하거나 라이브 입장 함수를 호출하지 않는다.
- 라이브 상세를 닫으면 navigation path를 변경하지 않고 기존 크리에이터 채널 화면이 유지된다.
- 메인 화면과 다른 navigation destination에서 사용하는 기존 `.liveDetail` 진입도 동일한 전역 호스트에서 한 번만 표시된다.
- `ContentView``if let liveDetailSheet` 상태 기반 렌더 분기는 외부 overlay에 하나만 남고 기존 루트 `ZStack`에는 남지 않는다.
- 이미 종료된 라이브 상세 조회 실패 시 `AppState.liveDetailSheet` 또는 각 화면의 `LiveDetailView` 표시 상태가 해제되어 DIM 배경이 남지 않는다.
- 채널 홈 후원 섹션 카드 너비는 `402pt` 이상의 디바이스에서 `374pt`이고, `402pt` 미만에서는 `374 × deviceWidth / 402`로 계산된다.
- 후원 카드의 높이, 내부 콘텐츠, 패딩, 카드 간격 및 후원 탭 세로 목록 레이아웃은 기존 상태를 유지한다.
- 내 채널의 헤더 팔로워 수를 누르면 `FollowerListView(userId: creatorId)`로 이동한다.
- 다른 사람 채널에서는 헤더 팔로워 수 탭으로 팔로워 목록에 이동하지 않는다.
## 11. Open Questions ## 11. Open Questions
해당 없음. 해당 없음.
@@ -411,3 +445,7 @@ Figma 참조:
- 2026-07-02: 사용자 확인 사항과 최신 코드 기준을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_colored`)을 확정 요구사항으로 추가했다. - 2026-07-02: 사용자 확인 사항과 최신 코드 기준을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_colored`)을 확정 요구사항으로 추가했다.
- 2026-07-02: 기존 `UserProfile` 동작을 확인해 title bar의 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하도록 확정했다. - 2026-07-02: 기존 `UserProfile` 동작을 확인해 title bar의 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하도록 확정했다.
- 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 요구사항을 정리했다. - 2026-07-02: 사용자 확인 사항을 반영해 HeaderView는 `CreatorChannelHomeResponse.creator` 데이터로 채우고, 별도 프로필 이미지 없이 큰 배경 이미지만 표시하며, title bar는 기본 투명 배경으로 header 위에 overlay되도록 요구사항을 정리했다.
- 2026-07-20: 크리에이터 채널 스케줄의 라이브 예약 항목 tap은 `CreatorChannelView.handleScheduleTap`에서 `AppState.setAppStep(.liveDetail)`까지 정상 전달되지만, `LiveDetailView``NavigationStack` 루트 콘텐츠 내부에 있어 destination인 `CreatorChannelView` 뒤에 렌더링되는 표시 계층 버그를 확인했다. 기존 전역 상태와 callback은 유지하고 `ContentView` 외부 overlay로 렌더 호스트만 이동하는 최소 수정안을 확정했다.
- 2026-07-20: 사용자 확인에 따라 기존 루트 `ZStack``liveDetailSheet` 분기를 외부 overlay에 복제하지 않고 이동하며, 이동 후 불필요해진 기존 위치의 분기를 제거하는 것으로 범위를 명확히 했다.
- 2026-07-20: 사용자 확인에 따라 `LiveDetailView` 표시 대상은 `.live`로 한정했다. `.liveReplay`는 다시 듣기 카테고리의 업로드 콘텐츠이므로 `.audio`와 동일한 오디오 콘텐츠 상세 화면으로 이동하며, 현재 두 스케줄 handler에서 `.live`와 묶인 분기를 각각 수정 대상으로 추가했다.
- 2026-08-03: 사용자 요청에 따라 내 채널의 헤더 팔로워 수를 기존 레거시 `FollowerListView`로 연결하는 범위를 추가했다. 요청 문구의 `팔로잉 n명`은 현재 UI와 API 계약상 `팔로워 n명`, 즉 해당 채널을 팔로우하는 사용자 목록으로 해석한다.

View File

@@ -0,0 +1,181 @@
# 라이브룸 전역 표시와 키보드 레이아웃 수정 계획
## 기준 문서
- PRD: `docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md`
- 코드 스타일: `docs/agent-guides/code-style.md`
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
- 기존 외부 이동 확인 기록: `docs/plan-task/20260306_라이브룸외부이동확인다이얼로그.md`
## 구현 원칙
- `enterRoom`의 API 요청·응답 처리는 유지하고, 전역 overlay와 충돌하는 navigation side effect만 분리한다.
- 전역 라이브룸은 한 곳에서만 생성한다.
- 표시 계층 이동으로 가려질 수 있는 기존 외부 이동 확인 다이얼로그도 같은 최상위 계층으로 옮긴다.
- 키보드 처리 방식은 새로 만들지 않고, 현재 수동 offset과 충돌하는 safe area 적용 위치만 바로잡는다.
### Phase 1: 라이브룸 전역 표시
- [x] **Task 1.1: 라이브룸 표시 지점을 앱 최상위로 이동**
- 대상 파일:
- 수정: `SodaLive/Sources/ContentView.swift`
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- `ContentView``NavigationStack` 최상위에 전역 `ZStack` overlay를 구성한다.
- `AppState.shared.isShowPlayer``true`이면 `NavigationStack` 위에 `LiveRoomViewV2`를 표시한다.
- `MainView` 내부의 기존 `LiveRoomViewV2` 표시 분기를 제거해 중복 생성을 방지한다.
- 검증 기준:
- 실행 명령: `rg -n "LiveRoomViewV2\\(" SodaLive/Sources/ContentView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 실제 표시 지점은 `ContentView` 한 곳에서만 확인된다.
- [x] **Task 1.2: 외부 이동 확인 상태와 다이얼로그를 전역 계층으로 이동**
- 대상 파일:
- 수정: `SodaLive/Sources/App/AppState.swift`
- 수정: `SodaLive/Sources/ContentView.swift`
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- pending navigation action과 cancel action, 다이얼로그 표시 상태를 `AppState`에서 관리한다.
- `MainView`의 외부 이동 요청은 `AppState`에 위임한다.
- `ContentView`에서 기존 문구와 `SodaV2ActionModal`을 사용해 라이브룸 위에 확인 다이얼로그를 표시한다.
- 라이브 종료 후 pending action 실행 및 취소 시 상태 정리 의미를 유지한다.
- 검증 기준:
- 실행 명령: `rg -n "isShowLeaveLiveNavigationDialog|requestExternalNavigation|confirmExternalNavigation|cancelExternalNavigation|requestLiveRoomQuitForExternalNavigation" SodaLive/Sources/App/AppState.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 상태/처리는 `AppState`, 표시는 `ContentView`, 요청 위임은 `MainView`에서 확인된다.
### Phase 2: 라이브 채팅 키보드 레이아웃 수정
- [x] **Task 2.1: 키보드 safe area 무시 위치 보정 (초기 보정, Phase 4에서 대체)**
- 대상 파일:
- 수정: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 작업 내용:
- 내부 hosted content에 적용된 `.ignoresSafeArea(.keyboard)`를 제거한다.
- `ScreenCaptureSecureContainer` 바깥에 `.ignoresSafeArea(.keyboard)`를 적용한다.
- 기존 `appliedKeyboardHeight`와 채팅 입력 offset은 유지한다.
- 검증 기준:
- 실행 명령: `rg -n "ScreenCaptureSecureContainer|ignoresSafeArea\\(\\.keyboard\\)|offset\\(y: -appliedKeyboardHeight\\)" SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 기대 결과: outer container가 키보드 safe area를 무시하고 수동 offset은 한 번만 적용된다.
### Phase 3: 검증
- [x] **Task 3.1: 변경 범위 및 정적 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/ContentView.swift`
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 확인: `SodaLive/Sources/App/AppState.swift`
- 확인: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 작업 내용:
- 라이브룸 단일 표시, 외부 이동 확인 흐름, 키보드 modifier 위치를 검색한다.
- `git diff --check`로 whitespace 오류를 확인한다.
- 검증 기준:
- 기대 결과: PRD 범위를 벗어난 소스 변경이 없고 정적 검사 오류가 없다.
- [x] **Task 3.2: Debug 빌드 검증**
- 대상 파일:
- 확인: `SodaLive.xcworkspace`
- 작업 내용:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행한다.
- 검증 기준:
- 기대 결과: `** BUILD SUCCEEDED **`.
- [ ] **Task 3.3: 수동 동작 검증**
- 대상 화면:
- 크리에이터 채널의 즉시 라이브 생성 화면
- 라이브룸 채팅 화면
- 라이브 중 푸시/딥링크 외부 이동 확인 화면
- 작업 내용:
- 즉시 라이브 생성 후 현재 화면 위에 라이브룸이 보이는지 확인한다.
- 키보드 표시 시 입력창이 키보드 바로 위에 있고 큰 빈 공간이 없는지 확인한다.
- 외부 이동 확인/취소 흐름을 확인한다.
- 검증 기준:
- 기대 결과: PRD Success Criteria를 충족한다.
### Phase 4: 사용자 재검증 후 보정
- [x] **Task 4.1: 보안 컨테이너를 화면 전체로 확장**
- 대상 파일:
- 수정: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 작업 내용:
- `ScreenCaptureSecureContainer`에 무한 최대 frame을 부여한다.
- 컨테이너 바깥에서 모든 safe area를 무시해 UIKit 컨테이너 경계가 전체 화면을 차지하도록 한다.
- 검증 기준:
- 라이브룸 배경이 상태 표시줄과 홈 인디케이터 영역까지 덮는다.
- [x] **Task 4.2: 내부 UIHostingController의 키보드 자동 회피 제거**
- 대상 파일:
- 수정: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 작업 내용:
- `UIHostingController.safeAreaRegions``.container`로 제한한다.
- 일반 container safe area는 내부 UI에 유지하고 keyboard safe area만 제외한다.
- 기존 `appliedKeyboardHeight` offset은 유지한다.
- 검증 기준:
- 키보드 표시 시 내부 호스팅 크기 축소와 수동 offset이 중복되지 않는다.
### Phase 5: 재검증
- [x] **Task 5.1: 정적 검사 및 Debug 빌드**
- 대상 파일:
- 확인: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 검증 기준:
- `git diff --check`가 성공한다.
- `SodaLive-dev` Debug 빌드가 `** BUILD SUCCEEDED **`로 완료된다.
- [ ] **Task 5.2: 수정본 수동 동작 검증**
- 대상 화면:
- 전역 라이브룸 화면
- 라이브룸 채팅 입력 화면
- 검증 기준:
- 라이브룸이 화면 전체를 덮는다.
- 키보드 표시 시 입력창이 키보드 바로 위에 위치하고 큰 빈 공간이 없다.
### Phase 6: 라이브 종료 후 입장 전 화면 복원
- [x] **Task 6.1: 공용 입장 성공 처리에서 navigation 초기화 제거**
- 대상 파일:
- 수정: `SodaLive/Sources/Live/LiveViewModel.swift`
- 수정: `SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift`
- 작업 내용:
- 입장 성공 시 `roomId``isShowPlayer`만 갱신한다.
- 공용 `enterRoom` 내부의 `setAppStep(.main)` 호출을 제거한다.
- 검증 기준:
- 앱 내부 진입점이 현재 navigation path를 유지한 채 전역 라이브룸을 표시한다.
- [x] **Task 6.2: 콜드 스타트 라이브 푸시의 메인 전환 명시**
- 대상 파일:
- 수정: `SodaLive/Sources/Splash/SplashView.swift`
- 작업 내용:
- 스플래시에서 `pushRoomId`를 로컬 값으로 소비한다.
- `.main`을 설정한 뒤 소비한 room ID로 입장을 진행한다.
- 검증 기준:
- 기존 콜드 스타트 푸시가 스플래시에 머물지 않고 메인 위 라이브룸으로 진입한다.
- [x] **Task 6.3: 정적 검사 및 Debug 빌드**
- 대상 파일:
- 확인: `SodaLive/Sources/Live/LiveViewModel.swift`
- 확인: `SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift`
- 확인: `SodaLive/Sources/Splash/SplashView.swift`
- 검증 기준:
- `git diff --check`가 성공한다.
- `SodaLive-dev` Debug 빌드가 `** BUILD SUCCEEDED **`로 완료된다.
- [ ] **Task 6.4: 진입점별 수동 복귀 검증**
- 대상 화면:
- 홈/라이브 탭, 라이브 상세, 크리에이터 채널, 프로필
- 콜드 스타트 및 포그라운드 라이브 푸시
- 검증 기준:
- 일반 입장은 종료 후 입장 직전 화면을 표시한다.
- 푸시 입장은 호출부에 정의된 navigation 정책을 유지한다.
## 검증 기록
- 2026-07-14: 코드 조사 결과 `LiveRoomViewV2``MainView` 내부에 있어 navigation destination보다 아래에 놓이는 표시 계층 문제와, `ScreenCaptureSecureContainer` 바깥의 시스템 키보드 회피 및 내부 수동 offset이 중복될 수 있는 구조를 확인했다. 구현 및 빌드 검증은 아직 수행하지 않았다.
- 2026-07-14: `LiveRoomViewV2` 표시를 `ContentView``NavigationStack` overlay로 옮기고, 외부 이동 확인 상태와 다이얼로그도 같은 전역 계층으로 이동했다. `MainView`에서는 전역 상태에 요청만 위임하도록 정리했다.
- 2026-07-14: `.ignoresSafeArea(.keyboard)`를 내부 hosted content에서 `ScreenCaptureSecureContainer` 바깥으로 이동해 시스템 키보드 회피와 기존 수동 offset이 중복되지 않도록 했다.
- 2026-07-14: `rg`로 라이브룸 단일 표시 지점, 외부 이동 확인 흐름, 키보드 modifier 위치를 확인했고 `git diff --check`가 오류 없이 완료됐다.
- 2026-07-14: sandbox 환경의 CoreSimulator/XPC 접근 제한으로 최초 빌드가 소스 컴파일 전에 중단됐다. 동일 명령을 허용된 환경에서 `-derivedDataPath /tmp/SodaLiveDerivedData`와 함께 다시 실행했으며, 전체 빌드와 최종 변경 후 증분 빌드 모두 `** BUILD SUCCEEDED **`를 확인했다.
- 2026-07-14: Task 3.3의 실제 즉시 라이브 생성, 채팅 키보드, 라이브 중 외부 이동 확인/취소는 기기 또는 시뮬레이터 수동 QA가 필요해 미실행 상태로 남겼다.
- 2026-07-14: 사용자 재검증에서 라이브룸이 화면 전체를 덮지 않고 키보드 위 빈 공간도 남는 것을 확인했다. 바깥 `ScreenCaptureSecureContainer`와 내부 `UIHostingController`가 별도의 safe area 경계를 가지므로 바깥 modifier 이동만으로는 부족했다.
- 2026-07-14: `ScreenCaptureSecureContainer`를 전체 화면 frame으로 확장하고 모든 바깥 safe area를 무시하도록 보정했다. 내부 `UIHostingController.safeAreaRegions``.container`로 제한해 상태 표시줄/홈 인디케이터 safe area는 유지하면서 키보드 자동 회피만 제외했다.
- 2026-07-14: 보정 후 `git diff --check`가 성공했고, `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -derivedDataPath /tmp/SodaLiveDerivedData build`에서 `** BUILD SUCCEEDED **`를 확인했다. Task 5.2의 실제 화면 확인은 사용자 수동 재검증이 필요하다.
- 2026-07-14: 사용자 재검증에서 크리에이터 채널로 입장한 라이브 종료 후 메인이 표시되는 문제를 확인했다. 종료 처리가 이동한 것이 아니라 공용 `enterRoom``setAppStep(.main)`이 입장 시 navigation path를 미리 삭제한 것이 원인이었다.
- 2026-07-14: 일반 입장은 현재 화면을 보존하도록 공용 입장 구현 두 곳에서 navigation 초기화를 제거했다. 2025년 콜드 스타트 라이브 푸시 보정의 의도는 `SplashView`에서 `pushRoomId` 소비와 `.main` 전환을 명시하는 방식으로 유지했다.
- 2026-07-14: 변경 후 `git diff --check`가 성공했고, `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -derivedDataPath /tmp/SodaLiveDerivedData build`에서 `** BUILD SUCCEEDED **`를 확인했다. Task 6.4의 진입점별 종료 복귀와 푸시 진입은 실제 앱 수동 검증이 필요하다.

View File

@@ -0,0 +1,86 @@
# PRD: 라이브룸 전역 표시와 키보드 레이아웃 수정
## 1. Overview
라이브룸 표시 계층을 메인 페이지 내부에서 앱 최상위 계층으로 옮겨 현재 화면과 무관하게 입장 결과가 보이도록 하고, 종료 후 입장 전 화면을 복원하며, 라이브 채팅 키보드 표시 시 중복 레이아웃 이동을 제거한다.
## 2. Problem
- `LiveViewModel.enterRoom(roomId:)` 성공 후 `AppState.shared.isShowPlayer``true`가 되어도 `LiveRoomViewV2``MainView` 내부에만 배치되어 있다.
- 크리에이터 채널과 라이브 생성 화면은 `ContentView``NavigationStack` destination으로 메인 페이지 위에 표시되므로, 라이브룸 상태가 활성화되어도 현재 destination보다 아래에서 렌더링될 수 있다.
- `LiveRoomViewV2`는 채팅 키보드 높이만큼 콘텐츠를 수동으로 올리지만, 화면 캡처 보호용 `ScreenCaptureSecureContainer`가 SwiftUI와 내부 `UIHostingController` 사이에 경계를 만든다.
- 바깥 SwiftUI 계층의 키보드 safe area만 무시해도 내부 `UIHostingController`의 기본 `safeAreaRegions == .all`에 키보드 영역이 남는다. 내부 콘텐츠가 자동으로 줄어든 뒤 수동 offset까지 적용되어 키보드 위에 불필요한 빈 공간이 생긴다.
- `ScreenCaptureSecureContainer` 자체가 일반 container safe area 안에 배치되면 내부 배경의 `edgesIgnoringSafeArea`가 UIKit 컨테이너 경계를 넘어갈 수 없어 라이브룸이 화면 전체를 덮지 못한다.
- 공용 `enterRoom` 성공 처리에서 `setAppStep(.main)`을 호출해 모든 일반 입장 경로의 navigation path를 삭제한다. 라이브 종료는 전역 overlay만 숨기므로 홈, 라이브 상세, 크리에이터 채널, 프로필 등 입장 전 화면 대신 메인 페이지가 나타난다.
## 3. Goals
- 어떤 `NavigationStack` destination이 표시 중이어도 라이브룸 입장 성공 시 `LiveRoomViewV2`가 앱 화면 최상위에 표시된다.
- 라이브룸의 보안 컨테이너와 배경이 상단 및 하단 safe area를 포함한 화면 전체를 덮는다.
- 크리에이터 채널에서 즉시 시작 라이브를 생성하면 `enterRoom` 성공 직후 라이브룸이 보인다.
- 라이브룸 표시 계층 변경 후에도 라이브 중 푸시/딥링크 외부 이동 확인 다이얼로그가 라이브룸 위에 표시된다.
- 채팅 키보드가 표시될 때 입력창은 키보드 바로 위에 위치하고, 키보드 높이만큼의 중복 빈 공간이 생기지 않는다.
- 앱 내부의 일반 입장에서는 현재 navigation path를 보존하고, 라이브 종료 후 입장 직전 화면을 다시 표시한다.
- 콜드 스타트 라이브 푸시는 스플래시 처리 지점에서 메인 페이지를 준비한 뒤 정상적으로 입장한다.
- 기존 입장 API, Agora 초기화/종료, 채팅 전송, 캡처 보호 동작을 유지한다.
## 4. Non-Goals
- `enterRoom`, `getRoomDetail`, 결제 또는 비밀번호 확인의 API 요청·응답 처리 흐름을 변경하지 않는다.
- 라이브 생성 후 메인/홈/라이브 데이터 갱신 정책을 변경하지 않는다.
- 라이브룸 UI 디자인이나 채팅 컴포넌트 모양을 변경하지 않는다.
- 키보드 처리 공통 모듈 전체를 리팩터링하지 않는다.
- `Pods/**`, `generated/**`, `build/**`를 수정하지 않는다.
## 5. Core Requirements
### 5.1 라이브룸 전역 표시
- `LiveRoomViewV2`의 단일 표시 지점을 `MainView` 내부가 아닌 `ContentView``NavigationStack` 최상위 overlay로 이동한다.
- 표시 조건은 기존과 동일하게 `AppState.shared.isShowPlayer`를 사용한다.
- `LiveRoomViewV2`를 중복 생성하지 않는다.
- `LiveViewModel.enterRoom(roomId:)`의 API 성공 판정과 `AppState.shared.roomId` 설정은 변경하지 않는다.
### 5.2 외부 이동 확인 보존
- 라이브 중 푸시/딥링크 이동 요청의 pending action을 앱 최상위 계층에서 접근 가능한 상태로 관리한다.
- 확인 다이얼로그는 전역 라이브룸보다 위에 표시한다.
- 확인 시 기존과 같이 `.requestLiveRoomQuitForExternalNavigation` 알림으로 라이브 종료를 요청하고, `isShowPlayer == false`가 된 뒤 pending action을 실행한다.
- 취소 시 pending action과 cancel action을 정리하고 라이브룸을 유지한다.
### 5.3 키보드 레이아웃
- 기존 수동 `appliedKeyboardHeight` offset 동작은 유지한다.
- `ScreenCaptureSecureContainer`는 화면 전체 크기로 확장하고 바깥 SwiftUI 계층의 safe area를 무시한다.
- 내부 `UIHostingController.safeAreaRegions``.container`로 제한해 일반 화면 safe area는 전달하되 키보드 safe area는 전달하지 않는다.
- 시스템 키보드 회피와 수동 offset이 동시에 적용되지 않도록 한다.
- 캡처 보호 활성/비활성 상태 모두 같은 키보드 레이아웃 규칙을 사용한다.
### 5.4 입장 전 화면 복원
- 공용 `LiveViewModel.enterRoom``UserProfileViewModel.enterRoom`은 라이브 상태만 활성화하고 navigation을 변경하지 않는다.
- 홈, 라이브 탭, 라이브 상세, 크리에이터 채널, 프로필 등 앱 내부 진입점은 각각의 현재 화면을 라이브룸 아래에 유지한다.
- 콜드 스타트 라이브 푸시는 `SplashView`에서 `pushRoomId`를 소비하고 `.main`을 설정한 뒤 입장한다.
- 포그라운드 푸시와 딥링크의 기존 호출부별 navigation 정책은 유지한다.
## 6. Technical Constraints
- 변경은 라이브룸 전역 표시, 키보드 레이아웃, 공용 입장 성공 처리와 콜드 스타트 푸시 처리에 직접 관련된 기존 파일에 한정한다.
- 기존 `SodaV2ActionModal`과 외부 이동 확인 문구를 재사용한다.
- 신규 사용자 노출 문자열이나 신규 API를 추가하지 않는다.
- 자동화 테스트 번들 타깃이 없는 현재 프로젝트 구성에서는 정적 검색과 `SodaLive-dev` Debug 빌드로 자동 검증하고, 실제 키보드 높이는 수동 QA 항목으로 남긴다.
## 7. Success Criteria
- 크리에이터 채널에서 즉시 라이브 생성 후 라이브룸이 현재 destination 위에 표시된다.
- 홈, 라이브, 크리에이터 채널 등 진입 화면과 무관하게 `isShowPlayer == true`이면 전역 라이브룸이 한 번만 표시된다.
- 라이브룸 배경이 상태 표시줄과 홈 인디케이터 영역을 포함한 화면 전체를 덮는다.
- 라이브 중 외부 이동 요청 시 확인 다이얼로그가 라이브룸 위에 표시되고 확인/취소가 기존 의미대로 동작한다.
- 채팅 입력 포커스 시 화면 이동량이 키보드 회피와 중복되지 않으며 입력창 아래에 큰 빈 공간이 생기지 않는다.
- 앱 내부에서 라이브 종료 시 입장 직전 화면과 navigation path가 유지된다.
- 콜드 스타트 라이브 푸시에서는 스플래시가 사라지고 메인 페이지 위에 라이브룸이 표시된다.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 성공한다.
## 8. Open Questions
- 해당 없음.

View File

@@ -0,0 +1,30 @@
# 메인 복귀 시 중복 초기화 방지 계획
### Phase 1: 현재 동작 확인
- [x] **Task 1.1: MainView 생명주기와 내비게이션 구조 확인**
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 확인: `SodaLive/Sources/ContentView.swift`
- 확인: `SodaLive/Sources/App/AppState.swift`
- 검증 기준: `MainView``NavigationStack` 루트에 유지되고 복귀 시 첫 번째 `.onAppear`의 요청이 반복되는 구조를 확인한다.
### Phase 2: 중복 초기화 차단
- [x] **Task 2.1: MainView 인증 데이터 초기화에 1회 가드 적용**
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 검증 기준: 로그인 상태에서 `hasLoadedMainData``false`인 경우에만 인증 데이터 요청이 실행된다.
- 검증 기준: `contentView`와 push 메시지용 두 번째 `.onAppear`는 변경되지 않는다.
### Phase 3: 검증
- [x] **Task 3.1: 변경 범위 및 Debug 빌드 검증**
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 실행 명령: `git diff --check`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath /private/tmp/SodaLiveDerivedData CODE_SIGNING_ALLOWED=NO build`
- 기대 결과: 공백 오류와 컴파일 오류 없이 빌드가 성공한다.
## 검증 기록
- 2026-07-15: `ContentView``AppState`를 확인해 일반 상세 이동은 `navigationPath`에 destination을 추가하고, 메인 복귀는 path를 제거하는 구조임을 확인했다. `MainView`의 인증 데이터 요청은 첫 번째 `.onAppear`에 있어 복귀마다 다시 실행될 수 있다.
- 2026-07-15: `MainView``hasLoadedMainData` 상태를 추가하고, 토큰이 존재하면서 아직 로드하지 않은 경우에만 인증 데이터 초기화 요청을 실행하도록 변경했다. `contentView`와 push 메시지용 두 번째 `.onAppear`는 변경하지 않았다.
- 2026-07-15: `git diff --check`가 exit 0으로 통과했다. Simulator 접근이 필요한 빌드는 샌드박스 밖에서 실행했으며, 위 `xcodebuild` 명령이 기존 경고만 출력하고 `** BUILD SUCCEEDED **` 및 exit 0으로 완료됐다.

View File

@@ -0,0 +1,41 @@
# PRD: 메인 복귀 시 중복 초기화 방지
## 목표
- 다른 페이지에서 `MainView`로 복귀할 때 메인 컨테이너의 인증 데이터 초기화 요청이 반복되지 않게 한다.
- `contentView`와 각 탭 하위 화면의 기존 새로고침 동작은 유지한다.
## 문제/배경
- `MainView``NavigationStack`의 루트로 유지되지만, destination에서 복귀하면 첫 번째 `.onAppear`가 다시 실행된다.
- 이때 `pushTokenUpdate()`, 회원 정보, 이벤트 팝업, 재생 추적 요청이 매번 반복된다.
## 성공 기준
- 로그인 상태에서 메인 인증 데이터 초기화 요청은 동일한 `MainView` 생명주기 동안 한 번만 실행된다.
- 비로그인 최초 진입은 로드 완료로 처리하지 않아 이후 로그인한 메인 진입에서 초기화 요청이 실행된다.
- `contentView` 구성과 push 메시지 복귀 처리는 변경하지 않는다.
- `SodaLive` Debug 빌드가 성공한다.
## 제외 범위
- 탭 하위 화면의 `.onAppear` 및 데이터 새로고침 정책 변경
- `AppState` 내비게이션 구조 변경
- `softRestart()`가 의도적으로 `MainView`를 재생성하는 동작 변경
- 기존 사용자 작업인 이벤트 팝업 배경 및 프로젝트 버전 설정 변경
## 핵심 요구사항
- `MainView`가 소유하는 로컬 `@State`로 초기화 완료 여부를 관리한다.
- 토큰이 비어 있을 때는 초기화 완료 상태를 설정하지 않는다.
- 기존 첫 번째 `.onAppear`의 인증 데이터 요청에만 가드를 적용한다.
## 기술/운영 제약
- 수정 범위는 `SodaLive/Sources/V2/Main/MainView.swift`로 제한한다.
- 현재 프로젝트에 테스트 번들 타깃이 없으므로 정적 확인과 공식 Debug 빌드로 검증한다.
- 기존 작업 트리 변경을 보존한다.
## 미결정 사항
- 해당 없음.

View File

@@ -0,0 +1,272 @@
# 이미지 크롭 범위 보정 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 어떤 crop 비율에서도 fitted image의 가로 또는 세로 한 축 전체를 사용하는 최대 crop 영역을 제공하면서 서버 전송 이미지의 긴 변 최대 800px 정책을 유지한다.
**Architecture:** UI와 독립된 `ImageCropGeometry.maximumCropSize`가 주어진 fitted image와 crop 비율로 최대 크기를 계산한다. `ImageCropEditorView`는 고정 crop의 크기, 자유 crop의 초기 크기와 resize 상한에 이 결과와 fitted image 경계를 사용하며 기존 crop 좌표 변환과 업로드 흐름은 유지한다.
**Tech Stack:** Swift, SwiftUI, UIKit, CoreGraphics, `swiftc`, Xcode workspace
---
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-03 |
| 요구사항 기준 | `docs/20260803_이미지_크롭_범위_보정/prd.md` |
| API 기준 | 변경 없음 |
| 현재 Phase | Phase 1 |
| 현재 활성 Goal | 없음 |
## 범위
### 포함
- fitted image 안의 최대 crop 크기 순수 계산
- `.square` crop 초기 범위 보정
- `.free` crop 초기 범위와 resize 상한 보정
- geometry check, 앱 빌드, Simulator 수동 확인
- 기존 최대 800px 결과 정책 회귀 확인
### 제외
- 외부 dependency 추가
- crop 비율 선택 UI 및 새 `ImageCropAspectPolicy` case
- 호출 화면과 업로드 API 수정
- 관련 없는 cropper 리팩터링
## 파일 구조
- Create: `SodaLive/Sources/ImagePicker/ImageCropGeometry.swift` — crop 비율에 맞는 최대 fitted 크기 계산만 담당한다.
- Create: `work/tests/ImageCropGeometryCheck.swift` — 프레임워크 없이 실행하는 geometry 회귀 check다.
- Modify: `SodaLive/Sources/ImagePicker/ImagePicker.swift` — 기존 crop size와 자유 resize 경계를 순수 geometry 결과에 연결한다.
- Modify: `SodaLive.xcodeproj/project.pbxproj` — 새 production Swift 파일을 `SodaLive`, `SodaLive-dev` 두 target에 포함한다.
- Modify: `docs/20260803_이미지_크롭_범위_보정/plan-task.md` — 완료 체크와 실제 검증 결과를 누적한다.
## Scenario Contract
| Scenario | Binary pass condition | 자동 검증 | 실제 surface |
|---|---|---|---|
| 세로 이미지 | fitted `350x700`, 비율 `1` 결과가 `350x350`이다. | `ImageCropGeometryCheck/portraitUsesFullWidth` | Simulator에서 세로 이미지 선택 후 crop box 좌우가 이미지 경계와 일치한다. |
| 가로 이미지 | fitted `400x200`, 비율 `4/3` 결과가 약 `266.67x200`이다. | `ImageCropGeometryCheck/landscapeUsesFullHeight` | Simulator에서 가로 이미지 선택 후 현재 비율 crop box 상하가 이미지 경계와 일치한다. |
| 동일 비율 경계 | fitted `320x180`, 비율 `16/9` 결과가 `320x180`이다. | `ImageCropGeometryCheck/matchingRatioUsesWholeImage` | 계산 check 출력이 PASS다. |
| 잘못된 입력 | 0 이하 크기 또는 비율 결과가 `.zero`다. | `ImageCropGeometryCheck/invalidInputReturnsZero` | 계산 check 출력이 PASS다. |
| 인접 회귀 | crop 적용 결과가 기존 `resizedToMaxDimension(800)`을 거치고 앱이 빌드된다. | 정적 확인 + workspace build | Simulator에서 적용 후 기존 미리보기 화면으로 복귀한다. |
**중단 조건:** 모든 scenario의 RED→GREEN 증거, Simulator 실제 화면 증거, 두 scheme build 성공, 변경 파일 진단, QA 자원 정리와 리뷰 승인이 확보되면 즉시 종료한다.
## Phase 1
**Phase 결과:** 공통 cropper가 fitted image 기준 최대 crop 영역을 사용한다.
**선행조건:** 승인된 `prd.md`.
### Task 1.1 최대 crop geometry를 TDD로 구현
**Goal 실행 `P1-T1`:** crop 비율을 유지하면서 fitted image 안의 최대 크기를 반환하는 순수 함수를 제공한다.
**Files:**
- Create: `work/tests/ImageCropGeometryCheck.swift`
- Create: `SodaLive/Sources/ImagePicker/ImageCropGeometry.swift`
- Modify: `SodaLive.xcodeproj/project.pbxproj`
- [x] **RED:** `work/tests/ImageCropGeometryCheck.swift`를 다음 scenario로 작성한다.
```swift
import CoreGraphics
import Foundation
@main
struct ImageCropGeometryCheck {
static func main() {
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 350, height: 700), aspectRatio: 1),
equals: CGSize(width: 350, height: 350),
scenario: "portraitUsesFullWidth"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 400, height: 200), aspectRatio: 4.0 / 3.0),
equals: CGSize(width: 800.0 / 3.0, height: 200),
scenario: "landscapeUsesFullHeight"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 320, height: 180), aspectRatio: 16.0 / 9.0),
equals: CGSize(width: 320, height: 180),
scenario: "matchingRatioUsesWholeImage"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: .zero, aspectRatio: 1),
equals: .zero,
scenario: "invalidInputReturnsZero"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 100, height: 100), aspectRatio: 0),
equals: .zero,
scenario: "invalidRatioReturnsZero"
)
print("ImageCropGeometryCheck PASS")
}
private static func assertSize(_ actual: CGSize, equals expected: CGSize, scenario: String) {
let tolerance = 0.001
precondition(abs(actual.width - expected.width) < tolerance, "\(scenario) width: \(actual.width)")
precondition(abs(actual.height - expected.height) < tolerance, "\(scenario) height: \(actual.height)")
}
}
```
- [x] **RED 확인:** production helper 없이 check를 컴파일해 `cannot find 'ImageCropGeometry' in scope` 실패를 확인한다.
```bash
xcrun swiftc -parse-as-library work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck
```
- [x] **GREEN:** `SodaLive/Sources/ImagePicker/ImageCropGeometry.swift`를 다음 최소 구현으로 추가한다.
```swift
import CoreGraphics
enum ImageCropGeometry {
static func maximumCropSize(fitting imageSize: CGSize, aspectRatio: CGFloat) -> CGSize {
guard imageSize.width > 0, imageSize.height > 0, aspectRatio > 0 else {
return .zero
}
if imageSize.width / imageSize.height > aspectRatio {
return CGSize(width: imageSize.height * aspectRatio, height: imageSize.height)
}
return CGSize(width: imageSize.width, height: imageSize.width / aspectRatio)
}
}
```
- [x] **GREEN 확인:** helper와 check를 함께 컴파일·실행해 `ImageCropGeometryCheck PASS`를 확인한다.
```bash
xcrun swiftc -parse-as-library SodaLive/Sources/ImagePicker/ImageCropGeometry.swift work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck
/tmp/ImageCropGeometryCheck
```
- [x] 새 helper를 `ImagePicker` group과 `SodaLive`, `SodaLive-dev` Sources phase에 각각 추가한다.
- [x] `/tmp/ImageCropGeometryCheck`를 삭제하고 삭제 결과를 기록한다.
### Task 1.2 기존 cropper에 최대 범위 계산 연결
**Goal 실행 `P1-T2`:** 고정 crop과 자유 crop의 초기·최대 범위가 fitted image 경계를 사용한다.
**Files:**
- Modify: `SodaLive/Sources/ImagePicker/ImagePicker.swift:274-357`
- [x] **RED:** Task 1.1의 check에 세로·가로·동일 비율·잘못된 입력 scenario가 모두 실패했던 기록이 있는지 확인한다.
- [x] **GREEN:** `.square`의 고정 `canvas * 0.72` 계산을 다음 fitted geometry 호출로 교체한다.
```swift
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
return ImageCropGeometry.maximumCropSize(fitting: fittedSize, aspectRatio: 1)
```
- [x] **GREEN:** `defaultFreeCropSize(in:)`에서 기존 기본 비율을 유지하면서 fitted image 안의 최대 크기를 반환한다.
```swift
let preferredSize = CGSize(
width: max(120, min(canvas.width * 0.82, canvas.width - 24)),
height: max(120, min(canvas.height * 0.58, canvas.height - 24))
)
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvas)
return ImageCropGeometry.maximumCropSize(
fitting: fittedSize,
aspectRatio: preferredSize.width / preferredSize.height
)
```
- [x] **GREEN:** 자유 crop의 width/height 상한은 `fittedImageSize`를 사용하고, 각 최소값은 해당 상한과 `120` 중 작은 값으로 계산한다.
```swift
let fittedSize = fittedImageSize(imageSize: normalizedImage.size, canvasSize: canvasSize)
let maxCropWidth = fittedSize.width
let maxCropHeight = fittedSize.height
let minCropWidth = min(120, maxCropWidth)
let minCropHeight = min(120, maxCropHeight)
```
- [x] **REFACTOR:** `cropImage()`의 좌표 변환과 `resizedToMaxDimension(800)`, public initializer, 5개 호출부는 수정하지 않는다.
- [x] **GREEN 확인:** geometry check를 다시 실행해 PASS를 확인한다.
### Task 1.3 검증 및 기록
**Goal 실행 `P1-GATE`:** 계산, 빌드, 실제 crop 화면과 기존 결과 크기 정책을 최종 판정한다.
- [x] `lsp_diagnostics``ImageCropGeometry.swift`, `ImagePicker.swift`에 실행해 신규 오류 0건을 확인한다.
- [x] 다음 build를 실행해 두 scheme 모두 `BUILD SUCCEEDED`를 확인한다.
```bash
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
```
- [x] `rg -n "resizedToMaxDimension\(800\)" SodaLive/Sources/ImagePicker/ImagePicker.swift`가 기존 반환 경로 1건을 출력하는지 확인한다.
- [x] Simulator에 앱을 설치·실행하고 현재 자동화 가능한 실제 화면 screenshot을 저장한다.
- [x] 자동 도구로 cropper 진입까지 조작할 수 없어 실제 crop handle 조작은 독립 reviewer 판정과 함께 수동 QA 한계로 기록한다.
- [x] QA용 임시 실행 파일, Simulator 상태 등 생성 자원을 정리하고 결과를 기록한다.
- [x] 변경 diff를 고강도 reviewer에게 제출하고 수용 기준을 위반하는 blocker가 없음을 확인한다.
- [x] 실제 명령, 결과, screenshot 경로와 남은 항목을 아래 Progress에 누적한다.
## 변경 금지 항목
- `resizedToMaxDimension(800)`과 JPEG/multipart 업로드 경로를 변경하지 않는다.
- 새 crop 비율 UI, external dependency 또는 공통 abstraction을 추가하지 않는다.
- 기존 자유 crop 모서리 gesture와 offset/scale 동작을 범위 밖에서 수정하지 않는다.
- 테스트를 삭제·완화하거나 타입 오류를 우회하지 않는다.
- 사용자 요청 없이 git commit을 만들지 않는다.
## Progress
### `P1` 계획 작성 — 2026-08-03
- 상태: 계획 작성 완료
- 무엇을: 승인된 최대 crop 범위와 기존 최대 800px 전송 정책을 PRD 및 TDD 계획으로 확정했다.
- 왜: canvas 고정 크기로 인해 원본의 가로 또는 세로 전체를 사용하지 못하는 문제를 최소 수정으로 해결하기 위해서다.
- 어떻게: 현재 `ImagePicker.swift`, 5개 호출부, 기존 crop 문서, dependency와 테스트 구성을 확인했다.
- 남은 항목: 사용자 명세 검토, `P1-T1`, `P1-T2`, `P1-GATE`.
- 다음 행동: 명세 승인 후 RED geometry check 작성.
### `P1-T1`/`P1-T2` 구현 — 2026-08-03
- 상태: 완료
- 무엇을: `ImageCropGeometry.maximumCropSize`를 추가하고 `ImageCropEditorView``.square``.free` crop 크기 계산을 fitted image 기준 최대 영역으로 연결했다.
- 왜: crop 비율과 관계없이 원본 표시 영역 안에서 가능한 최대 crop rect를 사용하기 위해서다.
- 어떻게:
- `xcrun swiftc -parse-as-library work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck` — RED 확인, `cannot find 'ImageCropGeometry' in scope` 실패.
- `xcrun swiftc -parse-as-library SodaLive/Sources/ImagePicker/ImageCropGeometry.swift work/tests/ImageCropGeometryCheck.swift -o /tmp/ImageCropGeometryCheck && /tmp/ImageCropGeometryCheck` — GREEN 확인, `ImageCropGeometryCheck PASS`.
- `lsp_diagnostics SodaLive/Sources/ImagePicker/ImageCropGeometry.swift` — 오류 0건.
- `lsp_diagnostics SodaLive/Sources/ImagePicker/ImagePicker.swift` — SourceKit 환경에서 `No such module 'UIKit'` 발생. 이 저장소의 SourceKit 환경 한계로 보고 Xcode build로 대체 검증.
- 남은 항목: 없음.
- 다음 행동: Gate 검증과 자원 정리.
### `P1-GATE` 검증 — 2026-08-03
- 상태: 완료
- 무엇을: geometry check, 두 app scheme build, Simulator build/launch, 결과 크기 정책 유지, 독립 리뷰를 확인했다.
- 왜: 실제 앱 타깃과 기존 서버 전송 정책을 깨지 않았는지 확인하기 위해서다.
- 어떻게:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` — 성공, 최종 `** BUILD SUCCEEDED **`.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -destination "platform=iOS Simulator,name=iPhone 15,OS=17.5" build` — 성공, `** BUILD SUCCEEDED **`.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` — 기존 프로젝트 상태와 동일하게 `Scheme SodaLive is not currently configured for the test action.`로 테스트 액션 미구성 확인.
- `rg -n "resizedToMaxDimension\(800\)" SodaLive/Sources/ImagePicker/ImagePicker.swift` — 1건, 기존 반환 정책 유지 확인.
- Simulator `C012DC2D-6F85-4C9F-AF22-A10621385D53``SodaLive-dev` 설치 및 `kr.co.vividnext.sodalive.debug2` 실행 — 정상 홈 화면 screenshot 저장: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sodalive-crop-qa/app-launch.png`.
- 독립 Oracle review — `VERDICT: PASS`, blocker 없음. 실제 crop 화면 handle 조작 증거는 자동화 경로 미확보로 non-blocking manual QA gap으로 판정.
- `/tmp/ImageCropGeometryCheck` 삭제, Simulator 앱 uninstall 및 shutdown 완료.
- 남은 항목: 실제 기기 또는 수동 Simulator에서 이미지 선택 후 cropper handle 조작 확인 권장.
- 다음 행동: 최종 보고.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-03 | `DEC-001` | 확정 | 외부 cropper를 추가하지 않고 기존 geometry를 보정한다. | 문제 범위가 공통 crop 계산에 한정된다. | `P1-T1`, `P1-T2` |
| 2026-08-03 | `DEC-002` | 확정 | 모든 crop 비율에 fitted image 최대 영역 규칙을 적용한다. | 1:1은 예시라는 사용자 확인 | `P1-T1`, `P1-T2` |
| 2026-08-03 | `DEC-003` | 확정 | 긴 변 최대 800px 결과 정책을 유지한다. | 서버 전송 크기 유지 요구 | `P1-GATE` |

View File

@@ -0,0 +1,84 @@
# 이미지 크롭 범위 보정 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-03 |
| 최종 수정일 | 2026-08-03 |
| 대상 제품 | SodaLive 이미지 선택 후 크롭 편집기 |
| 관련 구현 계획 | `docs/20260803_이미지_크롭_범위_보정/plan-task.md` |
| 관련 과거 기록 | `docs/plan-task/20260317_이미지등록크롭재구현.md`, `docs/plan-task/20260317_이미지선택크롭지연및미적용수정.md` |
## 1. Overview
이미지를 선택한 뒤 표시되는 공통 `ImageCropEditorView`가 crop 비율에 맞는 원본 최대 범위를 사용하도록 보정한다. 외부 cropper 의존성은 추가하지 않고 현재 편집 흐름과 서버 전송 규격을 유지한다.
## 2. Problem Statement
- 고정 1:1 crop 영역은 canvas 최소 변의 72%로 계산되어 원본 표시 영역보다 불필요하게 작을 수 있다.
- 자유 crop 영역의 초기 크기와 최대 크기도 canvas 기준이라 원본 표시 영역과 일치하지 않는다.
- 따라서 crop 비율에 따라 원본의 가로 또는 세로 한 축 전체를 포함할 수 있어도 더 작은 영역만 잘린다.
문제 해결 여부는 세로·가로 이미지에서 현재 crop 비율을 유지하는 최대 사각형이 원본 표시 영역의 가로 또는 세로 한 축과 일치하는지로 판정한다.
## 3. Goals
- 어떤 crop 비율에서도 원본 표시 영역 안에 들어가는 최대 crop 사각형을 계산한다.
- 세로 이미지는 가능한 경우 표시된 이미지의 전체 가로를, 가로 이미지는 전체 세로를 crop 영역에 포함한다.
- 자유 crop은 최대 범위를 fitted image 기준으로 제한하면서 기존 모서리 resize 동작을 유지한다.
- 최종 crop 결과의 긴 변 최대 800px 정책과 기존 서버 multipart 경로를 유지한다.
## 4. Non-Goals
- 외부 cropper 라이브러리 추가 또는 기존 편집기 교체
- 새로운 crop 비율 선택 UI 추가
- 이미지 선택 화면, 업로드 API, multipart 필드 변경
- 회전, 반전, 원근 보정 기능 추가
## 5. 핵심 사용자 흐름
1. 사용자가 기존 화면에서 이미지를 선택한다.
2. 앱이 이미지를 정규화하고 `ImageCropEditorView`를 표시한다.
3. 편집기는 현재 crop 비율에 맞는 최대 영역을 fitted image 안에서 계산한다.
4. 사용자가 이동·확대 또는 자유 crop resize 후 적용한다.
5. 앱은 기존처럼 crop 결과를 긴 변 최대 800px로 축소해 각 ViewModel의 기존 업로드 경로로 전달한다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 |
|---|---|---|---|
| `CROP-001` | 확정 | crop 비율을 유지하는 최대 크기를 fitted image 안에서 계산한다. | 반환 rect의 가로 또는 세로가 fitted image의 같은 축과 일치하고 두 축 모두 경계를 넘지 않는다. |
| `CROP-002` | 확정 | 고정 1:1 crop에 최대 범위 계산을 적용한다. | 세로 이미지는 전체 가로, 가로 이미지는 전체 세로를 초기 crop 영역으로 사용한다. |
| `CROP-003` | 확정 | 자유 crop의 초기 영역과 resize 상한을 fitted image 기준으로 계산한다. | 초기 영역은 현재 기본 비율의 최대 크기이며 모서리 resize 결과가 fitted image 크기를 넘지 않는다. |
| `CROP-004` | 확정 | 최종 전송 이미지 크기 정책을 유지한다. | `cropImage()`가 계속 `resizedToMaxDimension(800)` 결과를 반환한다. |
| `CROP-005` | 확정 | 기존 호출부 계약을 유지한다. | `ImageCropAspectPolicy`, `onCancel`, `onComplete(UIImage)`와 5개 호출부 수정이 없다. |
## 7. 기술 제약
- iOS 16.6 이상, SwiftUI와 UIKit을 유지한다.
- geometry 계산은 UI와 분리된 순수 함수로 두어 독립 실행 검증이 가능해야 한다.
- 테스트 번들 타깃이 없는 현재 저장소 구조에서는 `swiftc`로 순수 geometry check를 실행한다.
- `Pods/**`, `generated/**`, 업로드 API는 수정하지 않는다.
## 8. 성공 기준
- [ ] 세로 fitted image `350x700`, 비율 `1:1`에서 crop 크기가 `350x350`이다.
- [ ] 가로 fitted image `400x200`, 비율 `4:3`에서 crop 크기가 약 `266.67x200`이다.
- [ ] fitted image와 crop 비율이 같으면 전체 fitted image 크기를 반환한다.
- [ ] 0 이하 크기 또는 비율은 `.zero`를 반환한다.
- [ ] 자유 crop resize 상한이 fitted image를 넘지 않는다.
- [ ] 앱 빌드가 성공하고 최종 crop 결과의 긴 변 최대 800px 정책이 유지된다.
## 9. Open Questions
해당 없음.
## 10. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 |
|---|---|---|---|---|---|
| 2026-08-03 | `DEC-001` | 확정 | 외부 라이브러리 대신 기존 cropper geometry를 보정한다. | 현재 문제는 공통 `cropSize`와 fitted image 경계 계산에서 발생하며 한 컴포넌트에서 해결 가능하다. | `CROP-001~005` |
| 2026-08-03 | `DEC-002` | 확정 | 1:1은 예시이며 모든 crop 비율에 최대 fitted 영역 규칙을 적용한다. | 사용자 확인 | `CROP-001~003` |
| 2026-08-03 | `DEC-003` | 확정 | 최종 이미지 긴 변 최대 800px 정책을 유지한다. | 서버 전송 크기를 기존과 동일하게 유지한다는 사용자 확인 | `CROP-004` |

View File

@@ -1,10 +1,11 @@
# 문서 작성 및 유지보수 정책 # 문서 작성 및 유지보수 정책
`SodaLive` 저장소에서 에이전트가 PRD, 작업 계획/TASK 문서, 가이드 문서를 작성, 갱신, 검증할 때 따르는 규칙이다. `SodaLive` 저장소에서 에이전트가 PRD, 작업 계획/TASK 문서, 코드 리뷰 리포트, 가이드 문서를 작성, 갱신, 검증할 때 따르는 규칙이다.
## 구현 전 문서 필수 절차 ## 구현 전 문서 필수 절차
- 기능 구현, 버그 수정, 동작 변경은 PRD 문서와 계획/TASK 문서 없이 시작하지 않는다. - 기능 구현, 버그 수정, 동작 변경은 PRD 문서와 계획/TASK 문서 없이 시작하지 않는다.
- 작업 순서는 `사용자 프롬프트 입력 → PRD 문서 작성 → 애매하거나 더 필요한 내용 또는 결정해야 하는 사항이 없어질 때까지 사용자 인터뷰 → 인터뷰 내용을 바탕으로 PRD 문서 보강 → PRD 문서를 바탕으로 계획/TASK 문서 작성 → 계획/TASK 문서를 바탕으로 필요한 내용 최소 구현`으로 고정한다. - 작업 순서는 `사용자 프롬프트 입력 → PRD 문서 작성 → 애매하거나 더 필요한 내용 또는 결정해야 하는 사항이 없어질 때까지 사용자 인터뷰 → 인터뷰 내용을 바탕으로 PRD 문서 보강 → PRD 문서를 바탕으로 계획/TASK 문서 작성 → 계획/TASK 문서를 바탕으로 필요한 내용 최소 구현`으로 고정한다.
- 신규 PRD와 계획/TASK 문서를 만들기 전에 `docs/sample/sample-prd.md``docs/sample/sample-plan-task.md`를 각각 확인하고 현재 샘플의 구조와 작성 규칙을 따른다.
- PRD 작성 후 요구사항, 성공 기준, 제외 범위, 제약, 미결정 사항이 남아 있으면 구현이나 계획/TASK 작성으로 넘어가지 말고 사용자에게 질문한다. - PRD 작성 후 요구사항, 성공 기준, 제외 범위, 제약, 미결정 사항이 남아 있으면 구현이나 계획/TASK 작성으로 넘어가지 말고 사용자에게 질문한다.
- 사용자 인터뷰로 확인한 결정 사항은 먼저 PRD에 반영한 뒤 계획/TASK 문서에 옮긴다. - 사용자 인터뷰로 확인한 결정 사항은 먼저 PRD에 반영한 뒤 계획/TASK 문서에 옮긴다.
- 문서 작성 자체, 커밋, 단순 조회처럼 구현을 수반하지 않는 작업은 필요한 최소 문서만 작성한다. - 문서 작성 자체, 커밋, 단순 조회처럼 구현을 수반하지 않는 작업은 필요한 최소 문서만 작성한다.
@@ -15,31 +16,48 @@
- 문서 폴더명에서 원래 띄어쓰기가 들어갈 위치는 공백 대신 `_`를 사용한다. - 문서 폴더명에서 원래 띄어쓰기가 들어갈 위치는 공백 대신 `_`를 사용한다.
- 이 경로 규칙은 신규 작성 문서부터 적용하며, 기존 과거 문서는 이동하거나 변경하지 않는다. - 이 경로 규칙은 신규 작성 문서부터 적용하며, 기존 과거 문서는 이동하거나 변경하지 않는다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- PRD 문서는 `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌해 작성한다. - PRD 문서는 `docs/sample/sample-prd.md`를 기준으로 작성하고 실제 요구사항에 맞게 모든 placeholder와 예시 ID를 구체적인 값으로 교체한다.
- PRD에는 최소한 목표, 문제/배경, 성공 기준, 제외 범위, 핵심 요구사항, 기술/운영 제약, 미결정 사항을 포함한다. 해당 없는 항목은 억지로 채우지 않고 제외하거나 `해당 없음`으로 명시한다. - PRD에는 최소한 목표, 문제/배경, 성공 기준, 제외 범위, 핵심 요구사항, 기술/운영 제약, 미결정 사항을 포함한다. 해당 없는 항목은 억지로 채우지 않고 제외하거나 `해당 없음`으로 명시한다.
## 계획/TASK 문서 규칙 ## 계획/TASK 문서 규칙
- 계획/TASK 문서는 PRD와 같은 문서 폴더 아래에 `plan-task.md`로 작성한다. - 계획/TASK 문서는 PRD와 같은 문서 폴더 아래에 `plan-task.md`로 작성한다.
- 계획/TASK 문서 경로는 `docs/[날짜]_구현할내용한글/plan-task.md` 형식을 사용한다. - 계획/TASK 문서 경로는 `docs/[날짜]_구현할내용한글/plan-task.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 계획/TASK 문서는 `docs/sample/sample-plan-task.md`를 기준으로 작성하고 모든 placeholder를 실제 경로, Goal, 명령, 기대 결과로 교체한다.
- 계획/TASK 문서는 보강 완료된 PRD를 기준으로 작성하고, 구현 범위가 PRD의 성공 기준과 제외 범위를 벗어나지 않게 한다. - 계획/TASK 문서는 보강 완료된 PRD를 기준으로 작성하고, 구현 범위가 PRD의 성공 기준과 제외 범위를 벗어나지 않게 한다.
- 연속된 하나의 작업이라면 별도 새 문서를 만들지 말고 기존 PRD와 계획/TASK 문서에 추가 작업으로 이어서 기록한다. - 연속된 하나의 작업이라면 별도 새 문서를 만들지 말고 기존 PRD와 계획/TASK 문서에 추가 작업으로 이어서 기록한다.
- 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다. - 계획/TASK 문서는 샘플과 같이 의미 단위 Phase로 나누고 Phase 아래에 독립적으로 검토 가능한 Task, Goal, 완료 증거, 선행조건을 작성한다.
-phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성하고 완료 즉시 `- [x]`로 갱신한다. -Task의 실행 항목과 완료 조건은 체크박스로 작성하고 완료 즉시 `- [x]`로 갱신한다.
- 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다. - 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다.
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다. - 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
- 작업 도중 범위가 변경되면 PRD를 먼저 보강하고 계획/TASK 문서 체크리스트를 업데이트한 뒤 구현한다. - 작업 도중 범위가 변경되면 PRD를 먼저 보강하고 계획/TASK 문서 체크리스트를 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다. - 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다. - 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
## 코드 리뷰 리포트 규칙
- 코드 리뷰와 점검을 수행하면 발견 사항 유무와 관계없이 `docs/sample/sample-review.md`를 기준으로 결과 리포트를 작성한다.
- 리뷰 리포트는 대상 PRD와 `plan-task.md`가 있는 작업 디렉터리의 `reviews/` 아래에 둔다.
- 리뷰 리포트 경로는 `docs/[날짜]_구현할내용한글/reviews/phase<번호>-<implementation-summary>.md` 형식을 사용한다.
- 파일명의 `phase<번호>`에는 리뷰 대상 Phase 번호를 쓰고, `<implementation-summary>`에는 구현 내용을 나타내는 영문 kebab-case를 쓴다. 예: `phase2-user-profile-loading.md`.
- 리포트에는 리뷰 대상과 범위, 판정 기준, 검토 근거, 실행한 검증, 발견 사항 및 후속 판정을 기록한다. 발견 사항이 없으면 `확정 발견 사항 없음`을 명시한다.
- 발견 항목은 근거와 재현 또는 검증 절차를 확인한 뒤 수정 필요 여부를 판정한다.
- 수정이 필요하다고 확정한 항목은 코드 수정 전에 `plan-task.md`의 해당 Phase에 신규 회귀 수정 Task로 추가한다.
- 신규 Task에는 리뷰 항목 ID와 리포트 링크, 수정 대상 파일, 실패 재현 또는 대체 검증, 완료 조건과 검증 명령을 포함해 바로 실행할 수 있게 작성한다.
- 수정과 검증은 추가한 Task를 기준으로 수행하고, 완료 결과는 `plan-task.md`와 리뷰 리포트에 기존 기록을 지우지 않고 누적한다.
## 문서 유지보수 규칙 ## 문서 유지보수 규칙
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다. - 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다. - 에이전트 안내 문구는 한국어 중심으로 유지한다.
- `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 섹션은 공식 원문을 영어로 유지한다. - `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 섹션은 공식 원문을 영어로 유지한다.
- 명령/경로/타깃명이 바뀌면 `AGENTS.md``docs/agent-guides/**`를 즉시 업데이트한다. - 명령/경로/타깃명이 바뀌면 `AGENTS.md``docs/agent-guides/**`를 즉시 업데이트한다.
- 신규 문서의 형식 기준은 `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md`, `docs/sample/sample-review.md`로 통일한다.
- 샘플의 경로, 구조 또는 작성 규칙이 바뀌면 `AGENTS.md`, 이 문서, 다른 가이드와 샘플 내부의 상호 참조를 같은 변경에서 갱신한다.
- 새 샘플 규칙은 신규 문서와 진행 중인 작업 문서에 적용한다. 과거 완료 문서는 별도 요청 없이 일괄 이동하거나 재작성하지 않는다.
- 문서를 갱신할 때 기존 Decision Log, Progress, 리뷰 판정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 또는 후속 기록을 누적한다.
## 문서 분리 기준 ## 문서 분리 기준
- `AGENTS.md`에는 실행 판단에 필요한 핵심 정책과 참조 경로만 남긴다. - `AGENTS.md`에는 실행 판단에 필요한 핵심 정책과 참조 경로만 남긴다.
- 문서 샘플은 `docs/sample/**`에 두고 세부 형식과 placeholder는 각 샘플에서 관리한다.
- 빌드/테스트/검증 명령은 `docs/agent-guides/build-test-verification.md`에 둔다. - 빌드/테스트/검증 명령은 `docs/agent-guides/build-test-verification.md`에 둔다.
- 코드 스타일 규칙은 `docs/agent-guides/code-style.md`에 둔다. - 코드 스타일 규칙은 `docs/agent-guides/code-style.md`에 둔다.
- 에이전트 실행 정책 상세는 `docs/agent-guides/agent-execution-policy.md`에 둔다. - 에이전트 실행 정책 상세는 `docs/agent-guides/agent-execution-policy.md`에 둔다.

View File

@@ -621,6 +621,70 @@ Expected: 현재 저장소 상태상 테스트 번들 타깃이 없으면 `Schem
- `채팅`: `ic_nav_chat_selected` / `ic_nav_chat` - `채팅`: `ic_nav_chat_selected` / `ic_nav_chat`
- `마이`: `ic_tabbar_my_selected` / `ic_nav_my` - `마이`: `ic_tabbar_my_selected` / `ic_nav_my`
### Phase 13: 메인 홈 크리에이터 생성 Floating Menu
- [x] **Task 13.1: 내 채널 생성 메뉴를 공용 컴포넌트로 이동**
- 대상 파일:
- 이동/이름 변경: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelFloatingActionMenu.swift`
- 생성 위치: `SodaLive/Sources/V2/Component/Button/CreatorContentCreationFloatingActionMenu.swift`
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 확인: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- 내 채널 홈의 `CreatorChannelFloatingActionMenu`를 두 페이지에서 재사용할 수 있는 공용 컴포넌트로 이동하고 이름을 역할 중심으로 변경한다.
- `ic_plus_no_bg`, `ic_new_upload_community_post`, `ic_new_upload_audio`, `ic_new_create_live`, `ic_new_x_black`, dim, 버튼 순서, spring animation을 변경하지 않는다.
- 내 채널 홈도 이동한 공용 컴포넌트를 사용해 기존 UI와 action을 유지한다.
- 검증 기준:
- 실행 명령: `rg "CreatorContentCreationFloatingActionMenu|ic_plus_no_bg|ic_new_upload_community_post|ic_new_upload_audio|ic_new_create_live|ic_new_x_black|interpolatingSpring\\(mass: 1, stiffness: 256, damping: 24" SodaLive/Sources/V2/Component/Button SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 기대 결과: 하나의 공용 생성 메뉴 구현을 내 채널 홈에서 사용하고, 기존 아이콘과 animation 값이 유지된다.
- [x] **Task 13.2: 메인 홈에 크리에이터 조건으로 생성 메뉴 표시**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 확인: `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- 확인: `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- 작업 내용:
- `UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue`로 크리에이터 여부를 판별한다.
- 크리에이터이고 `viewModel.currentTab == .home`일 때만 공용 생성 floating menu를 `MainView` 최상위 `ZStack`에 표시한다.
- 메인 홈 내부 `추천`, `랭킹`, `팔로잉` 전환과 관계없이 메뉴를 유지한다.
- `.content`, `.chat`, `.my`로 이동하거나 일반 사용자로 앱을 사용하면 menu/dim을 표시하지 않는다.
- 미니 플레이어와 하단 탭바가 표시될 때 겹치지 않도록 기존 safe area 구조를 따른다.
- 검증 기준:
- 실행 명령: `rg "MemberRole\\.CREATOR|viewModel\\.currentTab == \\.home|CreatorContentCreationFloatingActionMenu" SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 크리에이터와 메인 홈 탭 조건을 모두 만족할 때만 공용 생성 메뉴가 표시된다.
- [x] **Task 13.3: 기존 등록·생성 action 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 확인: `SodaLive/Sources/Live/LiveView.swift`
- 확인: `SodaLive/Sources/App/AppStep.swift`
- 작업 내용:
- 커뮤니티 게시글 등록은 기존 `AppStep.creatorCommunityWrite(onSuccess:)`로 이동한다.
- 콘텐츠 등록은 기존 `AppStep.createContent(onSuccess:)`로 이동한다.
- 라이브 생성은 기존 `AppStep.createLive(timeSettingMode: .NOW, onSuccess:)`로 이동한다.
- 라이브 생성 성공 시 내 채널 홈과 `LiveView.onCreateSuccess`의 기존 흐름처럼 라이브 메인 데이터를 갱신하고, `response.channelName`이 있으면 생성된 room에 입장한다.
- 각 action 실행 전에 floating menu를 닫는다.
- 메인 홈 전용 신규 route, 작성/생성 API, 별도 화면을 추가하지 않는다.
- 검증 기준:
- 실행 명령: `rg "creatorCommunityWrite|createContent|createLive\\(timeSettingMode: \\.NOW|getLiveMain|enterRoom|is.*ActionMenuPresented = false" SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 세 버튼이 기존 route에 연결되고 라이브 생성 성공 흐름과 menu 닫힘 처리가 확인된다.
- [x] **Task 13.4: 정적·빌드·수동 검증**
- 대상 파일/화면:
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 확인: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 확인: `SodaLive.xcworkspace`
- 수동 확인: 크리에이터/일반 사용자 메인 화면과 내 채널 홈
- 작업 내용:
- 공용 메뉴 사용처와 지정 아이콘/action을 정적 검색한다.
- `SodaLive-dev` Debug 빌드를 실행한다.
- 크리에이터 메인 홈, 일반 사용자 메인 홈, 메인 홈 외 탭, 내 채널 홈에서 표시 조건과 회귀 여부를 확인한다.
- 각 생성 버튼이 기존 등록/생성 화면으로 이동하는지 확인한다.
- 검증 기준:
- 실행 명령: `rg "CreatorContentCreationFloatingActionMenu" SodaLive/Sources/V2/Main SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/V2/Component/Button`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: 메인 홈과 내 채널 홈이 같은 공용 메뉴를 사용하고 빌드가 성공하며, 크리에이터에게만 메인 홈 메뉴가 노출된다.
## 검증 기록 ## 검증 기록
- 계획 문서 작성 단계에서는 코드 구현을 수행하지 않았다. - 계획 문서 작성 단계에서는 코드 구현을 수행하지 않았다.
@@ -651,6 +715,32 @@ Expected: 현재 저장소 상태상 테스트 번들 타깃이 없으면 `Schem
- 2026-07-08 메인 콘텐츠 탭 title bar 요구사항 반영: Figma node `24:6738` 기준으로 콘텐츠 탭 상단은 홈 탭과 같은 좌측 로고/우측 알림 버튼 구성이 아니라, 좌측에 메인 탭명 `콘텐츠`를 고정 표시하고 우측에는 `ic_bar_cash`, `ic_bar_search`, `ic_bar_storage`를 표시하도록 수정했다. - 2026-07-08 메인 콘텐츠 탭 title bar 요구사항 반영: Figma node `24:6738` 기준으로 콘텐츠 탭 상단은 홈 탭과 같은 좌측 로고/우측 알림 버튼 구성이 아니라, 좌측에 메인 탭명 `콘텐츠`를 고정 표시하고 우측에는 `ic_bar_cash`, `ic_bar_search`, `ic_bar_storage`를 표시하도록 수정했다.
- 2026-07-08 메인 콘텐츠 탭 보관함 이동 반영: `ic_bar_storage` 터치 시 기존 보관함 진입 경로인 `AppState.shared.setAppStep(step: .myBox(currentTab: .orderlist))`로 이동하도록 `MainContentView``onTapStorage` 콜백을 추가하고 `MainView`에서 연결했다. 홈 탭의 `HomeTitleBar`/`ic_bar_bell` 동작은 유지한다. - 2026-07-08 메인 콘텐츠 탭 보관함 이동 반영: `ic_bar_storage` 터치 시 기존 보관함 진입 경로인 `AppState.shared.setAppStep(step: .myBox(currentTab: .orderlist))`로 이동하도록 `MainContentView``onTapStorage` 콜백을 추가하고 `MainView`에서 연결했다. 홈 탭의 `HomeTitleBar`/`ic_bar_bell` 동작은 유지한다.
- 2026-07-08 검증: Figma `24:6738` design context와 screenshot을 확인했고, `rg`/`ast-grep`로 콘텐츠 탭은 `DefaultTitleBar(title: MainTab.content.title)``ic_bar_storage`, 홈 탭은 기존 `HomeTitleBar``ic_bar_bell`을 사용하는 것을 확인했다. 구현 직후 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -destination 'generic/platform=iOS Simulator' -configuration Debug build` 실행 결과 빌드 성공을 확인했다. - 2026-07-08 검증: Figma `24:6738` design context와 screenshot을 확인했고, `rg`/`ast-grep`로 콘텐츠 탭은 `DefaultTitleBar(title: MainTab.content.title)``ic_bar_storage`, 홈 탭은 기존 `HomeTitleBar``ic_bar_bell`을 사용하는 것을 확인했다. 구현 직후 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -destination 'generic/platform=iOS Simulator' -configuration Debug build` 실행 결과 빌드 성공을 확인했다.
- 2026-07-20 문서 보강: 크리에이터가 메인 홈에서 라이브 생성, 콘텐츠 등록, 커뮤니티 게시글 등록을 실행할 수 있도록 Phase 13을 추가했다. `CreatorChannelFloatingActionMenu`, `CreatorChannelView`의 세 action, `MemberRole.CREATOR` 역할 판별 근거를 확인해 같은 아이콘·animation·기존 `AppStep` route를 공용화하는 범위로 기록했다. 이번 작업은 문서 수정만 수행했으며 구현/빌드는 Phase 13 실행 시 검증한다.
- 2026-07-20 Phase 13 구현: `CreatorChannelFloatingActionMenu``SodaLive/Sources/V2/Component/Button/CreatorContentCreationFloatingActionMenu.swift`로 이동/이름 변경하고, 내 채널 홈과 메인 홈이 같은 공용 메뉴를 사용하도록 반영했다.
- 실행 명령: `rg -n "CreatorContentCreationFloatingActionMenu|ic_plus_no_bg|ic_new_upload_community_post|ic_new_upload_audio|ic_new_create_live|ic_new_x_black|interpolatingSpring\\(mass: 1, stiffness: 256, damping: 24" "SodaLive/Sources/V2/Component/Button" "SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift"`
- 결과: 공용 메뉴 구현과 내 채널 홈 사용처, 기존 plus/커뮤니티/오디오/라이브/닫기 아이콘 및 spring animation 값이 확인됐다.
- 실행 명령: `rg -n "MemberRole\\.CREATOR|viewModel\\.currentTab == \\.home|CreatorContentCreationFloatingActionMenu" "SodaLive/Sources/V2/Main/MainView.swift"`
- 결과: `UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue && viewModel.currentTab == .home` 조건에서만 메인 홈 생성 메뉴를 표시하는 것을 확인했다.
- 실행 명령: `rg -n "creatorCommunityWrite|createContent\\(onSuccess|createLive\\(timeSettingMode: \\.NOW|getLiveMain|enterRoom|isCreatorActionMenuPresented = false" "SodaLive/Sources/V2/Main/MainView.swift"`
- 결과: 커뮤니티 게시글 등록, 콘텐츠 등록, 즉시 라이브 생성 route 연결과 action 실행 전 메뉴 닫힘, 라이브 생성 성공 후 `getLiveMain()``enterRoom(roomId:)` 흐름을 확인했다.
- 실행 명령: `rg -n "CreatorContentCreationFloatingActionMenu" "SodaLive/Sources/V2/Main" "SodaLive/Sources/V2/CreatorChannel" "SodaLive/Sources/V2/Component/Button"`
- 결과: 공용 메뉴 구현 1개와 `MainView`, `CreatorChannelView` 사용처가 확인됐다.
- 실행 명령: `lsp_diagnostics` (`CreatorContentCreationFloatingActionMenu.swift`, `CreatorChannelView.swift`, `MainView.swift`)
- 결과: SourceKit 단독 컨텍스트 한계로 전역 프로젝트 심볼 인식 오류/timeout이 보고되어 실제 컴파일 유효성은 빌드로 판정했다.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17,OS=26.0" build`
- 결과: `** BUILD SUCCEEDED **`.
- 실행 명령: `xcrun simctl spawn "iPhone 17" defaults write kr.co.vividnext.sodalive.debug2 role CREATOR`, `token`, `isViewedOnboardingView` 설정 후 앱 launch 및 screenshot 캡처
- 결과: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sodalive-phase13-creator-main-retry.png`에서 메인 홈 하단 오른쪽 plus floating action button 표시를 확인했다.
- 정리 명령: `xcrun simctl spawn "iPhone 17" defaults delete ...``xcrun simctl shutdown "iPhone 17"`
- 결과: Phase 13 수동 검증용 시뮬레이터 기본값 제거와 시뮬레이터 종료를 완료했다.
- 실행 명령: `git diff --check`
- 결과: 공백 오류 없음.
- 실행 도구: 리뷰어 게이트 `task(category="deep")`
- 결과: 차단 결함 없음. 자동 테스트는 test action 미구성으로 실행할 수 없고, 생성 버튼 실제 탭 이동은 정적 라우팅 검토로 확인했다는 메모만 남았다.
## 커밋 정책 ## 커밋 정책

View File

@@ -29,6 +29,8 @@
- `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 페이지를 만들어 표시한다. - `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 페이지를 만들어 표시한다.
- 하단 내비게이션 아이콘은 요청된 `ic_nav_*` 계열을 사용한다. - 하단 내비게이션 아이콘은 요청된 `ic_nav_*` 계열을 사용한다.
- 현재 없는 `ic_nav_my_selected`는 임시로 `ic_tabbar_my_selected`를 사용한다. - 현재 없는 `ic_nav_my_selected`는 임시로 `ic_tabbar_my_selected`를 사용한다.
- 후속 요구사항(2026-07-20): 크리에이터 계정으로 앱을 사용할 때 메인 `홈` 탭에서 라이브 생성, 콘텐츠 등록, 커뮤니티 게시글 등록으로 진입할 수 있는 plus floating menu를 제공한다.
- 메인 `홈` 탭의 plus 아이콘, 펼침 UI, 생성 액션은 내 채널 홈 탭의 `CreatorChannelFloatingActionMenu`와 동일하게 동작한다.
--- ---
@@ -39,6 +41,8 @@
- 전역 라우팅 구조(`AppState`, `AppStep`, `ContentView`)의 대규모 개편은 하지 않는다. - 전역 라우팅 구조(`AppState`, `AppStep`, `ContentView`)의 대규모 개편은 하지 않는다.
- 미니 플레이어, 팝업, 본인인증, 딥링크 등 기존 메인 주변 기능의 동작 변경은 하지 않는다. - 미니 플레이어, 팝업, 본인인증, 딥링크 등 기존 메인 주변 기능의 동작 변경은 하지 않는다.
- 누락된 실제 이미지 리소스 `ic_nav_my_selected`를 새로 제작하거나 추가하지 않는다. - 누락된 실제 이미지 리소스 `ic_nav_my_selected`를 새로 제작하거나 추가하지 않는다.
- 라이브 생성, 콘텐츠 등록, 커뮤니티 게시글 등록 화면과 API를 새로 구현하지 않는다.
- 일반 사용자 계정에는 생성용 plus floating menu를 표시하지 않는다.
--- ---
@@ -78,6 +82,27 @@
구현 시 탭 버튼별 콘텐츠 높이가 달라도 타이틀 기준선이 흔들리지 않도록 아이콘 영역 높이와 타이틀 영역 높이를 명확히 분리한다. 구현 시 탭 버튼별 콘텐츠 높이가 달라도 타이틀 기준선이 흔들리지 않도록 아이콘 영역 높이와 타이틀 영역 높이를 명확히 분리한다.
### 5.4 크리에이터용 생성 Floating Menu
- 크리에이터 여부는 기존 앱의 역할 판별 기준인 `UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue`를 사용한다.
- 크리에이터이고 메인 하단 탭이 `.home`일 때만 생성용 plus floating menu를 표시한다.
- 메인 홈 내부의 `추천`, `랭킹`, `팔로잉` 선택과 관계없이 메인 하단 탭이 `.home`인 동안 동일한 floating menu를 유지한다.
- 일반 사용자이거나 메인 하단 탭이 `.content`, `.chat`, `.my`이면 floating menu와 dim overlay를 표시하지 않는다.
- 닫힘 상태 plus 버튼, 열림 상태 dim, 버튼 순서, spring animation은 내 채널 홈 탭의 `CreatorChannelFloatingActionMenu`와 동일하게 사용한다.
- 닫힘 상태 아이콘은 `ic_plus_no_bg`를 사용한다.
- 열림 상태의 버튼은 위에서부터 아래 순서로 다음과 같이 표시한다.
1. 커뮤니티 게시글 등록: `ic_new_upload_community_post`
2. 콘텐츠 등록: `ic_new_upload_audio`
3. 라이브 생성: `ic_new_create_live`
4. 닫기: `ic_new_x_black`
- 각 버튼은 내 채널 홈 탭과 동일한 기존 action을 사용한다.
- 커뮤니티 게시글 등록: `AppStep.creatorCommunityWrite(onSuccess:)`
- 콘텐츠 등록: `AppStep.createContent(onSuccess:)`
- 라이브 생성: `AppStep.createLive(timeSettingMode: .NOW, onSuccess:)`
- 라이브 생성 성공 시 기존 내 채널 홈과 `LiveView.onCreateSuccess`가 사용하는 라이브 메인 갱신 및 생성된 room 입장 흐름을 유지한다.
- 액션 선택, 닫기 버튼 탭, dim 영역 탭 시 menu를 닫는 동작은 내 채널 홈과 동일하게 유지한다.
- 메인 미니 플레이어와 하단 탭바가 표시될 때 floating menu가 겹치지 않도록 기존 `MainView`의 overlay와 safe area 구조 안에서 배치한다.
--- ---
## 6. Recommended Approach ## 6. Recommended Approach
@@ -114,6 +139,8 @@ SwiftUI 표준 탭 내비게이션 방식이다. 탭별 독립 상태 관리와
- `I18n.Main.Tab`에는 현재 `home`, `live`, `chat`, `my`만 있으므로 `content` 라벨을 추가해야 한다. - `I18n.Main.Tab`에는 현재 `home`, `live`, `chat`, `my`만 있으므로 `content` 라벨을 추가해야 한다.
- `ic_nav_my_selected`는 현재 에셋에 없으므로 구현 시 `ic_tabbar_my_selected`를 임시 사용한다. - `ic_nav_my_selected`는 현재 에셋에 없으므로 구현 시 `ic_tabbar_my_selected`를 임시 사용한다.
- 신규로 생성되는 메인 페이지 관련 파일은 모두 `SodaLive/Sources/V2/Main/**` 아래에 둔다. - 신규로 생성되는 메인 페이지 관련 파일은 모두 `SodaLive/Sources/V2/Main/**` 아래에 둔다.
- 두 페이지에서 재사용하는 생성 floating menu는 `SodaLive/Sources/V2/Component/Button/**` 아래 공용 컴포넌트로 이동하고, 내 채널 홈과 메인 홈이 같은 구현을 사용한다.
- 기존 `AppStep.creatorCommunityWrite`, `AppStep.createContent`, `AppStep.createLive` case와 각 작성/생성 화면은 변경하지 않는다.
--- ---
@@ -127,6 +154,10 @@ SwiftUI 표준 탭 내비게이션 방식이다. 탭별 독립 상태 관리와
- 탭 아이콘 선택/미선택 매핑이 요구사항과 일치한다. - 탭 아이콘 선택/미선택 매핑이 요구사항과 일치한다.
- `마이` 선택 아이콘은 임시로 `ic_tabbar_my_selected`를 사용한다. - `마이` 선택 아이콘은 임시로 `ic_tabbar_my_selected`를 사용한다.
- 탭 버튼 내부 콘텐츠는 가로/세로 가운데 정렬을 우선하고, 타이틀 하단 라인이 맞는다. - 탭 버튼 내부 콘텐츠는 가로/세로 가운데 정렬을 우선하고, 타이틀 하단 라인이 맞는다.
- 크리에이터 계정의 메인 `홈` 탭에는 내 채널 홈과 동일한 plus floating menu가 표시된다.
- 일반 사용자 계정과 메인 `홈` 이외 탭에는 생성용 plus floating menu가 표시되지 않는다.
- Plus 버튼을 펼치면 커뮤니티 게시글 등록, 콘텐츠 등록, 라이브 생성, 닫기 버튼이 내 채널 홈과 동일한 아이콘과 순서로 표시된다.
- 세 생성 버튼은 각각 기존 커뮤니티 게시글 등록, 콘텐츠 등록, 즉시 라이브 생성 화면으로 이동한다.
--- ---
@@ -135,6 +166,8 @@ SwiftUI 표준 탭 내비게이션 방식이다. 탭별 독립 상태 관리와
- 신규 빈 페이지는 검정 배경을 사용하고 탭명만 표시한다. - 신규 빈 페이지는 검정 배경을 사용하고 탭명만 표시한다.
- 신규로 생성되는 파일은 모두 `SodaLive/Sources/V2/Main/**` 아래에 둔다. - 신규로 생성되는 파일은 모두 `SodaLive/Sources/V2/Main/**` 아래에 둔다.
- 메인 컨테이너는 접근안 B에 따라 신규 구현 후 기존 미니 플레이어, 인증, 팝업 등 필요한 주변 기능을 이관한다. - 메인 컨테이너는 접근안 B에 따라 신규 구현 후 기존 미니 플레이어, 인증, 팝업 등 필요한 주변 기능을 이관한다.
- 크리에이터용 생성 메뉴는 내 채널 홈의 UI와 action을 복제하지 않고 공용 컴포넌트와 기존 `AppStep` route를 재사용한다.
- 메인 홈에서의 크리에이터 판별은 별도 API 없이 저장된 `MemberRole.CREATOR` 값을 사용한다.
--- ---
@@ -145,3 +178,9 @@ SwiftUI 표준 탭 내비게이션 방식이다. 탭별 독립 상태 관리와
- `SodaLive/Sources/App/AppState.swift`, `AppStep.swift`, `ContentView.swift`를 확인해 `.main` 라우팅과 전역 내비게이션 구조를 검증했다. - `SodaLive/Sources/App/AppState.swift`, `AppStep.swift`, `ContentView.swift`를 확인해 `.main` 라우팅과 전역 내비게이션 구조를 검증했다.
- `SodaLive/Resources/Assets.xcassets` 검색으로 `ic_nav_home(_selected)`, `ic_nav_content(_selected)`, `ic_nav_chat(_selected)`, `ic_nav_my`, `ic_tabbar_my_selected` 존재와 `ic_nav_my_selected` 부재를 확인했다. - `SodaLive/Resources/Assets.xcassets` 검색으로 `ic_nav_home(_selected)`, `ic_nav_content(_selected)`, `ic_nav_chat(_selected)`, `ic_nav_my`, `ic_tabbar_my_selected` 존재와 `ic_nav_my_selected` 부재를 확인했다.
- `SodaLive/Sources/I18n/I18n.swift`를 확인해 `I18n.Main.Tab.content`가 아직 없음을 확인했다. - `SodaLive/Sources/I18n/I18n.swift`를 확인해 `I18n.Main.Tab.content`가 아직 없음을 확인했다.
- 2026-07-20: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelFloatingActionMenu.swift``CreatorChannelView.swift`를 확인해 내 채널 홈의 plus 아이콘, 4개 메뉴 아이콘, spring animation, 커뮤니티 게시글 등록·콘텐츠 등록·즉시 라이브 생성 route를 확인했다.
- 2026-07-20: `SodaLive/Sources/MyPage/MyPageView.swift``SodaLive/Sources/Extensions/UserDefaultsExtension.swift`를 확인해 기존 크리에이터 역할 판별 기준이 `UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue`임을 확인했다.
## 11. Open Questions
- 해당 없음.

View File

@@ -1,106 +0,0 @@
# PRD: [제품명]
## 1. Overview
이 제품이 무엇인지 한 줄 설명
---
## 2. Problem
어떤 문제를 해결하는가?
- 현재 사용자의 불편
- 기존 방식의 한계
- 왜 지금 필요한가
---
## 3. Goals
성공 기준
예:
- 가입 전환율 20%
- 작업 시간 50% 감소
---
## 4. Non-Goals
이번에 하지 않을 것
매우 중요함.
예:
- 모바일 앱 지원 안 함
- 실시간 협업 제외
- 다국어 제외
---
## 5. Target Users
누가 사용하는가?
- 초보 개발자
- PM
- 디자이너
---
## 6. User Stories
사용자 행동 시나리오
예:
- 사용자는 버튼 하나로 요약하고 싶다
- 사용자는 로그인 없이 체험하고 싶다
---
## 7. Core Features
### Feature A
설명
#### Requirements
- must
- should
- constraints
#### Edge Cases
- 빈 입력
- timeout
- 중복 요청
---
## 8. UX / UI Expectations
- 반응속도
- 클릭 수
- 모바일 대응
- 접근성
---
## 9. Technical Constraints
- Next.js 사용
- PostgreSQL 사용
- API latency 2초 이하
---
## 10. Metrics
무엇을 측정할 것인가?
- retention
- DAU
- conversion
---
## 11. Open Questions
아직 결정 안 된 것
- OAuth 제공?
- pricing?
- offline mode?

View File

@@ -0,0 +1,318 @@
# Goal 실행형 구현 계획 샘플
> 이 문서는 goal 기능으로 구현 계획을 실행하기 위한 템플릿이다. 실제 `plan-task.md`를 만들 때 `<...>` placeholder를 모두 구체적인 값으로 교체한다. Phase는 결과와 의존성을 묶고, `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다.
| 문서 항목 | 내용 |
|---|---|
| 상태 | 계획 작성 중 / 구현 중 / 외부 조건 대기 / 구현 완료 |
| 작성일 | `YYYY-MM-DD` |
| 요구사항 기준 | `<대상 prd.md 경로>` |
| API 기준 | `<대상 api-contract.md 경로>` |
| 현재 Phase | `<Phase 번호와 이름>` |
| 현재 활성 Goal | `<없음 또는 Goal ID>` |
## 목표
`<사용자가 얻는 최종 결과를 한 문장으로 작성한다. 구현 수단보다 완결된 사용자 흐름을 먼저 쓴다.>`
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 대기 / 진행 중 / 완료 / 제외 | `0/N` | `P1-T1` | `<없음 또는 조건>` |
| 2 | 대기 / 진행 중 / 완료 / 제외 | `0/N` | `P2-T1` | `<없음 또는 조건>` |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다. 후속 수정은 회귀 수정 Task와 새 goal ID를 추가한다.
- 사용자가 명시적으로 요청하지 않으면 goal에 token budget을 설정하지 않는다.
## 범위
### 포함
- `<구현할 사용자 흐름>`
- `<구현할 route/API/UI 범위>`
- `<필수 오류·반응형·접근성·test 범위>`
### 제외
- `<이번 릴리스에서 만들지 않을 기능>`
- `<외부 시스템 또는 다음 Phase 소유 범위>`
- `<복원, hard delete처럼 명시적으로 금지한 기능>`
## 기술적 제약
- 기술 스택: `<언어, framework, 주요 library와 version 기준>`
- 아키텍처: `<상태·API·UI·파일 책임 경계>`
- 데이터·보안: `<저장 위치, 인증, 민감정보 비기록 규칙>`
- 호환성: `<지원 browser, viewport, runtime>`
- 의존성: 실제 소비 Task에서 필요한 최소 dependency만 추가한다.
- 계약: 제공되지 않은 endpoint, DTO, enum, 오류 status/key와 validation 상한을 추정하지 않는다.
- backend 구현 전 UI 확인이 필요하면 제공 계약 기반 explicit mock mode를 사용하고 실제 404 자동 fallback·production mock을 금지하며 mock/server 완료 증거를 분리한다.
- 구현: 모든 구현 Task는 아래 `RED → GREEN → REFACTOR` 순서를 체크박스에 명시하고, 가장 작은 실패 test에서 시작해 최소 구현으로 통과시킨다.
- 검증: focused test에서 시작해 영향받는 package/feature 회귀로 확장한다. 전체 회귀는 공통 경계 변경, 여러 domain/phase 변경,
release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자
명시 요청이 있을 때만 실행한다.
- 전체 회귀를 생략하면 생략 사실·근거와 대신 실행한 focused/영향 범위 회귀 명령을 Progress에 기록한다.
## Task TDD 작성 규칙
구현 Task의 실행 체크박스는 아래 순서와 표제어를 그대로 사용한다. 각 단계에는 대상 test, 실행 명령과 관찰할 실패·성공 결과를
구체적으로 적고, `RED` 확인 없이 production 구현을 시작하거나 `GREEN` 확인 없이 `REFACTOR`로 넘어가지 않는다.
- [ ] **RED:** 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** focused test를 실행해 요구 동작이 없어서 발생한 의도한 assertion 실패를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
- [ ] **REFACTOR:** 새 동작을 바꾸지 않는 범위에서 이번 Task가 만든 중복만 정리하고, focused test·직접 영향 회귀·lint를 다시 실행해 실제 결과를 Progress에 기록한다.
test 작성이 현실적으로 불가능한 read-only 리뷰·문서·외부 확인 Task는 TDD 단계를 형식적으로 만들지 않는다. 대신 Task 본문에
`TDD 예외 사유`와 실행 명령·대조표·수동 확인을 포함한 `대체 검증 방법`을 명시한다.
## Phase 1
**Phase 결과:** `<이 Phase가 끝나면 사용자가 완료할 수 있는 흐름>`
**선행조건:** `<없음 또는 선행 Goal/Gate ID>`
**Phase 완료 조건:** `P1-T1`~`P1-TN``P1-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 1.1 `<독립적으로 검토 가능한 결과>`
**Goal 실행 `P1-T1`:** `<이 Task가 만드는 결과를 한 문장으로 작성한다.>`
- **시작 조건:** `<선행 Goal, 요구사항 ID, API Contract section>`
- **완료 증거:** `<체크박스 전체, focused test, 산출물, 문서 기록>`
- **범위 밖:** `<다음 Task 또는 다른 Phase가 소유하는 항목>`
**Files:**
- Create: `<정확한 파일 경로>`
- Modify: `<정확한 파일 경로>`
- Test: `<정확한 test 파일 경로>`
**Interfaces:**
- Consumes: `<선행 Task가 제공하는 type/function/component contract>`
- Produces: `<후속 Task가 사용할 정확한 type/function/component contract>`
- [ ] **RED:** `<요구 동작>`을 재현하는 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** `<focused test 명령>`을 실행해 `<요구 동작 미구현 때문에 발생할 assertion 실패>`를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** `<focused test 명령>`을 다시 실행해 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test·직접 영향 회귀·typecheck·lint 결과를 Progress에 기록한다.
#### Task 1.2 `<두 번째 독립 결과>`
**Goal 실행 `P1-T2`:** `<이 Task가 만드는 결과를 한 문장으로 작성한다.>`
- **시작 조건:** `P1-T1` 완료와 `<필요한 계약>`.
- **완료 증거:** `<체크박스 전체, test와 문서 기록>`
- **범위 밖:** `<이 Task에서 다루지 않는 항목>`
**Files:**
- Create: `<정확한 파일 경로>`
- Modify: `<정확한 파일 경로>`
- Test: `<정확한 test 파일 경로>`
**Interfaces:**
- Consumes: `<P1-T1이 제공한 정확한 contract>`
- Produces: `<Phase 2 또는 Gate가 사용할 정확한 contract>`
- [ ] **RED:** `<두 번째 요구 동작>`의 실패 test를 작성한다.
- [ ] **RED 확인:** `<focused test 명령>`으로 의도한 assertion 실패를 확인한다.
- [ ] **GREEN:** 최소 구현으로 focused test를 통과시킨다.
- [ ] **GREEN 확인:** 오류·loading·empty·success와 접근성 상태를 포함한 focused test 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관련 test·typecheck·lint 결과를 Progress에 기록한다.
### 완료 조건
- [ ] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 모두 충족됐다.
- [ ] Phase 1의 확정 요구사항이 구현·명시적 제외·후속 결정 중 하나로 추적된다.
- [ ] 알려진 문서와 구현의 차이가 없다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** Phase 1의 사용자 흐름과 공통 품질 기준을 최종 판정한다.
- **시작 조건:** Phase 1의 모든 활성 Task goal 완료.
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
- **범위 밖:** Gate 통과를 위한 test 삭제·완화와 관련 없는 기능 수정.
```bash
<focused unit/integration test 명령>
<Phase 전용 E2E 명령>
<typecheck 명령>
<lint 명령>
<build 명령>
```
**Expected:** `<0 exit code, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>`
수동 검증:
- [ ] `<viewport와 사용자 흐름>`
- [ ] `<keyboard·focus·zoom·접근성 검사>`
- [ ] `<민감정보·network request 검증>`
## Phase 2
**Phase 결과:** `<Phase 1 결과를 소비해 완성하는 다음 사용자 흐름>`
**선행조건:** `P1-GATE` 또는 `<Phase 1의 부분 contract Goal ID>` 완료.
**Phase 완료 조건:** `P2-T1`~`P2-TN``P2-GATE` 완료, 검증 기록 누적.
### 구현 항목
#### Task 2.1 계약·상태 확인
**Goal 실행 `P2-T1`:** Phase 2 구현에 필요한 계약, 상태/action inventory와 component/file map을 확정한다.
- **시작 조건:** `<선행 Goal>` 완료, 관련 PRD/API Contract 확인.
- **완료 증거:** 계약 제공 또는 제외 결정이 기준 문서에 일치하고 구현 map이 기록됨.
- **범위 밖:** 계약을 추정한 production adapter와 실제 기능 구현.
- **TDD 예외 사유:** production 동작을 변경하지 않는 read-only 계약·상태 확인 Task다.
- **대체 검증 방법:** PRD/API Contract/현재 구현의 field·상태·파일 대조표와 누락·결정 기록을 작성하고 문서 검증 명령 결과를 Progress에 남긴다.
- [ ] 필요한 endpoint·DTO·오류·pagination 계약을 확인한다.
- [ ] loading·empty·error·success·read-only·viewport 상태와 action을 inventory한다.
- [ ] 계약이 없으면 담당 주체·영향·재개 조건과 제외/후속 결정을 문서화한다.
- [ ] Page·feature·shared component와 test file 책임을 확정한다.
#### Task 2.2 `<Phase 2의 독립 구현 결과>`
**Goal 실행 `P2-T2`:** `<사용자가 직접 확인할 수 있는 흐름을 한 문장으로 작성한다.>`
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** `<contract/UI/E2E test와 문서 기록>`
- **범위 밖:** `<다음 Task 또는 후속 Phase>`
**Files:**
- Create: `<정확한 파일 경로>`
- Modify: `<정확한 파일 경로>`
- Test: `<정확한 test 파일 경로>`
- [ ] **RED:** contract·serializer와 사용자 action의 가장 작은 실패 test를 작성한다.
- [ ] **RED 확인:** `<focused test 명령>`을 실행해 각 요구 동작이 없어 발생한 assertion 실패를 확인한다.
- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다.
- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 contract와 사용자 흐름의 성공을 확인한다.
- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관련 integration/E2E·공통 품질 명령의 실제 결과와 남은 항목을 Progress에 기록한다.
### 완료 조건
- [ ] `P2-T1`, `P2-T2`의 체크박스와 완료 증거가 모두 충족됐다.
- [ ] 외부 의존은 제공 계약 구현 또는 명시적 제외/후속 결정으로 종결됐다.
- [ ] Phase 2의 사용자 흐름과 오류·반응형·접근성 상태가 검증됐다.
### 검증 방법
#### Phase 2 Gate
**Goal 실행 `P2-GATE`:** Phase 2의 contract, 사용자 흐름과 회귀 방지를 최종 판정한다.
- **시작 조건:** Phase 2의 모든 활성 Task goal 완료.
- **완료 증거:** 아래 명령과 Expected 통과, Progress에 실제 결과 누적.
- **범위 밖:** 실패와 무관한 다음 Phase 구현.
```bash
<Phase 2 focused test 명령>
<Phase 2 E2E 명령>
<typecheck·lint·build 명령>
```
**Expected:** `<사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>`
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 없음 | 아니요 | 근거 문서 보정 |
| 2 | `P1-T2` | `P1-T1` | 아니요 | 독립 검증 계속 |
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
| 4 | `P2-T1` | `P1-GATE` | 아니요 | 외부 계약 담당·재개 조건 기록 |
| 5 | `P2-T2` | `P2-T1` | `<예/아니요>` | 독립 범위만 계속 수행 |
| 6 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패 소유 Task로 되돌림 |
```text
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE
```
## 변경 금지 항목
- 확정된 요구사항·API Contract를 근거 없이 변경하지 않는다.
- 기존 완료 체크박스와 Progress·Decision Log·검증 기록을 삭제하거나 덮어쓰지 않는다.
- 제공되지 않은 endpoint·DTO·enum·오류 값과 validation 상한을 만들지 않는다.
- 요청 범위 밖의 리팩터링, dependency 추가와 공통 abstraction을 확장하지 않는다.
- test를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과시키지 않는다.
- JWT, password, signed URL, 파일 본문 같은 민감정보를 log·fixture·문서에 기록하지 않는다.
## 의사결정 및 중단 규칙
- PRD와 API Contract가 충돌하면 프로젝트가 정한 우선순위에 따라 결정하고 Decision Log에 근거를 남긴다.
- 구현 범위가 바뀌면 `plan-task.md` 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다.
- 안전한 최소 기본값이 문서에 있으면 그 값만 구현한다. 안전하게 구현할 수 없으면 추정하지 않는다.
- 계약 미제공 기능을 제외할 때는 PRD 결정 기록 → API Contract → 이 계획 순서로 갱신한다.
- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고, 문서화나 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
- 체크박스 일부, test 일부 또는 코드 작성만 끝난 상태에서는 goal을 `complete`로 갱신하지 않는다.
- 완료 증거와 Progress 기록까지 충족한 뒤에만 goal을 `complete`로 갱신한다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### `<Goal ID>` N차 실행 — YYYY-MM-DD
- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단
- 무엇을: `<이번 실행에서 완료한 체크박스와 산출물>`
- 왜: `<Task objective와 요구사항 근거>`
- 어떻게:
- `<실행 명령>``<성공/실패, exit code, test 수와 핵심 결과>`
- `<수동 검증>``<성공/실패/불가 사유>`
- 남은 항목: `<체크박스, 외부 조건 또는 없음>`
- 다음 행동: `<같은 goal에서 이어서 할 가장 작은 단계>`
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| `YYYY-MM-DD` | `DEC-001` | 제안 / 확정 / 정정 | `<결정 내용>` | `<요구사항, 계약, 검증 근거>` | `<Goal ID와 문서>` |
- 기존 결정을 정정할 때 원문을 지우지 않고 새 `정정` 행을 추가한다.
- 외부 의존 제외, 새로운 dependency, 범위 변경, 안전한 기본값 선택은 반드시 기록한다.
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `ISSUE-001` | Blocker / High / Medium / Low | 후보 / 확정 / 보류 / 해결 | `<관찰 사실>` | `<Goal ID>` | `<수정 Task, 외부 담당 또는 없음>` |
- 구현 중 발견한 범위 내 문제는 근거와 재현 방법을 기록하고 해당 Task에서 처리한다.
- 완료된 범위의 회귀는 기존 Task를 다시 열지 않고 별도 회귀 수정 Task와 goal을 만든다.
- 범위 밖 문제는 임의로 수정하지 않고 사용자에게 보고하거나 후속 Task로 결정한다.
- 코드 리뷰와 점검 결과는 발견 사항 유무와 관계없이 `docs/sample/sample-review.md` 형식으로
`docs/[날짜]_구현할내용한글/reviews/phase<번호>-<implementation-summary>.md`에 별도 문서를 만든다.
`<implementation-summary>`에는 구현 내용을 나타내는 영문 kebab-case를 사용한다.
## 최종 보고 형식
```markdown
구현 결과: <완료한 Phase와 사용자 흐름>
- 변경: <주요 파일과 동작>
- 결정: <중요한 Decision Log ID와 내용>
- 검증:
- `<실행 명령>`<성공/실패와 핵심 수치>
- `<수동 검증>`<성공/실패/불가 사유>
- 남은 항목: <외부 의존, 후속 범위 또는 없음>
- 문서: <갱신한 PRD/API Contract/plan/review 링크>
```
최종 보고는 성공을 추정하지 않는다. 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다.

271
docs/sample/sample-prd.md Normal file
View File

@@ -0,0 +1,271 @@
# 제품 요구사항 문서(PRD) 샘플
> 이 문서는 요구사항을 API Contract와 goal 실행형 `plan-task.md`로 연결하기 위한 템플릿이다. 실제 `prd.md`를 만들 때 `<...>` placeholder와 예시 ID를 모두 제품의 구체적인 값으로 교체한다.
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 초안 / 검토 중 / 구현 기준 확정 / 구현 완료 |
| 작성일 | `YYYY-MM-DD` |
| 최종 수정일 | `YYYY-MM-DD` |
| 대상 제품 | `<제품 또는 기능 이름>` |
| 작성자·결정권자 | `<이름 또는 역할>` |
| 관련 API Contract | `<대상 api-contract.md 경로>` |
| 관련 구현 계획 | `<대상 plan-task.md 경로>` |
| 관련 review | `<없음 또는 같은 작업 디렉터리의 reviews/ 아래 문서 링크>` |
### 요구사항 상태
| 상태 | 의미 | 구현 처리 |
|---|---|---|
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
| 미결 | 제품·UX·운영 결정이 더 필요함 | 권고안과 결정 주체·기한을 기록하고 임의 구현 금지 |
| 외부 의존 | 프론트엔드 밖의 계약·권한·환경 제공이 필요함 | 담당 주체·영향·재개 조건을 기록하고 추정 구현 금지 |
| 권고 | 미결 항목에 대한 현재 추천안 | 확정되기 전 계약이나 수용 기준으로 사용하지 않음 |
| 제외 | 현재 릴리스에서 구현하지 않기로 결정 | 제외 이유와 후속 조건을 Decision Log에 기록 |
### 문서 우선순위와 갱신 순서
1. 사용자·제품 결정은 이 PRD에 기록한다.
2. request/response/error 계약은 `api-contract.md`에 정규화한다.
3. 구현 범위·순서·완료 증거는 `plan-task.md`에 반영한다.
4. 요구사항이 바뀌면 Decision Log → 관련 요구사항·수용 기준 → API Contract → plan 순서로 갱신한다.
5. 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 기록을 누적한다.
## 1. Overview
`<누가 어떤 상황에서 어떤 가치를 얻는 제품인지 2~4문장으로 설명한다.>`
## 2. Problem Statement
현재 사용자는 다음 문제를 겪는다.
- `<관찰 가능한 현재 문제>`
- `<기존 방식의 비용·위험·제약>`
- `<해결하지 않을 때의 사용자 또는 사업 영향>`
문제를 해결했다는 판단은 `<측정하거나 직접 확인할 결과>`로 한다.
## 3. Goals
### 3.1 제품 목표
- `<사용자가 완료할 수 있어야 하는 핵심 결과>`
- `<안전성·운영 효율·데이터 품질 목표>`
- `<릴리스 후 측정 가능한 성공 목표>`
### 3.2 UX 목표
- `<핵심 흐름의 명확성·속도 목표>`
- `<오류·loading·empty·success feedback 목표>`
- `<반응형·keyboard·접근성 목표>`
## 4. Non-Goals
- `<명시적으로 구현하지 않을 기능>`
- `<다음 릴리스 또는 외부 시스템 소유 범위>`
- `<복원, hard delete, 자동 갱신처럼 금지할 동작>`
Non-Goal을 변경하려면 Decision Log와 `plan-task.md` 범위를 먼저 갱신한다.
## 5. Target Users and Permissions
### 5.1 사용자
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| `<역할>` | `<달성 목표>` | `<조회·생성·수정 등>` | `<desktop/mobile 등>` |
### 5.2 권한
- 인증 주체: `<사용자 또는 시스템>`
- 허용 역할: `<role 목록>`
- 거부 조건: `<401/403 또는 제품 정책>`
- 리소스 소유권: `<tenant, characterId, ownerId 등 격리 기준>`
- read-only 조건: `<비활성·권한 부족·외부 상태 등>`
## 6. 핵심 사용자 흐름
1. `<시작 조건과 진입점>`
2. `<대상 탐색·선택>`
3. `<핵심 생성·조회·수정 동작>`
4. `<성공 feedback과 다음 화면>`
5. `<오류·권한·session 만료 복구>`
각 흐름은 `plan-task.md`의 최소 하나의 Phase 결과와 E2E 완료 증거로 연결한다.
## 7. 정보 구조와 라우팅
```text
/<entry>
/<resource>
/<resource>/:resourceId
/<child-resource>
```
- 전역 화면과 선택된 리소스 문맥의 경계를 명시한다.
- URL path와 query에 보존할 식별자·검색·filter·page 상태를 명시한다.
- 직접 링크·새로고침이 가능한 화면과 collection modal/Sheet처럼 별도 route가 없는 화면을 구분한다.
- 존재하지 않음, 다른 소유자, 비활성 리소스의 처리는 서버 계약과 공통 오류 정책을 따른다.
## 8. 기능 요구사항
요구사항 ID는 `<DOMAIN>-NNN` 형식을 사용한다. 하나의 행에는 독립적으로 판정 가능한 요구사항 하나만 작성한다.
### 8.1 `<도메인 A>`
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DOMAINA-001` | 확정 | `<사용자가 할 수 있어야 하는 동작>` | `<관찰 가능한 성공·오류 결과>` | `api-contract.md §<번호>`, `P1-T1` |
| `DOMAINA-002` | 확정 | `<payload·상태·권한 불변식>` | `<보내야/보내지 말아야 할 값과 test>` | `P1-T2` |
| `DOMAINA-003` | 외부 의존 | `<외부 제공이 필요한 계약>` | `<제공 전 network integration 0건>` | `EXT-001`, `P1-T1` |
### 8.2 `<도메인 B>`
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DOMAINB-001` | 확정 | `<목록·상세·mutation 흐름>` | `<loading·empty·error·success 포함>` | `api-contract.md §<번호>`, `P2-T2` |
| `DOMAINB-002` | 미결 | `<제품 결정이 필요한 항목>` | `<확정 전 최대값·동작 추정 금지>` | `OQ-001` |
### 8.3 공통 파일·데이터 정책
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `FILE-001` | 확정 | `<허용 확장자·MIME·크기>` | `<정확한 byte 경계 test>` | `api-contract.md §<번호>`, `<Goal ID>` |
| `DATA-001` | 확정 | `<날짜·가격·enum·pagination 규칙>` | `<formatter/schema/contract test>` | `<Goal ID>` |
## 9. 반응형 기능 범위
| 기능 | Desktop | Tablet | Mobile | 비고 |
|---|---:|---:|---:|---|
| 조회 | 전체 | 전체 | 전체 | `<예외>` |
| 생성·수정 | 허용 | 허용 | 허용 / 조회 전용 | `<직접 route 차단 정책>` |
| 파일 upload | 허용 | 허용 | 허용 / 미지원 | `<이유>` |
- 화면에서 action을 숨기는 것뿐 아니라 직접 route와 mutation capability도 같은 정책으로 차단한다.
- 최소 viewport, zoom, touch target과 virtual keyboard 조건을 수용 기준에 연결한다.
## 10. UI/UX Expectations
### 10.1 디자인과 component 원칙
- `<브랜드 token, theme 범위, typography>`
- Page는 route·query·permission·component 조합을 담당한다.
- 도메인 표시·입력·상호작용은 feature component가 담당한다.
- 두 개 이상 Phase에서 같은 의미·동작으로 재사용할 때만 shared component로 올린다.
### 10.2 화면 상태
- 모든 비동기 화면에 loading·empty·error·success 상태를 정의한다.
- mutation에는 진행 중·성공·실패·재시도·중복 제출 정책을 정의한다.
- destructive action에는 대상·영향·복구 여부를 알리는 확인 절차를 둔다.
### 10.3 접근성
- 모든 input은 visible label과 연결된 오류를 가진다.
- keyboard-only 흐름, focus 표시·복귀, skip link와 live region 기준을 명시한다.
- 색상만으로 상태를 전달하지 않고 목표 대비와 touch target을 명시한다.
- `<지원 viewport>`, 200% zoom과 axe critical·serious 0건을 수용 기준으로 사용한다.
## 11. API 계약
### 11.1 공통 규칙
- base URL과 인증 header: `<값>`
- locale: `<header와 값>`
- 성공 envelope: `<type 또는 예시>`
- 오류 envelope와 status: `<type 또는 예시>`
- pagination: `<page/size/total/hasNext 규칙>`
- multipart JSON part: `<이름>`
### 11.2 Endpoint 추적
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|---|---|---|---|---|---|
| `DOMAINA-001` | `<METHOD>` | `<path>` | 제공됨 / 보정 필요 / 제공 대기 | `§<번호>` | `<Goal ID>` |
### 11.3 외부 제공 대기 계약
| ID | 우선순위 | 제공 필요 계약 | 담당 주체 | 구현 영향 | 재개 조건 |
|---|---:|---|---|---|---|
| `EXT-001` | P0 / P1 | `<endpoint·DTO·오류 규칙>` | `<팀/역할>` | `<차단되는 흐름>` | `<문서와 fixture 제공>` |
- P0 계약이 없으면 영향을 받는 network flow를 완료로 표시하지 않는다.
- 계약에 의존하지 않는 UI shell·상태 inventory·문서화는 독립적으로 진행할 수 있다.
## 12. 보안과 데이터 취급
- 인증 정보 저장 위치와 lifecycle: `<정확한 정책>`
- log·분석·오류 리포트 금지 값: `<token, password, signed URL 등>`
- 업로드 파일명·MIME·본문 취급: `<정책>`
- 리소스 격리와 ownership 검증: `<정책>`
- 401·403·동시 실패 처리: `<정책>`
- 감사 로그: `<포함, 외부 의존 또는 후속 범위>`
## 13. 성능과 품질 요구사항
- 목록 pagination과 전체 로드 예외: `<정책>`
- 검색 debounce와 기존 데이터 유지: `<정책>`
- image layout shift와 lazy loading: `<정책>`
- mutation 중복 제출·upload 취소/재시도: `<정책>`
- 지원 runtime·browser: `<정확한 범위>`
- test stack과 필수 Gate: `<unit/integration/E2E/typecheck/lint/build>`
- backend 구현 전 UI 확인: `<불필요 또는 explicit mock mode, production 금지, no-auto-fallback, mock/server 완료 상태 분리>`
## 14. 성공 기준
### 14.1 기능 수용 기준
- [ ] `<핵심 사용자 journey가 성공한다.>` (`DOMAINA-001`, `P1-GATE`)
- [ ] `<권한·오류·payload 불변식이 검증된다.>` (`DOMAINA-002`, `<test>`)
- [ ] `<외부 의존의 구현 또는 제외 결정이 문서화된다.>` (`EXT-001`)
### 14.2 UI/UX 수용 기준
- [ ] loading·empty·error·success 상태가 있다.
- [ ] keyboard-only로 핵심 흐름을 완료한다.
- [ ] `<최소 viewport>`와 200% zoom에서 핵심 control이 가려지지 않는다.
- [ ] axe critical·serious 위반이 0건이다.
### 14.3 추적성 완료 기준
- [ ] 모든 `확정` 요구사항이 API Contract와 하나 이상의 Task/Goal 완료 증거로 연결된다.
- [ ] 모든 `미결` 항목에 결정 주체와 다음 행동이 있다.
- [ ] 모든 `외부 의존` 항목에 담당 주체·영향·재개 조건이 있다.
- [ ] `제외` 항목에 Decision Log와 후속 조건이 있다.
## 15. Open Questions
| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 기한/시점 | 영향 Goal |
|---|---|---|---|---|---|---|
| `OQ-001` | 미결 | `<질문>` | `<권고안 또는 없음>` | `<역할>` | `<날짜 또는 UI 작성 후>` | `<Goal ID>` |
- Open Question과 외부 의존을 혼합하지 않는다. 제품이 결정할 수 없는 backend 계약은 `EXT-*`로 관리한다.
- 미결 값을 임의의 상수·enum·endpoint로 구현하지 않는다.
## 16. 요구사항 추적표
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---|---:|---|---|---|
| `DOMAINA-001~003` | `§<번호>` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | `<test 경로>` | `<흐름>` |
| `DOMAINB-001~002` | `§<번호 또는 제공 대기>` | 2 | `P2-T1`, `P2-T2`, `P2-GATE` | `<test 경로>` | `<흐름>` |
## 17. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| `YYYY-MM-DD` | `DEC-001` | 확정 / 정정 / 폐기 | `<결정 내용>` | `<인터뷰·계약·검증 근거>` | `<ID와 문서 section>` |
- 기존 결정을 수정할 때 원문을 지우지 않고 `정정` 행을 추가한다.
- 범위 변경, Non-Goal 변경, 외부 의존 제외, 안전한 기본값과 주요 기술 선택을 기록한다.
## 18. 변경 관리
요구사항 변경 시 다음을 확인한다.
- [ ] Decision Log에 변경 이유와 날짜를 기록했다.
- [ ] 관련 요구사항 상태·본문·수용 기준을 갱신했다.
- [ ] API Contract의 request/response/error와 fixture를 갱신했다.
- [ ] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신했다.
- [ ] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않았다.

View File

@@ -0,0 +1,191 @@
# 코드 리뷰 보고서 샘플
> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다.
> 실제 리뷰 문서는 대상 PRD·`plan-task.md`와 같은 작업 디렉터리의 `reviews/` 아래에
> `docs/[날짜]_구현할내용한글/reviews/phase<번호>-<implementation-summary>.md` 형식으로 저장한다.
> `<implementation-summary>`에는 구현 내용을 나타내는 영문 kebab-case를 사용한다.
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase `<번호>` / Task `<번호 또는 범위>` |
| 기준 commit 또는 working tree | `<commit SHA 또는 변경 상태>` |
| 리뷰 일자 | `YYYY-MM-DD` |
| 리뷰어 | `<이름 또는 agent>` |
| 기준 문서 | `<대상 prd.md, api-contract.md, plan-task.md 경로>` |
| 리뷰 상태 | 진행 중 / 판정 완료 / 수정 검증 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `<예: Phase 0~1 구현이 요구사항과 API Contract를 충족하는지 확인한다.>`
- `<예: 완료 체크박스와 실제 코드·test·검증 기록이 일치하는지 확인한다.>`
### 포함 범위
- 코드: `<검토할 경로>`
- 테스트: `<검토할 unit/integration/E2E 경로>`
- 문서: `<검토할 요구사항·계약·Task 범위>`
- 수동 검증: `<브라우저, viewport, keyboard, 접근성 등>`
### 제외 범위
- `<이번 리뷰에서 다루지 않는 Phase, 기능 또는 외부 계약>`
## 3. 판정 기준
### 심각도
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
### 상태
| 상태 | 의미 | 후속 처리 |
|---|---|---|
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 |
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `<예: AUTH-001~013>`
- API Contract: `<예: §3 인증>`
- 계획: `<예: P1-T2, P1-T3, P1-GATE>`
- 코드: `<파일 경로와 line>`
- 테스트: `<테스트 파일과 test name>`
### 실행 환경
```text
OS: <값>
Node: <값>
npm: <값>
Browser/viewport: <값>
환경 변수: 민감정보를 제외한 이름과 사용 mode만 기록
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `<실제 실행 명령>` | 성공 / 실패 / 불가 | `<exit code, test 수, 오류 또는 불가 사유>` |
| `<수동 검증 절차>` | 성공 / 실패 / 불가 | `<관찰 결과>` |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-001` | `<심각도>` | 후보 | `<한 문장 제목>` | `<예: P1-T3>` | 판정 전 |
발견 사항이 없으면 “확정 발견 사항 없음”이라고 명시하고, 검토 범위와 실행 증거는 그대로 남긴다.
## 6. 발견 사항 상세
### REV-P1-001 — `<한 문장 제목>`
- **심각도:** `<Blocker | High | Medium | Low>`
- **상태:** `<후보 | 확정 | 오탐 | 보류 | 수정 완료>`
- **관련 요구사항:** `<요구사항 ID 또는 없음>`
- **관련 계약:** `<api-contract.md section 또는 없음>`
- **소유 Task:** `<기존 Goal ID 또는 신규 회귀 Task>`
**관찰 내용**
`<실제로 관찰한 동작을 추정 없이 작성한다.>`
**근거**
- 코드: `<파일 경로:line과 관련 동작>`
- 테스트: `<테스트 파일:test name 또는 누락 사실>`
- 문서: `<문서 경로와 요구사항/계약/계획 항목>`
**재현 또는 검증 절차**
1. `<사전 조건>`
2. `<실행 명령 또는 사용자 동작>`
3. `<실제 결과>`
4. `<요구되는 결과>`
**영향**
`<사용자, 데이터, 보안, 접근성, 회귀 범위를 구체적으로 작성한다.>`
**권장 조치**
`<최소 수정 방향과 추가해야 할 회귀 test를 작성한다. 구현 전 확정되지 않은 endpoint·DTO·오류 값은 추정하지 않는다.>`
**판정 기록**
- `YYYY-MM-DD``<확정/오탐/보류 판정과 근거>`
- `YYYY-MM-DD``<후속 정정이 있으면 기존 기록을 지우지 않고 추가>`
## 7. 확정 항목의 plan·goal 전환
확정 발견 사항이 없으면 이 절에 “전환 항목 없음”을 기록한다. 확정 항목이 있으면 구현 전에 `plan-task.md`의 해당 Phase에 아래 내용을 신규 Task로 반영한다.
### 신규 회귀 수정 Task 초안
```markdown
#### Task <Phase 번호>.<다음 순번> <수정할 결과>
**Goal 실행 `P<Phase>-R<번호>`:** <확정된 문제를 수정하고 회귀를 방지하는 문장 objective>
- **시작 조건:** <관련 review ID, 기존 Task/Gate, 필요한 계약>
- **완료 증거:** <실패 재현 test 수정 focused test Phase Gate 검증 기록>
- **범위 밖:** <이번 수정에서 건드리지 않을 기능>
- **Files:** <생성·수정·테스트할 정확한 파일 경로>
- **Review:** < 리포트 경로와 관련 review ID>
- [ ] `<재현 가능한 실패 test를 먼저 추가하고 의도한 assertion 실패를 확인한다.>`
- [ ] `<최소 수정으로 test를 통과시킨다.>`
- [ ] `<관련 focused test와 Phase Gate를 실행한다.>`
- [ ] `<plan-task.md 하단에 무엇을/왜/어떻게를 누적한다.>`
```
### create_goal objective 초안
```text
[P<Phase>-R<번호>]의 확정 review 항목 <REV-ID 목록>을 수정하고 회귀를 방지한다.
plan-task.md에 추가된 회귀 수정 Task만 수행한다.
실패 재현, 최소 수정, focused test, Phase Gate와 검증 기록이 모두 끝나기 전에는 complete로 표시하지 않는다.
관련 없는 리팩터링과 계약 추정은 범위 밖이다.
```
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 / 미충족 | `<근거>` |
| 후보 항목 판정 완료 | 충족 / 미충족 | `<근거>` |
| 확정 항목 plan 반영 | 충족 / 해당 없음 / 미충족 | `<Task 또는 사유>` |
| 보류 항목의 담당·재개 조건 기록 | 충족 / 해당 없음 / 미충족 | `<근거>` |
| 검증 명령과 결과 기록 | 충족 / 미충족 | `<근거>` |
**최종 결론:** `<확정 발견 사항 없음 | 수정 goal 필요 | 외부 조건 대기 | 수정 검증 완료>`
**남은 항목:** `<없음 또는 review ID와 다음 행동>`
## 9. 수정 후 검증 기록
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
### N차 수정 검증 — YYYY-MM-DD
- 무엇을: `<수정한 review ID와 결과>`
- 왜: `<요구사항·계약 위반 또는 회귀 위험>`
- 어떻게:
- `<실행 명령>``<성공/실패와 핵심 수치>`
- `<수동 검증>``<성공/실패/불가 사유>`
- 남은 항목: `<없음, 보류 또는 후속 review ID>`

View File

@@ -0,0 +1,40 @@
import CoreGraphics
import Foundation
@main
struct ImageCropGeometryCheck {
static func main() {
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 350, height: 700), aspectRatio: 1),
equals: CGSize(width: 350, height: 350),
scenario: "portraitUsesFullWidth"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 400, height: 200), aspectRatio: 4.0 / 3.0),
equals: CGSize(width: 800.0 / 3.0, height: 200),
scenario: "landscapeUsesFullHeight"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 320, height: 180), aspectRatio: 16.0 / 9.0),
equals: CGSize(width: 320, height: 180),
scenario: "matchingRatioUsesWholeImage"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: .zero, aspectRatio: 1),
equals: .zero,
scenario: "invalidInputReturnsZero"
)
assertSize(
ImageCropGeometry.maximumCropSize(fitting: CGSize(width: 100, height: 100), aspectRatio: 0),
equals: .zero,
scenario: "invalidRatioReturnsZero"
)
print("ImageCropGeometryCheck PASS")
}
private static func assertSize(_ actual: CGSize, equals expected: CGSize, scenario: String) {
let tolerance = 0.001
precondition(abs(actual.width - expected.width) < tolerance, "\(scenario) width: \(actual.width)")
precondition(abs(actual.height - expected.height) < tolerance, "\(scenario) height: \(actual.height)")
}
}