9 Commits

44 changed files with 10399 additions and 222 deletions

2
.gitignore vendored
View File

@@ -259,7 +259,6 @@ iOSInjectionProject/
### SwiftPackageManager ### ### SwiftPackageManager ###
Packages Packages
xcuserdata xcuserdata
*.xcodeproj
### SwiftPM ### ### SwiftPM ###
@@ -279,5 +278,6 @@ xcuserdata
.kiro/ .kiro/
.junie/ .junie/
.omo/
# End of https://www.toptal.com/developers/gitignore/api/macos,xcode,appcode,swift,swiftpackagemanager,swiftpm,fastlane,cocoapods # End of https://www.toptal.com/developers/gitignore/api/macos,xcode,appcode,swift,swiftpackagemanager,swiftpm,fastlane,cocoapods

View File

@@ -98,6 +98,8 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
### 수정 우선순위 ### 수정 우선순위
- 기능 변경은 `SodaLive/Sources/**`에서 해결한다. - 기능 변경은 `SodaLive/Sources/**`에서 해결한다.
- 기존 로직 수정이 아닌 신규 `View`, `ViewModel`, `Repository` 및 그와 연결된 하위 코드는 `SodaLive/Sources/V2/**` 아래에 작성한다. - 기존 로직 수정이 아닌 신규 `View`, `ViewModel`, `Repository` 및 그와 연결된 하위 코드는 `SodaLive/Sources/V2/**` 아래에 작성한다.
- 여러 페이지에서 재사용 가능한 공용 컴포넌트는 `SodaLive/Sources/V2/Component/**` 아래에 형태별 폴더(`Card`, `Banner`, `Text`, `Button`, `Creator` 등)로 배치한다.
- 특정 페이지 내부에서만 사용하는 컴포넌트는 해당 페이지 폴더 하위 `Components`에 배치한다.
- 프로젝트 설정 변경은 필요한 경우에만 수행한다. - 프로젝트 설정 변경은 필요한 경우에만 수행한다.
- `Pods/**`, `generated/**`는 직접 수정하지 않는다. - `Pods/**`, `generated/**`는 직접 수정하지 않는다.
- `build/**`는 빌드 산출물로 간주하며 수정 대상이 아니다. - `build/**`는 빌드 산출물로 간주하며 수정 대상이 아니다.
@@ -149,6 +151,8 @@ 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와 `plan-task.md`에 이어서 기록한다.
- 계획/TASK 문서는 phase heading(`### Phase N: ...`)과 단계별 체크박스(`- [ ] **Task N.N: ...**`)를 사용하고, 각 task에는 대상 파일 경로와 검증 기준을 함께 적는다.
## 문서 유지보수 규칙 ## 문서 유지보수 규칙
- 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다. - 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "40FE0E9E2A8362A200252293"
BuildableName = "SodaLive-dev.app"
BlueprintName = "SodaLive-dev"
ReferencedContainer = "container:SodaLive.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "40FE0E9E2A8362A200252293"
BuildableName = "SodaLive-dev.app"
BlueprintName = "SodaLive-dev"
ReferencedContainer = "container:SodaLive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "40FE0E9E2A8362A200252293"
BuildableName = "SodaLive-dev.app"
BlueprintName = "SodaLive-dev"
ReferencedContainer = "container:SodaLive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "40FE0E832A8361DF00252293"
BuildableName = "SodaLive.app"
BlueprintName = "SodaLive"
ReferencedContainer = "container:SodaLive.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "40FE0E832A8361DF00252293"
BuildableName = "SodaLive.app"
BlueprintName = "SodaLive"
ReferencedContainer = "container:SodaLive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "40FE0E832A8361DF00252293"
BuildableName = "SodaLive.app"
BlueprintName = "SodaLive"
ReferencedContainer = "container:SodaLive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,294 +1,303 @@
{ {
"originHash" : "cf552e0db687218f4a2207a39678af43731c56f6f8ea12b111a15ac39574aa38", "originHash": "1f28da3687662a2a9efe60ffc2ca2499be411b5b0a1e07f72559059c40728121",
"pins" : [ "pins": [
{ {
"identity" : "abseil-cpp-binary", "identity": "abseil-cpp-binary",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git", "location": "https://github.com/google/abseil-cpp-binary.git",
"state" : { "state": {
"revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", "revision": "194a6706acbd25e4ef639bcaddea16e8758a3e27",
"version" : "1.2024011602.0" "version": "1.2024011602.0"
} }
}, },
{ {
"identity" : "agorainfra_ios", "identity": "agorainfra_ios",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/AgoraIO/AgoraInfra_iOS.git", "location": "https://github.com/AgoraIO/AgoraInfra_iOS.git",
"state" : { "state": {
"revision" : "0071cecee99160f056c943ee6bfca06685ffa3d7", "revision": "0071cecee99160f056c943ee6bfca06685ffa3d7",
"version" : "1.3.0" "version": "1.3.0"
} }
}, },
{ {
"identity" : "agorartcengine_ios", "identity": "agorartcengine_ios",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/AgoraIO/AgoraRtcEngine_iOS.git", "location": "https://github.com/AgoraIO/AgoraRtcEngine_iOS.git",
"state" : { "state": {
"revision" : "c1224ad3e316db15a4ac48a6ff08208f7bcf5238", "revision": "c1224ad3e316db15a4ac48a6ff08208f7bcf5238",
"version" : "4.6.0" "version": "4.6.0"
} }
}, },
{ {
"identity" : "alamofire", "identity": "alamofire",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git", "location": "https://github.com/Alamofire/Alamofire.git",
"state" : { "state": {
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", "revision": "513364f870f6bfc468f9d2ff0a95caccc10044c5",
"version" : "5.10.2" "version": "5.10.2"
} }
}, },
{ {
"identity" : "app-check", "identity": "app-check",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/app-check.git", "location": "https://github.com/google/app-check.git",
"state" : { "state": {
"revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", "revision": "3b62f154d00019ae29a71e9738800bb6f18b236d",
"version" : "10.19.2" "version": "10.19.2"
} }
}, },
{ {
"identity" : "appsflyerframework-static", "identity": "appsflyerframework-static",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/AppsFlyerSDK/AppsFlyerFramework-Static", "location": "https://github.com/AppsFlyerSDK/AppsFlyerFramework-Static",
"state" : { "state": {
"revision" : "1741d025d5bdd8a64c42854ba0fcfd7f768e4594", "revision": "1741d025d5bdd8a64c42854ba0fcfd7f768e4594",
"version" : "6.17.5" "version": "6.17.5"
} }
}, },
{ {
"identity" : "facebook-ios-sdk", "identity": "facebook-ios-sdk",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/facebook/facebook-ios-sdk.git", "location": "https://github.com/facebook/facebook-ios-sdk.git",
"state" : { "state": {
"revision" : "a77ba210bf6534564ad4027fce2fef65babfadf8", "revision": "a77ba210bf6534564ad4027fce2fef65babfadf8",
"version" : "18.0.1" "version": "18.0.1"
} }
}, },
{ {
"identity" : "firebase-ios-sdk", "identity": "firebase-ios-sdk",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk.git", "location": "https://github.com/firebase/firebase-ios-sdk.git",
"state" : { "state": {
"revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", "revision": "eca84fd638116dd6adb633b5a3f31cc7befcbb7d",
"version" : "10.29.0" "version": "10.29.0"
} }
}, },
{ {
"identity" : "googleappmeasurement", "identity": "googleappmeasurement",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location": "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state": {
"revision" : "fe727587518729046fc1465625b9afd80b5ab361", "revision": "fe727587518729046fc1465625b9afd80b5ab361",
"version" : "10.28.0" "version": "10.28.0"
} }
}, },
{ {
"identity" : "googledatatransport", "identity": "googledatatransport",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location": "https://github.com/google/GoogleDataTransport.git",
"state" : { "state": {
"revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", "revision": "a637d318ae7ae246b02d7305121275bc75ed5565",
"version" : "9.4.0" "version": "9.4.0"
} }
}, },
{ {
"identity" : "googleutilities", "identity": "googleutilities",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location": "https://github.com/google/GoogleUtilities.git",
"state" : { "state": {
"revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", "revision": "57a1d307f42df690fdef2637f3e5b776da02aad6",
"version" : "7.13.3" "version": "7.13.3"
} }
}, },
{ {
"identity" : "grpc-binary", "identity": "grpc-binary",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git", "location": "https://github.com/google/grpc-binary.git",
"state" : { "state": {
"revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", "revision": "e9fad491d0673bdda7063a0341fb6b47a30c5359",
"version" : "1.62.2" "version": "1.62.2"
} }
}, },
{ {
"identity" : "gtm-session-fetcher", "identity": "gtm-session-fetcher",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git", "location": "https://github.com/google/gtm-session-fetcher.git",
"state" : { "state": {
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", "revision": "a2ab612cb980066ee56d90d60d8462992c07f24b",
"version" : "3.5.0" "version": "3.5.0"
} }
}, },
{ {
"identity" : "interop-ios-for-google-sdks", "identity": "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", "location": "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : { "state": {
"revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", "revision": "2d12673670417654f08f5f90fdd62926dc3a2648",
"version" : "100.0.0" "version": "100.0.0"
} }
}, },
{ {
"identity" : "kakao-ios-sdk", "identity": "kakao-ios-sdk",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/kakao/kakao-ios-sdk", "location": "https://github.com/kakao/kakao-ios-sdk",
"state" : { "state": {
"revision" : "5978979157a5a0521c9c56fd0156aec794caa21c", "revision": "5978979157a5a0521c9c56fd0156aec794caa21c",
"version" : "2.27.2" "version": "2.27.2"
} }
}, },
{ {
"identity" : "kingfisher", "identity": "kingfisher",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git", "location": "https://github.com/onevcat/Kingfisher.git",
"state" : { "state": {
"revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", "revision": "2ef543ee21d63734e1c004ad6c870255e8716c50",
"version" : "7.12.0" "version": "7.12.0"
} }
}, },
{ {
"identity" : "leveldb", "identity": "leveldb",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git", "location": "https://github.com/firebase/leveldb.git",
"state" : { "state": {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", "revision": "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5" "version": "1.22.5"
} }
}, },
{ {
"identity" : "line-sdk-ios-swift", "identity": "line-sdk-ios-swift",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/line/line-sdk-ios-swift.git", "location": "https://github.com/line/line-sdk-ios-swift.git",
"state" : { "state": {
"revision" : "51ef2ebefb05db8f748e80208b3281ca723abcdb", "revision": "51ef2ebefb05db8f748e80208b3281ca723abcdb",
"version" : "5.14.0" "version": "5.14.0"
} }
}, },
{ {
"identity" : "moya", "identity": "moya",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/Moya/Moya.git", "location": "https://github.com/Moya/Moya.git",
"state" : { "state": {
"revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", "revision": "c263811c1f3dbf002be9bd83107f7cdc38992b26",
"version" : "15.0.3" "version": "15.0.3"
} }
}, },
{ {
"identity" : "nanopb", "identity": "nanopb",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git", "location": "https://github.com/firebase/nanopb.git",
"state" : { "state": {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", "revision": "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0" "version": "2.30910.0"
} }
}, },
{ {
"identity" : "notifly-ios-sdk", "identity": "notifly-ios-sdk",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/team-michael/notifly-ios-sdk", "location": "https://github.com/team-michael/notifly-ios-sdk",
"state" : { "state": {
"revision" : "406d95344b7adb71adcc9e46960fec50d608e576", "revision": "406d95344b7adb71adcc9e46960fec50d608e576",
"version" : "1.17.3" "version": "1.17.3"
} }
}, },
{ {
"identity" : "objectbox-swift-spm", "identity": "objectbox-swift-spm",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/objectbox/objectbox-swift-spm", "location": "https://github.com/objectbox/objectbox-swift-spm",
"state" : { "state": {
"branch" : "main", "branch": "main",
"revision" : "28c3261c9836cd3f4d64ab6419a3628d2b167811" "revision": "28c3261c9836cd3f4d64ab6419a3628d2b167811"
} }
}, },
{ {
"identity" : "promises", "identity": "popupview",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/google/promises.git", "location": "https://github.com/exyte/PopupView.git",
"state" : { "state": {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", "revision": "1b99d6e9872ef91fd57aaef657661b5a00069638",
"version" : "2.4.0" "version": "1.3.1"
} }
}, },
{ {
"identity" : "reactiveswift", "identity": "promises",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", "location": "https://github.com/google/promises.git",
"state" : { "state": {
"revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", "revision": "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "6.7.0" "version": "2.4.0"
} }
}, },
{ {
"identity" : "refreshablescrollview", "identity": "reactiveswift",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/phuhuynh2411/RefreshableScrollView", "location": "https://github.com/ReactiveCocoa/ReactiveSwift.git",
"state" : { "state": {
"revision" : "e06edf5dc4facc7fbf71179e8a94f0d1c7035ce3", "revision": "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c",
"version" : "1.1.1" "version": "6.7.0"
} }
}, },
{ {
"identity" : "richtext", "identity": "refreshablescrollview",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/NuPlay/RichText.git", "location": "https://github.com/phuhuynh2411/RefreshableScrollView",
"state" : { "state": {
"revision" : "ec3469ee47c17e0f90e4b43f23d7e19bc42e069b", "revision": "e06edf5dc4facc7fbf71179e8a94f0d1c7035ce3",
"version" : "2.7.0" "version": "1.1.1"
} }
}, },
{ {
"identity" : "rxswift", "identity": "richtext",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/ReactiveX/RxSwift.git", "location": "https://github.com/NuPlay/RichText.git",
"state" : { "state": {
"revision" : "5dd1907d64f0d36f158f61a466bab75067224893", "revision": "ec3469ee47c17e0f90e4b43f23d7e19bc42e069b",
"version" : "6.9.0" "version": "2.7.0"
} }
}, },
{ {
"identity" : "sdwebimage", "identity": "rxswift",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage.git", "location": "https://github.com/ReactiveX/RxSwift.git",
"state" : { "state": {
"revision" : "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a", "revision": "5dd1907d64f0d36f158f61a466bab75067224893",
"version" : "5.21.2" "version": "6.9.0"
} }
}, },
{ {
"identity" : "sdwebimageswiftui", "identity": "sdwebimage",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git", "location": "https://github.com/SDWebImage/SDWebImage.git",
"state" : { "state": {
"revision" : "53573d6dd017e354c0e7d8f1c86b77ef1383c996", "revision": "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a",
"version" : "2.2.7" "version": "5.21.2"
} }
}, },
{ {
"identity" : "swift-protobuf", "identity": "sdwebimageswiftui",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git", "location": "https://github.com/SDWebImage/SDWebImageSwiftUI.git",
"state" : { "state": {
"revision" : "2547102afd04fe49f1b286090f13ebce07284980", "revision": "53573d6dd017e354c0e7d8f1c86b77ef1383c996",
"version" : "1.31.1" "version": "2.2.7"
} }
}, },
{ {
"identity" : "swiftui-flow-layout", "identity": "swift-protobuf",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/globulus/swiftui-flow-layout", "location": "https://github.com/apple/swift-protobuf.git",
"state" : { "state": {
"revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d", "revision": "2547102afd04fe49f1b286090f13ebce07284980",
"version" : "1.0.5" "version": "1.31.1"
} }
}, },
{ {
"identity" : "swiftui-sliders", "identity": "swiftui-flow-layout",
"kind" : "remoteSourceControl", "kind": "remoteSourceControl",
"location" : "https://github.com/spacenation/swiftui-sliders.git", "location": "https://github.com/globulus/swiftui-flow-layout",
"state" : { "state": {
"revision" : "d5a7d856655d5c91f891c2b69d982c30fd5c7bdf", "revision": "de7da3440c3b87ba94adfa98c698828d7746a76d",
"version" : "2.1.0" "version": "1.0.5"
}
},
{
"identity": "swiftui-sliders",
"kind": "remoteSourceControl",
"location": "https://github.com/spacenation/swiftui-sliders.git",
"state": {
"revision": "d5a7d856655d5c91f891c2b69d982c30fd5c7bdf",
"version": "2.1.0"
} }
} }
], ],
"version" : 3 "version": 3
} }

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_chevron_right.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_community_lock.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_follow.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_following.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -989,6 +989,8 @@
<string>NotoSansJP-Medium.ttf</string> <string>NotoSansJP-Medium.ttf</string>
<string>NotoSansJP-Light.ttf</string> <string>NotoSansJP-Light.ttf</string>
<string>NotoSansJP-Regular.ttf</string> <string>NotoSansJP-Regular.ttf</string>
<string>Pattaya-Regular.ttf</string>
<string>Phosphate-Solid.ttf</string>
</array> </array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>

Binary file not shown.

Binary file not shown.

View File

@@ -989,6 +989,8 @@
<string>NotoSansJP-Medium.ttf</string> <string>NotoSansJP-Medium.ttf</string>
<string>NotoSansJP-Light.ttf</string> <string>NotoSansJP-Light.ttf</string>
<string>NotoSansJP-Regular.ttf</string> <string>NotoSansJP-Regular.ttf</string>
<string>Pattaya-Regular.ttf</string>
<string>Phosphate-Solid.ttf</string>
</array> </array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>

View File

@@ -53,6 +53,12 @@ enum SodaTypography {
} }
} }
enum SodaFontFamily {
case localized
case pattaya
case phosphate
}
private func resolvedLanguageCode(currentLocale: Locale) -> String? { private func resolvedLanguageCode(currentLocale: Locale) -> String? {
if let raw = UserDefaults.standard.string(forKey: "app.language"), if let raw = UserDefaults.standard.string(forKey: "app.language"),
let option = LanguageOption(rawValue: raw), let option = LanguageOption(rawValue: raw),
@@ -88,49 +94,67 @@ private func japaneseFontName(for weight: SwiftUI.Font.Weight) -> String {
} }
} }
private func fontName(for family: SodaFontFamily, weight: SwiftUI.Font.Weight, locale: Locale) -> String? {
switch family {
case .localized:
switch resolvedLanguageCode(currentLocale: locale) {
case "ko":
return koreanFontName(for: weight)
case "ja":
return japaneseFontName(for: weight)
default:
return nil
}
case .pattaya:
return "Pattaya-Regular"
case .phosphate:
return "PhosphateSolid"
}
}
struct FontModifier: ViewModifier { struct FontModifier: ViewModifier {
@Environment(\.locale) private var locale @Environment(\.locale) private var locale
let size: CGFloat let size: CGFloat
let weight: SwiftUI.Font.Weight let weight: SwiftUI.Font.Weight
let family: SodaFontFamily
func body(content: Content) -> some View { func body(content: Content) -> some View {
switch resolvedLanguageCode(currentLocale: locale) { if let fontName = fontName(for: family, weight: weight, locale: locale) {
case "ko": content.font(.custom(fontName, size: size))
content.font(.custom(koreanFontName(for: weight), size: size)) } else {
case "ja":
content.font(.custom(japaneseFontName(for: weight), size: size))
default:
content.font(.system(size: size, weight: weight)) content.font(.system(size: size, weight: weight))
} }
} }
} }
extension View { extension View {
func appFont(size: CGFloat, weight: SwiftUI.Font.Weight = .regular) -> some View { func appFont(
self.modifier(FontModifier(size: size, weight: weight)) size: CGFloat,
weight: SwiftUI.Font.Weight = .regular,
family: SodaFontFamily = .localized
) -> some View {
self.modifier(FontModifier(size: size, weight: weight, family: family))
} }
func appFont(_ typography: SodaTypography) -> some View { func appFont(_ typography: SodaTypography, family: SodaFontFamily = .localized) -> some View {
appFont(size: typography.size, weight: typography.weight) appFont(size: typography.size, weight: typography.weight, family: family)
} }
} }
extension Text { extension Text {
func appFont(size: CGFloat, weight: SwiftUI.Font.Weight = .regular) -> Text { func appFont(
if resolvedLanguageCode(currentLocale: .autoupdatingCurrent) == "ko" { size: CGFloat,
return self.font(.custom(koreanFontName(for: weight), size: size)) weight: SwiftUI.Font.Weight = .regular,
} family: SodaFontFamily = .localized
) -> Text {
if resolvedLanguageCode(currentLocale: .autoupdatingCurrent) == "ja" { if let fontName = fontName(for: family, weight: weight, locale: .autoupdatingCurrent) {
return self.font(.custom(japaneseFontName(for: weight), size: size)) return self.font(.custom(fontName, size: size))
} }
return self.font(.system(size: size, weight: weight)) return self.font(.system(size: size, weight: weight))
} }
func appFont(_ typography: SodaTypography) -> Text { func appFont(_ typography: SodaTypography, family: SodaFontFamily = .localized) -> Text {
appFont(size: typography.size, weight: typography.weight) appFont(size: typography.size, weight: typography.weight, family: family)
} }
} }

View File

@@ -13,4 +13,8 @@ enum Font: String {
case preRegular = "Pretendard-Regular" case preRegular = "Pretendard-Regular"
case preLight = "Pretendard-Light" case preLight = "Pretendard-Light"
case pattayaRegular = "Pattaya-Regular"
case phosphateSolid = "PhosphateSolid"
} }

View File

@@ -3060,6 +3060,68 @@ If you block this user, the following features will be restricted.
} }
} }
enum HomeRecommendation {
static var activityLive: String {
pick(ko: "라이브", en: "Live", ja: "ライブ")
}
static var activityAudio: String {
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
}
static var activityCommunity: String {
pick(ko: "커뮤니티", en: "Community", ja: "コミュニティ")
}
static var liveSectionTitle: String {
pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ")
}
static var activeCreatorSectionTitle: String {
pick(ko: "방금 활동한 크리에이터", en: "Recently active creators", ja: "最近活動したクリエイター")
}
static var recentDebutCreatorSectionTitle: String {
pick(ko: "최근 데뷔한 크리에이터", en: "Recently debuted creators", ja: "最近デビューしたクリエイター")
}
static var firstAudioContentSectionTitle: String {
pick(ko: "처음 만나는 오디오", en: "First audio to discover", ja: "初めて出会うオーディオ")
}
static var aiCharacterSectionTitle: String {
pick(ko: "AI 캐릭터", en: "AI characters", ja: "AIキャラクター")
}
static var genreCreatorSectionTitle: String {
pick(ko: "장르의 크리에이터", en: "Creators by genre", ja: "ジャンルのクリエイター")
}
static var cheerCreatorSectionTitle: String {
pick(ko: "최근 응원이 많은 크리에이터", en: "Recently cheered creators", ja: "最近応援が多いクリエイター")
}
static var popularCommunitySectionTitle: String {
pick(ko: "인기 커뮤니티", en: "Popular community", ja: "人気コミュニティ")
}
static var followAll: String {
pick(ko: "모두 팔로우하기", en: "Follow all", ja: "すべてフォロー")
}
static var followAllCompleted: String {
pick(ko: "모두 팔로우 완료", en: "All followed", ja: "すべてフォロー済み")
}
static var more: String {
pick(ko: "더보기", en: "More", ja: "もっと見る")
}
static var collapse: String {
pick(ko: "접기", en: "Collapse", ja: "閉じる")
}
}
enum Explorer { enum Explorer {
static var channel: String { static var channel: String {
pick(ko: "채널", en: "Channel", ja: "チャンネル") pick(ko: "채널", en: "Channel", ja: "チャンネル")

View File

@@ -0,0 +1,154 @@
//
// AudioContentCard.swift
// SodaLive
//
import SwiftUI
enum AudioContentCardSize {
case large
case medium
case small
var width: CGFloat {
switch self {
case .large:
return 185
case .medium:
return 163
case .small:
return 122
}
}
var labelWidth: CGFloat {
width - (labelHorizontalPadding * 2)
}
var labelHorizontalPadding: CGFloat {
switch self {
case .large:
return 0
case .medium:
return SodaSpacing.s6
case .small:
return SodaSpacing.s4
}
}
var titleTypography: SodaTypography {
switch self {
case .large, .medium:
return .heading4
case .small:
return .body1
}
}
var subtitleTypography: SodaTypography {
switch self {
case .large, .medium:
return .body5
case .small:
return .caption2
}
}
}
struct AudioContentCard<Thumbnail: View>: View {
let size: AudioContentCardSize
let title: String
let subtitle: String
private let thumbnail: Thumbnail
init(
size: AudioContentCardSize,
title: String,
subtitle: String,
@ViewBuilder thumbnail: () -> Thumbnail
) {
self.size = size
self.title = title
self.subtitle = subtitle
self.thumbnail = thumbnail()
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
thumbnailContent
labelContent
}
.frame(width: size.width, alignment: .leading)
}
private var thumbnailContent: some View {
thumbnail
.frame(width: size.width, height: size.width)
.clipped()
.frame(width: size.width, height: size.width)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var labelContent: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.appFont(size.titleTypography)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(subtitle)
.appFont(size.subtitleTypography)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(width: size.labelWidth, alignment: .leading)
.padding(.horizontal, size.labelHorizontalPadding)
.frame(width: size.width, alignment: .leading)
}
}
struct AudioContentCard_Previews: PreviewProvider {
static var previews: some View {
HStack(alignment: .top, spacing: SodaSpacing.s20) {
AudioContentCard(
size: .large,
title: "오디오 콘텐츠 제목입니다",
subtitle: "크리에이터명"
) {
previewThumbnail(Color.gray800)
}
AudioContentCard(
size: .medium,
title: "긴 제목은 한 줄로 말줄임 처리됩니다",
subtitle: "긴 부제목도 한 줄로 말줄임 처리됩니다"
) {
previewThumbnail(Color.gray700)
}
AudioContentCard(
size: .small,
title: "스몰 카드",
subtitle: "오디오"
) {
previewThumbnail(Color.gray900)
}
}
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
private static func previewThumbnail(_ color: Color) -> some View {
ZStack {
color
Image(systemName: "waveform")
.font(.system(size: 32, weight: .bold))
.foregroundColor(Color.gray500)
}
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct BannerCarouselItem: Identifiable, Hashable {
let id: String
let imageUrl: String?
init(id: String, imageUrl: String?) {
self.id = id
self.imageUrl = imageUrl
}
}
struct BannerCarousel: View {
let items: [BannerCarouselItem]
let height: CGFloat
let action: (BannerCarouselItem) -> Void
init(
items: [BannerCarouselItem],
height: CGFloat = 120,
action: @escaping (BannerCarouselItem) -> Void = { _ in }
) {
self.items = items
self.height = height
self.action = action
}
var body: some View {
if !items.isEmpty {
TabView {
ForEach(items) { item in
Button {
action(item)
} label: {
DownsampledKFImage(
url: URL(string: item.imageUrl ?? ""),
size: CGSize(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2), height: height)
)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s20)
}
}
.frame(height: height)
.tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .automatic : .never))
}
}
}
struct BannerCarousel_Previews: PreviewProvider {
static var previews: some View {
BannerCarousel(items: [BannerCarouselItem(id: "1", imageUrl: nil)])
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct FollowAllButton: View {
let isCompleted: Bool
let isLoading: Bool
let action: () -> Void
init(
isCompleted: Bool,
isLoading: Bool = false,
action: @escaping () -> Void
) {
self.isCompleted = isCompleted
self.isLoading = isLoading
self.action = action
}
var body: some View {
Button {
if !isCompleted && !isLoading {
action()
}
} label: {
HStack(spacing: SodaSpacing.s6) {
if isCompleted {
Image("ic_new_following")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 16, height: 16)
}
Text(isCompleted ? I18n.HomeRecommendation.followAllCompleted : I18n.HomeRecommendation.followAll)
.appFont(.body5)
.foregroundColor(.white)
}
.padding(.horizontal, SodaSpacing.s16)
.frame(height: 36)
.background(isCompleted ? Color.gray700 : Color.button)
.clipShape(Capsule())
.opacity(isLoading ? 0.6 : 1)
}
.buttonStyle(.plain)
.disabled(isCompleted || isLoading)
}
}
struct FollowAllButton_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: SodaSpacing.s12) {
FollowAllButton(isCompleted: false) {}
FollowAllButton(isCompleted: true) {}
}
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct AiCharacterCard: View {
let name: String
let description: String
let profileImageUrl: String?
let chatCount: Int?
let originalTitle: String?
var body: some View {
HStack(alignment: .top, spacing: SodaSpacing.s12) {
DownsampledKFImage(url: URL(string: profileImageUrl ?? ""), size: CGSize(width: 72, height: 72))
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text(name)
.appFont(.heading4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(description)
.appFont(.body5)
.foregroundColor(Color.gray500)
.lineLimit(2)
.truncationMode(.tail)
HStack(spacing: SodaSpacing.s8) {
if let chatCount {
Text("Chat \(chatCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let originalTitle, !originalTitle.isEmpty {
Text(originalTitle)
.appFont(.caption2)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
}
.padding(SodaSpacing.s12)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
}
struct AiCharacterCard_Previews: PreviewProvider {
static var previews: some View {
AiCharacterCard(
name: "AI 캐릭터",
description: "캐릭터 설명이 표시됩니다.",
profileImageUrl: nil,
chatCount: 128,
originalTitle: "원작"
)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
struct CommunityPostCard: View {
let creatorNickname: String
let creatorProfileImageUrl: String?
let content: String
let imageUrl: String?
let price: Int?
let existOrdered: Bool
let createdAt: String?
let likeCount: Int?
let commentCount: Int?
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
header
Text(content)
.appFont(.body4)
.foregroundColor(.white)
.lineLimit(3)
.truncationMode(.tail)
if hasImage {
imageContent
}
footer
}
.padding(SodaSpacing.s12)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var header: some View {
HStack(spacing: SodaSpacing.s8) {
DownsampledKFImage(url: URL(string: creatorProfileImageUrl ?? ""), size: CGSize(width: 32, height: 32))
.background(Color.gray800)
.clipShape(Circle())
Text(creatorNickname)
.appFont(.body5)
.foregroundColor(.white)
.lineLimit(1)
Spacer(minLength: 0)
}
}
private var imageContent: some View {
ZStack {
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 240, height: 150))
.background(Color.gray800)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s12, style: .continuous))
.blur(radius: isLocked ? 8 : 0)
if isLocked {
VStack(spacing: SodaSpacing.s6) {
Image("ic_new_community_lock")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.frame(width: 24, height: 24)
if let price {
Text("\(price) can")
.appFont(.caption2)
.foregroundColor(Color.button)
.padding(.horizontal, SodaSpacing.s12)
.padding(.vertical, SodaSpacing.s6)
.overlay(
Capsule()
.stroke(Color.button, lineWidth: 1)
)
}
}
}
}
}
private var footer: some View {
HStack(spacing: SodaSpacing.s12) {
if let createdAt, !createdAt.isEmpty {
Text(createdAt)
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let likeCount {
Text("Like \(likeCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
if let commentCount {
Text("Comment \(commentCount)")
.appFont(.caption2)
.foregroundColor(Color.gray500)
}
}
}
private var hasImage: Bool {
!(imageUrl ?? "").isEmpty
}
private var isLocked: Bool {
(price ?? 0) > 0 && !existOrdered
}
}
struct CommunityPostCard_Previews: PreviewProvider {
static var previews: some View {
CommunityPostCard(
creatorNickname: "크리에이터",
creatorProfileImageUrl: nil,
content: "커뮤니티 본문입니다.",
imageUrl: nil,
price: nil,
existOrdered: false,
createdAt: "방금 전",
likeCount: 10,
commentCount: 2
)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct CreatorProfileGridItem: Identifiable, Hashable {
let id: String
let imageUrl: String?
let name: String
let subtitle: String?
init(
id: String,
imageUrl: String?,
name: String,
subtitle: String? = nil
) {
self.id = id
self.imageUrl = imageUrl
self.name = name
self.subtitle = subtitle
}
}
struct CreatorProfileGrid: View {
let items: [CreatorProfileGridItem]
let columns: Int
let spacing: CGFloat
let action: (CreatorProfileGridItem) -> Void
init(
items: [CreatorProfileGridItem],
columns: Int = 3,
spacing: CGFloat = SodaSpacing.s16,
action: @escaping (CreatorProfileGridItem) -> Void = { _ in }
) {
self.items = items
self.columns = columns
self.spacing = spacing
self.action = action
}
var body: some View {
LazyVGrid(columns: gridColumns, alignment: .center, spacing: spacing) {
ForEach(items) { item in
CreatorProfileItem(
imageUrl: item.imageUrl,
name: item.name,
subtitle: item.subtitle
) {
action(item)
}
}
}
}
private var gridColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns)
}
}
struct CreatorProfileGrid_Previews: PreviewProvider {
static var previews: some View {
CreatorProfileGrid(items: [
CreatorProfileGridItem(id: "1", imageUrl: nil, name: "크리에이터 1"),
CreatorProfileGridItem(id: "2", imageUrl: nil, name: "크리에이터 2"),
CreatorProfileGridItem(id: "3", imageUrl: nil, name: "크리에이터 3")
])
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
struct CreatorProfileItem: View {
let imageUrl: String?
let name: String
let subtitle: String?
let action: (() -> Void)?
init(
imageUrl: String?,
name: String,
subtitle: String? = nil,
action: (() -> Void)? = nil
) {
self.imageUrl = imageUrl
self.name = name
self.subtitle = subtitle
self.action = action
}
var body: some View {
Button {
action?()
} label: {
VStack(alignment: .center, spacing: SodaSpacing.s8) {
profileImage
VStack(alignment: .center, spacing: 2) {
Text(name)
.appFont(.body4)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.appFont(.caption2)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(action == nil)
}
private var profileImage: some View {
DownsampledKFImage(url: URL(string: imageUrl ?? ""), size: CGSize(width: 72, height: 72))
.background(Color.gray800)
.clipShape(Circle())
}
}
struct CreatorProfileItem_Previews: PreviewProvider {
static var previews: some View {
CreatorProfileItem(imageUrl: nil, name: "크리에이터", subtitle: "방금 활동")
.frame(width: 96)
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,63 @@
//
// SectionTitle.swift
// SodaLive
//
import SwiftUI
struct SectionTitle: View {
let title: String
let action: (() -> Void)?
init(
title: String,
action: (() -> Void)? = nil
) {
self.title = title
self.action = action
}
var body: some View {
content
}
private var content: some View {
HStack(alignment: .center, spacing: 0) {
Text(title)
.appFont(.heading3)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
if let action {
Button(action: action) {
Image("ic_chevron_right")
.resizable()
.renderingMode(.template)
.foregroundColor(.white)
.scaledToFit()
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(.isButton)
}
}
.padding(.horizontal, SodaSpacing.s20)
.frame(maxWidth: .infinity)
.frame(height: 42, alignment: .center)
}
}
struct SectionTitle_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: SodaSpacing.s12) {
SectionTitle(title: "텍스트")
SectionTitle(title: "긴 섹션 타이틀은 한 줄로 줄임 처리됩니다 한 줄로 줄임 처리됩니다") {}
}
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,81 @@
import SwiftUI
struct ExpandableTextView: 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
init(
text: String,
lineLimit: Int = 3,
moreTitle: String = I18n.HomeRecommendation.more,
collapseTitle: String = I18n.HomeRecommendation.collapse,
font: SodaTypography = .body3,
foregroundColor: Color = Color.gray500
) {
self.text = text
self.lineLimit = lineLimit
self.moreTitle = moreTitle
self.collapseTitle = collapseTitle
self.font = font
self.foregroundColor = foregroundColor
}
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 shouldShowToggle {
Button {
isExpanded.toggle()
} label: {
Text(isExpanded ? collapseTitle : moreTitle)
.appFont(.body5)
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
}
private var shouldShowToggle: Bool {
fullTextHeight > limitedTextHeight + 1
}
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 ExpandableTextView_Previews: PreviewProvider {
static var previews: some View {
ExpandableTextView(text: "긴 사업자 정보 텍스트가 들어가는 영역입니다. 세 줄을 넘어가면 더보기 버튼이 표시되고, 더보기 버튼을 누르면 전체 문구가 표시됩니다.")
.padding(SodaSpacing.s20)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
import Combine
final class MainHomeViewModel: ObservableObject {
private let repository = MainHomeRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var isShowPopup = false
@Published var errorMessage = ""
@Published var recommendations: HomeRecommendationResponse?
@Published private(set) var completedFollowKeys = Set<String>()
@Published private(set) var followingKeys = Set<String>()
func fetchRecommendations() {
isLoading = true
repository.getRecommendations()
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<HomeRecommendationResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.recommendations = data
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
func followAll(creatorIds: [Int], completionKey: String) {
guard !creatorIds.isEmpty, !completionKey.isEmpty else { return }
guard !completedFollowKeys.contains(completionKey), !followingKeys.contains(completionKey) else { return }
followingKeys.insert(completionKey)
repository.followRecommendedCreators(request: FollowRecommendedCreatorsRequest(creatorIds: creatorIds))
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.followingKeys.remove(completionKey)
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
self.completedFollowKeys.insert(completionKey)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.followingKeys.remove(completionKey)
}
.store(in: &subscription)
}
func isFollowAllCompleted(_ completionKey: String) -> Bool {
completedFollowKeys.contains(completionKey)
}
func isFollowAllLoading(_ completionKey: String) -> Bool {
followingKeys.contains(completionKey)
}
}

View File

@@ -0,0 +1,5 @@
import Foundation
struct FollowRecommendedCreatorsRequest: Encodable {
let creatorIds: [Int]?
}

View File

@@ -0,0 +1,98 @@
import Foundation
struct HomeRecommendationResponse: Decodable {
let lives: [HomeLiveItem]
let banners: [HomeBannerItem]
let recentlyActiveCreators: [HomeActiveCreatorItem]
let recentDebutCreators: [HomeCreatorItem]
let firstAudioContents: [HomeFirstAudioContentItem]
let aiCharacters: [HomeAiCharacterItem]
let genreCreators: [HomeGenreCreatorGroupItem]
let cheerCreators: [HomeCreatorItem]
let popularCommunityPosts: [HomePopularCommunityPostItem]
}
struct HomeLiveItem: Decodable, Hashable {
let liveRoomId: Int?
let roomId: Int?
let creatorId: Int?
let creatorNickname: String?
let title: String?
let profileImageUrl: String?
let thumbnailUrl: String?
let imageUrl: String?
let beginDateTime: String?
let participantCount: Int?
}
struct HomeBannerItem: Decodable, Hashable {
let bannerId: Int?
let type: String?
let title: String?
let imageUrl: String?
let link: String?
let eventId: Int?
let creatorId: Int?
let seriesId: Int?
}
struct HomeActiveCreatorItem: Decodable, Hashable {
let creatorId: Int?
let creatorNickname: String?
let profileImageUrl: String?
let activityType: RecommendedActivityType?
let activityAt: String?
}
struct HomeCreatorItem: Decodable, Hashable {
let creatorId: Int?
let creatorNickname: String?
let nickname: String?
let profileImageUrl: String?
let imageUrl: String?
let description: String?
let followerCount: Int?
let cheerCount: Int?
let debutDate: String?
}
struct HomeFirstAudioContentItem: Decodable, Hashable {
let contentId: Int?
let creatorId: Int?
let creatorNickname: String?
let title: String?
let imageUrl: String?
let coverImageUrl: String?
let releaseDate: String?
}
struct HomeAiCharacterItem: Decodable, Hashable {
let characterId: Int?
let name: String?
let description: String?
let profileImageUrl: String?
let imageUrl: String?
let chatCount: Int?
let originalTitle: String?
}
struct HomeGenreCreatorGroupItem: Decodable, Hashable {
let genreId: Int?
let genreName: String?
let title: String?
let creators: [HomeCreatorItem]?
}
struct HomePopularCommunityPostItem: Decodable, Hashable {
let postId: Int?
let creatorId: Int?
let creatorNickname: String?
let creatorProfileImageUrl: String?
let content: String?
let imageUrl: String?
let price: Int?
let existOrdered: Bool?
let likeCount: Int?
let commentCount: Int?
let createdAt: String?
}

View File

@@ -0,0 +1,37 @@
import Foundation
enum RecommendedActivityType: Decodable, Hashable {
case live
case audio
case community
case unknown(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let code = try container.decode(String.self)
switch code {
case "LIVE", "LIVE_REPLAY":
self = .live
case "AUDIO":
self = .audio
case "COMMUNITY":
self = .community
default:
self = .unknown(code)
}
}
var title: String {
switch self {
case .live:
return I18n.HomeRecommendation.activityLive
case .audio:
return I18n.HomeRecommendation.activityAudio
case .community:
return I18n.HomeRecommendation.activityCommunity
case .unknown:
return ""
}
}
}

View File

@@ -0,0 +1,47 @@
import Foundation
import Moya
enum MainHomeApi {
case getRecommendations
case followRecommendedCreators(request: FollowRecommendedCreatorsRequest)
}
extension MainHomeApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getRecommendations:
return "/api/v2/home/recommendations"
case .followRecommendedCreators:
return "/api/v2/home/recommendations/creators/follow"
}
}
var method: Moya.Method {
switch self {
case .getRecommendations:
return .get
case .followRecommendedCreators:
return .post
}
}
var task: Moya.Task {
switch self {
case .getRecommendations:
return .requestPlain
case .followRecommendedCreators(let request):
return .requestJSONEncodable(request)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
import CombineMoya
import Combine
import Moya
final class MainHomeRepository {
private let api = MoyaProvider<MainHomeApi>()
func getRecommendations() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getRecommendations)
}
func followRecommendedCreators(request: FollowRecommendedCreatorsRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.followRecommendedCreators(request: request))
}
}

View File

@@ -0,0 +1,460 @@
# 메인 홈 추천 UI와 API 연동 구현 계획
## 기준 문서
- PRD: `docs/20260602_메인_홈_추천_UI_API_연동/prd.md`
- Figma 추천 화면: `24:5514`
- Figma 모두 팔로우 완료 버튼: `24:9092`
- Figma 커뮤니티 카드:
- Text Only: `446:9688`
- Text + Img, 유료 + 구매하지 않음: `446:9690`
- Text + Img, 유료/무료 + 구매함: `446:9691`
- 코드 스타일: `docs/agent-guides/code-style.md`
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
## 작업 원칙
- PRD의 성공 기준과 제외 범위를 벗어나지 않는다.
- `추천 필모그래피`, `또 다른 모습` 섹션은 만들지 않는다.
- 기존 `SodaLive/Sources/Home/HomeApi.swift`에는 추천 API를 추가하지 않는다.
- 신규 페이지 루트, ViewModel, Repository, API, Models, MainHome 전용 컴포넌트는 `SodaLive/Sources/V2/Main/Home/**` 아래에 작성한다.
- 여러 페이지에서 재사용 가능한 UI widget은 `SodaLive/Sources/V2/Component/**` 아래에 형태별 폴더로 작성한다.
- 외부 라이브러리는 추가하지 않는다.
- Figma localhost asset URL은 앱 코드에 사용하지 않는다.
- 서버/기획 확인이 필요한 이동 규칙과 최대 표시 개수는 임의 확장하지 않고, 현재 화면 렌더링과 API 연동에 필요한 최소 동작만 구현한다.
- 수정 후 검증 기록은 이 문서 하단에 누적한다.
## 구현 대상 파일
### 생성
- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- `MainView` 홈 탭에서 표시할 실제 홈 추천 페이지 루트.
- `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
- 추천 API 로딩/에러/섹션 데이터/모두 팔로우 완료 상태 관리.
- `SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift`
- `/api/v2/home/recommendations`, `/api/v2/home/recommendations/creators/follow` 전용 Moya `TargetType`.
- `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift`
- `MainHomeApi` 호출을 `AnyPublisher<Response, MoyaError>`로 제공.
- `SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift`
- `HomeRecommendationResponse`와 하위 응답 모델.
- `SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift`
- 모두 팔로우 요청 모델.
- `SodaLive/Sources/V2/Main/Home/Models/RecommendedActivityType.swift`
- 서버 활동 타입 enum 변환과 I18n 표시.
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeLiveSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeLiveItem.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeActiveCreatorSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeActiveCreatorItem.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeRecentDebutCreatorSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeFirstAudioContentSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeAiCharacterSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeGenreCreatorSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeCheerCreatorSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeCreatorGroupSection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeCreatorGrid.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomePopularCommunitySection.swift`
- `SodaLive/Sources/V2/Main/Home/Components/MainHomeBusinessInfoSection.swift`
- `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
- `SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift`
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
- `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
- `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
- `SodaLive/Sources/V2/Component/Button/FollowAllButton.swift`
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
### 수정
- `SodaLive/Sources/V2/Main/MainView.swift`
- `.home` 탭에서 `MainPlaceholderTabView` 대신 `MainHomeView` 표시.
- `SodaLive/Sources/I18n/I18n.swift`
- 홈 추천 섹션 제목, 활동 타입, 모두 팔로우 문구, 더보기/접기 문구 추가.
- `SodaLive.xcodeproj/project.pbxproj`
- 신규 Swift 파일이 Xcode 빌드 대상에 포함되지 않는 경우에만 수정.
- `docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
- 체크리스트와 검증 기록 유지.
## TASK 체크리스트
### Phase 1: 구현 기준과 데이터 계층 준비
- [x] **Task 1.1: 구현 전 구조 확인**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 확인: `SodaLive/Sources/V2/Component/SectionTitle.swift`
- 확인: `SodaLive/Sources/V2/Component/AudioContentCard.swift`
- 확인: `SodaLive/Sources/V2/Component/HomeTitleBar.swift`
- 확인: `SodaLive/Sources/V2/Main/MainTabBarView.swift`
- 확인: `SodaLive/Sources/Home/HomeApi.swift`
- 확인: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- `.home` 탭이 현재 `MainPlaceholderTabView`를 표시하는지 확인한다.
- 기존 V2 공용 컴포넌트 재사용 범위를 확인한다.
- 기존 `HomeApi.swift`를 수정하지 않는 것을 기준으로 신규 `MainHomeApi` 위치를 확정한다.
- `I18n.swift`의 기존 enum 배치 방식과 `pick(ko:en:ja:)` 사용 방식을 확인한다.
- 검증 기준:
- 실행 명령: `rg "case \\.home|MainPlaceholderTabView|enum HomeApi|enum I18n" SodaLive/Sources/V2/Main SodaLive/Sources/Home SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: `.home` placeholder 위치, 기존 `HomeApi`, `I18n` 구조를 확인할 수 있다.
- 수동 확인: 신규 추천 API를 기존 `HomeApi.swift`에 추가하지 않아야 한다.
- [x] **Task 1.2: 추천 API 모델 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Models/FollowRecommendedCreatorsRequest.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Models/RecommendedActivityType.swift`
- 작업 내용:
- `MainHomeRecommendationResponse.swift``HomeRecommendationResponse`와 하위 응답 모델을 `Decodable`로 작성한다.
- 서버 nullable 필드는 Swift optional로 선언한다.
- 서버 Long 값은 우선 기존 앱 라우팅 관례와 맞추기 쉬운 `Int`로 작성하고, 빌드 또는 호출부에서 `Int64`가 필요하면 보정한다.
- `FollowRecommendedCreatorsRequest``creatorIds: [Int]?`를 갖는 `Encodable`로 작성한다.
- `RecommendedActivityType``LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`, unknown 처리를 제공한다.
- 검증 기준:
- 실행 명령: `rg "struct HomeRecommendationResponse|struct FollowRecommendedCreatorsRequest|enum RecommendedActivityType" SodaLive/Sources/V2/Main/Home/Models`
- 기대 결과: 세 모델/enum 정의가 검색된다.
- 수동 확인: PRD의 응답 필드명이 Swift 모델에 누락 없이 반영되어야 한다.
- [x] **Task 1.3: I18n 문구 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- `I18n.HomeRecommendation` enum을 추가한다.
- 활동 타입 문구를 추가한다.
- `activityLive`: ko `라이브`, en `Live`, ja `ライブ`
- `activityAudio`: ko `오디오`, en `Audio`, ja `オーディオ`
- `activityCommunity`: ko `커뮤니티`, en `Community`, ja `コミュニティ`
- 섹션 제목 문구를 추가한다.
- 현재 라이브
- 방금 활동한 크리에이터
- 최근 데뷔한 크리에이터
- 처음 만나는 오디오
- AI 캐릭터
- 장르의 크리에이터
- 최근 응원이 많은 크리에이터
- 인기 커뮤니티
- 버튼/텍스트 문구를 추가한다.
- `followAll`: `모두 팔로우하기`
- `followAllCompleted`: `모두 팔로우 완료`
- `more`: `더보기`
- `collapse`: `접기`
- 검증 기준:
- 실행 명령: `rg "enum HomeRecommendation|activityLive|followAllCompleted|collapse" SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: 홈 추천 I18n enum과 핵심 문구가 검색된다.
- 수동 확인: 신규 사용자 노출 문자열이 하드코딩 View 텍스트로 흩어지지 않아야 한다.
- [x] **Task 1.4: 신규 API와 Repository 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift`
- 확인: `SodaLive/Sources/Home/HomeApi.swift`
- 확인: `SodaLive/Sources/Home/HomeTabRepository.swift`
- 작업 내용:
- `MainHomeApi``getRecommendations`, `followRecommendedCreators(request:)` 케이스를 작성한다.
- `getRecommendations``GET /api/v2/home/recommendations`로 작성한다.
- `followRecommendedCreators(request:)``POST /api/v2/home/recommendations/creators/follow`로 작성한다.
- 인증 헤더는 기존 패턴대로 `Authorization: Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))`를 사용한다.
- `MainHomeRepository`에서 위 두 API를 `requestPublisher`로 감싼다.
- 기존 `HomeApi.swift`, `HomeTabRepository.swift`는 수정하지 않는다.
- 검증 기준:
- 실행 명령: `rg "/api/v2/home/recommendations|followRecommendedCreators|getRecommendations" SodaLive/Sources/V2/Main/Home/Repository`
- 기대 결과: 신규 API 경로와 repository 메서드가 신규 폴더에서만 검색된다.
- 실행 명령: `rg "/api/v2/home/recommendations" SodaLive/Sources/Home`
- 기대 결과: 검색 결과가 없어야 한다.
### Phase 2: ViewModel 상태와 공용 UI 컴포넌트 작성
- [x] **Task 2.1: MainHomeViewModel 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Models/MainHomeRecommendationResponse.swift`
- 작업 내용:
- `@Published` 상태를 작성한다.
- `isLoading`
- `isShowPopup`
- `errorMessage`
- `recommendations`
- 장르/응원 그룹별 모두 팔로우 완료 상태
- 모두 팔로우 호출 중 상태
- `fetchRecommendations()`에서 `ApiResponse<HomeRecommendationResponse>`를 디코딩한다.
- `success == true`이고 `data`가 있으면 화면 상태를 갱신한다.
- 실패, 디코딩 실패, 네트워크 실패는 기존 앱 관례대로 `errorMessage``isShowPopup`으로 처리한다.
- `followAll(creatorIds:completionKey:)`에서 `ApiResponseWithoutData`를 디코딩한다.
- 모두 팔로우 성공 시 해당 섹션 완료 상태만 true로 바꾼다.
-`creatorIds`인 경우 API를 호출하지 않고 버튼을 숨기거나 비활성화하는 방식으로 처리한다.
- 검증 기준:
- 실행 명령: `rg "final class MainHomeViewModel|fetchRecommendations|followAll|ApiResponse<HomeRecommendationResponse>|ApiResponseWithoutData" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
- 기대 결과: ViewModel 상태와 API 처리 메서드가 검색된다.
- 수동 확인: 실패 처리에서 빈 `catch`를 사용하지 않아야 한다.
- [x] **Task 2.2: 공용 `ExpandableTextView` 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
- 작업 내용:
- `text`, `lineLimit`, `moreTitle`, `collapseTitle`, `font`, `foregroundColor`를 입력으로 받는다.
- 기본 상태는 `lineLimit(3)`과 말줄임표를 적용한다.
- `더보기` 터치 시 전체 표시로 전환한다.
- `접기` 터치 시 다시 3줄 제한 상태로 전환한다.
- 외부 라이브러리를 사용하지 않는다.
- 텍스트가 3줄 이하이면 더보기/접기 버튼을 숨긴다.
- 검증 기준:
- 실행 명령: `rg "struct ExpandableTextView|lineLimit|moreTitle|collapseTitle|isExpanded" SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
- 기대 결과: 확장/접기 상태와 3줄 제한 구현 키워드가 검색된다.
- 수동 확인: 외부 라이브러리 import 없이 `SwiftUI` 기반으로 구현되어야 한다.
- [x] **Task 2.3: 공용 Creator 컴포넌트 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
- 작업 내용:
- `CreatorProfileItem`은 프로필 이미지, 이름, 선택적 보조 텍스트, 아이템 탭 액션을 입력으로 받는다.
- `CreatorProfileItem`은 API 응답 모델에 직접 의존하지 않는다.
- `CreatorProfileGrid`는 표시 모델 배열, 열/간격, 탭 액션을 입력으로 받는다.
- 최근 데뷔 크리에이터와 장르/응원 그룹의 개별 아이템에 재사용할 수 있게 작성한다.
- 검증 기준:
- 실행 명령: `rg "struct CreatorProfileItem|struct CreatorProfileGrid|creatorNickname|HomeCreatorItem|HomeRecommendationResponse" SodaLive/Sources/V2/Component/Creator`
- 기대 결과: `CreatorProfileItem`, `CreatorProfileGrid`는 검색되고 API 응답 타입 의존은 없어야 한다.
- 수동 확인: 공용 컴포넌트 타입명에 `MainHome` 또는 `HomeRecommendation` 접두사를 붙이지 않아야 한다.
- [x] **Task 2.4: 공용 Button/Banner/Card 컴포넌트 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Component/Button/FollowAllButton.swift`
- 생성: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
- 생성: `SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift`
- 생성: `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
- 작업 내용:
- `FollowAllButton`은 기본 상태 `모두 팔로우하기`, 완료 상태 `모두 팔로우 완료`, 완료 아이콘 `ic_new_following`, 호출 중 중복 터치 방지를 지원한다.
- `BannerCarousel`은 배너 이미지 URL 목록과 탭 액션을 입력으로 받는다.
- `AiCharacterCard`는 캐릭터명, 설명, 프로필 이미지, 채팅 수, 원작 제목을 입력으로 받는다.
- `CommunityPostCard`는 Text Only, Text + Img 잠금, Text + Img 노출 variant를 `imageUrl`, `price`, `existOrdered` 기준으로 분기한다.
- `CommunityPostCard``구매완료` 캡슐을 구현하지 않는다.
- 검증 기준:
- 실행 명령: `rg "struct FollowAllButton|ic_new_following|struct BannerCarousel|struct AiCharacterCard|struct CommunityPostCard|구매완료" SodaLive/Sources/V2/Component`
- 기대 결과: 네 공용 컴포넌트와 `ic_new_following`은 검색되고, `구매완료` 캡슐 구현 텍스트는 production UI로 남지 않아야 한다.
- 수동 확인: 공용 컴포넌트가 특정 API 응답 모델을 직접 받지 않아야 한다.
### Phase 3: MainHome 전용 섹션과 페이지 조립
- [ ] **Task 3.1: MainHome 전용 섹션 컴포넌트 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeLiveSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeLiveItem.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeActiveCreatorSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeActiveCreatorItem.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeRecentDebutCreatorSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeFirstAudioContentSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeAiCharacterSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeGenreCreatorSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeCheerCreatorSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeCreatorGroupSection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeCreatorGrid.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomePopularCommunitySection.swift`
- 생성: `SodaLive/Sources/V2/Main/Home/Components/MainHomeBusinessInfoSection.swift`
- 작업 내용:
- 라이브/최근 활동/최근 데뷔/첫 오디오/AI 캐릭터/장르/응원/커뮤니티/사업자 정보 섹션을 작성한다.
- `MainHomeRecentDebutCreatorSection`은 공용 `CreatorProfileGrid`를 사용한다.
- `MainHomeFirstAudioContentSection`은 기존 `AudioContentCard`를 사용한다.
- `MainHomeAiCharacterSection`은 공용 `AiCharacterCard`를 사용한다.
- `MainHomeGenreCreatorSection`, `MainHomeCheerCreatorSection``MainHomeCreatorGroupSection`을 조합한다.
- `MainHomePopularCommunitySection`은 공용 `CommunityPostCard`를 사용한다.
- `MainHomeBusinessInfoSection`은 공용 `ExpandableTextView`를 사용한다.
- 각 섹션은 데이터 배열이 비어 있으면 렌더링하지 않는다.
- 검증 기준:
- 실행 명령: `rg "struct MainHome.*Section|struct MainHome.*Item|CreatorProfileGrid|AudioContentCard|AiCharacterCard|CommunityPostCard|ExpandableTextView" SodaLive/Sources/V2/Main/Home/Components`
- 기대 결과: MainHome 전용 섹션과 공용 컴포넌트 조합이 검색된다.
- 수동 확인: `추천 필모그래피`, `또 다른 모습` 섹션 파일이나 View를 만들지 않아야 한다.
- [ ] **Task 3.2: MainHomeView 작성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
- 확인: `SodaLive/Sources/V2/Component/HomeTitleBar.swift`
- 작업 내용:
- `HomeTitleBar`, 상단 탭 UI, 세로 `ScrollView`, 각 섹션 컴포넌트를 조합한다.
- `.onAppear`에서 최초 `fetchRecommendations()`를 호출한다.
- 로딩/에러 표시 방식은 기존 앱 관례를 따른다.
- 배너/카드 탭 액션은 PRD의 미결정 항목이므로 구현 범위에서는 비워 두거나 기존 라우팅이 명확한 경우에만 연결한다.
- 사업자 정보 텍스트는 Figma의 사업자 정보 문구를 사용한다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeView|HomeTitleBar|ScrollView|fetchRecommendations|MainHomeBusinessInfoSection" SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 기대 결과: 페이지 루트와 주요 조합 요소가 검색된다.
- 수동 확인: `MainTabBarView``MainView`의 safe area inset에서 유지하고, `MainHomeView` 내부에서 중복 생성하지 않아야 한다.
- [ ] **Task 3.3: MainView 홈 탭 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- `.home` 분기에서 `MainPlaceholderTabView(title: MainTab.home.title)``MainHomeView()`로 교체한다.
- 다른 탭의 placeholder 동작은 변경하지 않는다.
- 기존 `legacyHomeViewModel`의 이벤트 팝업/회원 정보 초기화 흐름은 변경하지 않는다.
- 검증 기준:
- 실행 명령: `rg "case \\.home|MainHomeView|MainPlaceholderTabView" SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: `.home` 분기는 `MainHomeView()`를 표시하고, 다른 탭에는 기존 placeholder가 남아 있다.
- 수동 확인: 홈 탭 외 content/chat/my 분기를 불필요하게 수정하지 않아야 한다.
### Phase 4: 프로젝트 포함과 정적 점검
- [ ] **Task 4.1: Xcode 프로젝트 포함 여부 확인**
- 대상 파일:
- 확인: `SodaLive.xcodeproj/project.pbxproj`
- 확인: `SodaLive/Sources/V2/Main/Home/**`
- 확인: `SodaLive/Sources/V2/Component/**`
- 작업 내용:
- 신규 Swift 파일이 Xcode 빌드 대상에 포함되는지 확인한다.
- 파일 시스템 동기화 방식으로 자동 포함되면 `SodaLive.xcodeproj/project.pbxproj`를 수정하지 않는다.
- 프로젝트 파일 수정이 필요한 경우에만 `SodaLive.xcodeproj/project.pbxproj`를 갱신한다.
- 검증 기준:
- 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj`
- 기대 결과: 프로젝트 파일을 수정했거나 확인이 필요할 때 `OK`가 출력된다.
- 수동 확인: 프로젝트 파일 수정이 불필요하면 변경하지 않아야 한다.
- [ ] **Task 4.2: 정적 점검**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/Main/Home/**`
- 확인: `SodaLive/Sources/V2/Component/**`
- 확인: `SodaLive/Sources/Home/**`
- 작업 내용:
- 제외 섹션, 기존 홈 API 오염, Figma URL, 공백 오류를 확인한다.
- 검증 기준:
- 실행 명령: `rg "추천 필모그래피|또 다른 모습" SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Component`
- 기대 결과: 검색 결과가 없어야 한다.
- 실행 명령: `rg "api/v2/home/recommendations" SodaLive/Sources/Home`
- 기대 결과: 검색 결과가 없어야 한다.
- 실행 명령: `rg "localhost:3845|figma.com" SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Component`
- 기대 결과: 검색 결과가 없어야 한다.
- 실행 명령: `git diff --check`
- 기대 결과: 출력 없이 성공한다.
### Phase 5: 빌드와 기능 검증
- [ ] **Task 5.1: 빌드 검증**
- 대상 파일:
- 확인: `SodaLive.xcworkspace`
- 확인: `SodaLive.xcodeproj`
- 확인: `SodaLive/Sources/V2/Main/Home/**`
- 확인: `SodaLive/Sources/V2/Component/**`
- 작업 내용:
- 기본 빌드를 실행한다.
- 필요 시 운영 스킴 빌드를 실행한다.
- 테스트 액션 확인이 필요하면 테스트 명령을 실행하고, 스킴 미구성 시 결과를 검증 기록에 남긴다.
- 검증 기준:
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `BUILD SUCCEEDED`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 기대 결과: `BUILD SUCCEEDED`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 기대 결과: 테스트가 실행되거나, 스킴 미구성 메시지를 검증 기록에 남긴다.
- [ ] **Task 5.2: 기능 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Components/**`
- 확인: `SodaLive/Sources/V2/Component/**`
- 작업 내용:
- 추천 API 성공/실패, 빈 섹션, 모두 팔로우 성공/실패, 사업자 정보 확장/접기, 활동 타입 I18n, 커뮤니티 카드 variant를 확인한다.
- 검증 기준:
- 수동 확인: 추천 API 성공 시 각 섹션이 응답 데이터로 표시된다.
- 수동 확인: 빈 배열 섹션은 제목과 컨테이너까지 숨겨진다.
- 수동 확인: 모두 팔로우 버튼 터치 후 API 성공 시 해당 버튼만 `모두 팔로우 완료`로 바뀐다.
- 수동 확인: 모두 팔로우 실패 시 버튼 상태가 변경되지 않고 오류가 표시된다.
- 수동 확인: 사업자 정보 기본 3줄 말줄임표, 더보기, 전체 표시, 접기가 동작한다.
- 수동 확인: 활동 타입 `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY`가 I18n 문구로 표시된다.
- 수동 확인: 커뮤니티 카드 3개 variant가 조건에 맞게 표시되고 `구매완료` 캡슐이 표시되지 않는다.
### Phase 6: 문서와 체크리스트 갱신
- [ ] **Task 6.1: 문서/체크리스트 갱신**
- 대상 파일:
- 수정: `docs/20260602_메인_홈_추천_UI_API_연동/prd.md`
- 수정: `docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
- 작업 내용:
- 구현 중 확정된 배너 이동 규칙, 표시 개수, 타입 보정 사항이 있으면 PRD를 먼저 갱신한다.
- 완료한 task 체크박스를 실제 상태에 맞게 `- [x]`로 갱신한다.
- 실행한 검증 명령과 결과를 이 문서 하단 검증 기록에 누적한다.
- 검증 기준:
- 실행 명령: `rg "### Phase [0-9]+:|- \\[[ x]\\] \\*\\*Task [0-9]+\\.[0-9]+:" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
- 기대 결과: phase heading과 `Task N.N` 형식 체크박스가 검색된다.
- 수동 확인: 새 문서를 만들지 않고 기존 PRD/계획 문서에 이어서 기록해야 한다.
## 검증 기록
### 2026-06-02 계획 문서 생성
- 목적: `docs/20260602_메인_홈_추천_UI_API_연동/prd.md`를 기준으로 구현 전 계획/TASK 문서 작성
- 수행 내용:
- MainHome 페이지 루트, 신규 API 계층, 공용 컴포넌트, MainHome 전용 컴포넌트, 연결/검증 작업을 TASK 단위로 분리
- 공용 컴포넌트는 `SodaLive/Sources/V2/Component/**` 형태별 폴더 기준으로 배치
- MainHome 전용 컴포넌트는 `SodaLive/Sources/V2/Main/Home/Components` 기준으로 배치
- 아직 수행하지 않은 작업:
- Swift 코드 구현
- Xcode 프로젝트 포함 여부 확인
- 빌드/기능 검증
### 2026-06-02 Phase 1 구현 완료
- 목적: Phase 1 범위인 구현 전 구조 확인, 추천 API 모델, I18n 문구, 신규 API/Repository 작성
- 수행 내용:
- `SodaLive/Sources/V2/Main/MainView.swift``.home` placeholder 유지 상태와 기존 `HomeApi`, `HomeTabRepository`, `I18n` 구조 확인
- `SodaLive/Sources/V2/Main/Home/Models``HomeRecommendationResponse`, `FollowRecommendedCreatorsRequest`, `RecommendedActivityType` 추가
- `SodaLive/Sources/I18n/I18n.swift``I18n.HomeRecommendation` 문구 추가
- `SodaLive/Sources/V2/Main/Home/Repository``MainHomeApi`, `MainHomeRepository` 추가
- 신규 Swift 파일 5개를 `SodaLive.xcodeproj/project.pbxproj``SodaLive`, `SodaLive-dev` Sources에 포함
- 검증:
- `rg "case \.home|MainPlaceholderTabView|enum HomeApi|enum I18n" SodaLive/Sources/V2/Main SodaLive/Sources/Home SodaLive/Sources/I18n/I18n.swift` 실행, `.home` placeholder와 기존 `HomeApi`, `I18n` 구조 확인
- `rg "struct HomeRecommendationResponse|struct FollowRecommendedCreatorsRequest|enum RecommendedActivityType" SodaLive/Sources/V2/Main/Home/Models` 실행, 모델 정의 검색 확인
- `rg "enum HomeRecommendation|activityLive|followAllCompleted|collapse" SodaLive/Sources/I18n/I18n.swift` 실행, 신규 I18n 문구 검색 확인
- `rg "/api/v2/home/recommendations|followRecommendedCreators|getRecommendations" SodaLive/Sources/V2/Main/Home/Repository` 실행, 신규 API 경로와 repository 메서드 검색 확인
- `rg "/api/v2/home/recommendations" SodaLive/Sources/Home` 실행, 검색 결과 없음 확인
- `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인
- `git diff --check` 실행, 출력 없이 성공 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -list` 실행, 별도 테스트 타깃 없음 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인
- 아직 수행하지 않은 작업:
- Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결
- 테스트 타깃이 없어 Phase 1 전용 RED/GREEN 단위 테스트는 추가하지 않음
### 2026-06-02 Phase 2 구현 완료
- 목적: Phase 2 범위인 `MainHomeViewModel`과 공용 UI 컴포넌트 작성
- 수행 내용:
- `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`에 추천 API 로딩/오류/응답 상태와 모두 팔로우 완료/호출 중 상태 추가
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` 추가
- `SodaLive/Sources/V2/Component/Creator``CreatorProfileItem`, `CreatorProfileGrid` 추가
- `SodaLive/Sources/V2/Component/Button`, `Banner`, `Card``FollowAllButton`, `BannerCarousel`, `AiCharacterCard`, `CommunityPostCard` 추가
- `SodaLive/Resources/Assets.xcassets/v2`에 Phase 2 버튼/카드용 `ic_new_following`, `ic_new_follow`, `ic_new_community_lock` 이미지셋 포함
- 신규 Swift 파일 8개를 `SodaLive.xcodeproj/project.pbxproj``SodaLive`, `SodaLive-dev` Sources에 포함
- 실제 파일이 없던 기존 `CustomView/ExpandableTextView.swift` stale 프로젝트 참조를 제거해 중복 빌드 산출물 오류 해결
- 검증:
- `rg "final class MainHomeViewModel|fetchRecommendations|followAll|ApiResponse<HomeRecommendationResponse>|ApiResponseWithoutData" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift` 실행, ViewModel 상태/API 처리 메서드 검색 확인
- `rg "struct ExpandableTextView|lineLimit|moreTitle|collapseTitle|isExpanded" SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` 실행, 확장/접기 상태와 3줄 제한 구현 키워드 검색 확인
- `rg "struct CreatorProfileItem|struct CreatorProfileGrid|creatorNickname|HomeCreatorItem|HomeRecommendationResponse|MainHome" SodaLive/Sources/V2/Component/Creator` 실행, 공용 Creator 컴포넌트 검색 및 API 응답 타입 의존 없음 확인
- `rg "struct FollowAllButton|ic_new_following|struct BannerCarousel|struct AiCharacterCard|struct CommunityPostCard|구매완료|HomeBannerItem|HomeAiCharacterItem|HomePopularCommunityPostItem|HomeRecommendationResponse" SodaLive/Sources/V2/Component` 실행, 공용 컴포넌트 검색 및 금지 문구/응답 타입 의존 없음 확인
- `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인
- `git diff --check` 실행, 출력 없이 성공 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인
- 아직 수행하지 않은 작업:
- Phase 3 이후 MainHome 전용 섹션, 페이지 조립, 홈 탭 연결
- 테스트 타깃이 없어 Phase 2 전용 RED/GREEN 단위 테스트는 추가하지 않음
### 2026-06-12 Phase 2 코드 리뷰 보완
- 목적: Phase 2 코드 리뷰에서 확인된 ViewModel Combine 캡처 안정성과 커뮤니티 카드 잠금 아이콘 asset 불일치 보완
- 수행 내용:
- `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`의 추천 API/모두 팔로우 API Combine callback 캡처를 `[unowned self]`에서 `[weak self]`와 early return으로 변경
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`의 잠금 아이콘을 기존 `ic_lock_bb`에서 Phase 2 신규 asset인 `ic_new_community_lock`으로 변경
- 검증:
- `rg "unowned self" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, 검색 결과 없음 확인
- `rg "weak self|ic_new_community_lock|ic_lock_bb" SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, `weak self` 캡처 4곳과 `ic_new_community_lock` 사용 확인
- `git diff --check` 실행, 출력 없이 성공 확인
### 2026-06-12 Phase 1/2 리뷰 추가 보완
- 목적: Phase 1/2 리뷰에서 확인된 커뮤니티 카드 PRD 표시 누락과 사업자 정보 더보기 판정 안정성 보완
- 수행 내용:
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`에 생성 시간 표시용 `createdAt` 입력과 footer 렌더링 추가
- 잠금 이미지의 가격 표시를 단순 텍스트에서 pay capsule 형태로 변경
- `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`의 측정 높이 변화 감지를 추가해 부모 폭/레이아웃 변경 시 더보기 표시 판정이 갱신되도록 보완
- 검증:
- `rg "createdAt|Capsule\(\)|onChange\(of: proxy.size.height\)" SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift` 실행, 생성 시간 입력/표시, pay capsule, 높이 변화 감지 검색 확인
- `git diff --check` 실행, 출력 없이 성공 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인

View File

@@ -0,0 +1,300 @@
# PRD: 메인 홈 추천 UI와 API 연동
## 1. Overview
메인 홈의 `추천` 탭을 Figma 디자인 기준으로 구성하고, 신규 홈 추천 API(`/api/v2/home/recommendations`)와 모두 팔로우 API(`/api/v2/home/recommendations/creators/follow`)를 연동한다.
## 2. Problem
- 기존 홈 API에 추천 탭 전용 응답을 추가하지 않고 신규 V2 API로 분리해야 한다.
- Figma 기준 추천 탭에는 라이브, 배너, 최근 활동 크리에이터, 데뷔/첫 오디오/AI 캐릭터/장르/응원/커뮤니티/사업자 정보 등 여러 반복 UI가 포함되어 있다.
- 반복 UI가 많아 단일 화면에 직접 구현하면 유지보수 비용이 커진다.
- 사업자 정보 텍스트는 외부 라이브러리 없이 3줄 말줄임표와 더보기/접기 토글을 제공해야 한다.
## 3. Goals
- `추천` 탭 진입 시 `/api/v2/home/recommendations`를 호출하고 응답 데이터로 화면을 구성한다.
- 기존 `HomeApi`에 케이스를 추가하지 않고 신규 API/Repository/ViewModel 계층을 만든다.
- Figma에서 확인한 기존 widget과 저장소 내 V2 공용 컴포넌트를 가능한 범위에서 재사용한다.
- 반복되는 UI는 Custom Widget으로 분리해 재사용 가능하게 한다.
- 모두 팔로우 API 호출이 정상 완료되고 `success == true`이면 버튼 문구를 `모두 팔로우 완료`로 변경한다.
- `RecommendedActivityType` 서버 enum 값은 앱 enum으로 변환하고 다국어 문구로 표시한다.
- 빈 섹션 데이터는 제목과 빈 컨테이너를 노출하지 않아 화면 밀도를 유지한다.
## 4. Non-Goals
- `추천 필모그래피` 섹션은 만들지 않는다.
- `또 다른 모습` 섹션은 만들지 않는다.
- 기존 홈 API(`/api/home`) 또는 기존 `HomeApi`에 추천 API를 추가하지 않는다.
- 외부 라이브러리를 추가하지 않는다.
- 서버 응답 스펙에 없는 팔로우 개별 상태, 페이지네이션, 정렬/필터 기능은 이번 범위에 포함하지 않는다.
- Figma 로컬 asset URL을 앱 코드에 직접 사용하지 않는다.
## 5. Target Users
- 앱 홈에서 추천 크리에이터, 라이브, 콘텐츠, 커뮤니티를 빠르게 탐색하는 일반 사용자
- 추천된 크리에이터 그룹을 한 번에 팔로우하려는 사용자
## 6. User Stories
- 사용자는 홈 `추천` 탭에서 현재 라이브 중인 크리에이터와 추천 콘텐츠를 한 화면에서 보고 싶다.
- 사용자는 관심 있는 장르/응원 크리에이터 그룹을 한 번에 팔로우하고 싶다.
- 사용자는 사업자 정보를 기본적으로 짧게 보고, 필요할 때 전체 내용을 펼쳐 보고 싶다.
- 사용자는 최근 활동 타입을 한국어/영어/일본어 등 현재 앱 언어에 맞게 보고 싶다.
## 7. Core Features
### 7.1 추천 홈 데이터 조회
#### API
- Method: `GET`
- Path: `/api/v2/home/recommendations`
- 인증: 기존 인증 헤더 패턴과 동일하게 `Authorization: Bearer {token}` 사용
- 응답 래퍼: 기존 관례대로 `ApiResponse<HomeRecommendationResponse>` 디코딩
#### Response
```kotlin
data class HomeRecommendationResponse(
val lives: List<HomeLiveItem>,
val banners: List<HomeBannerItem>,
val recentlyActiveCreators: List<HomeActiveCreatorItem>,
val recentDebutCreators: List<HomeCreatorItem>,
val firstAudioContents: List<HomeFirstAudioContentItem>,
val aiCharacters: List<HomeAiCharacterItem>,
val genreCreators: List<HomeGenreCreatorGroupItem>,
val cheerCreators: List<HomeCreatorItem>,
val popularCommunityPosts: List<HomePopularCommunityPostItem>
)
```
Swift 모델은 위 필드명을 그대로 `Decodable`로 구성한다. 서버 nullable 필드는 Swift optional로 선언한다.
### 7.2 모두 팔로우 하기
#### API
- Method: `POST`
- Path: `/api/v2/home/recommendations/creators/follow`
- Request body:
```kotlin
data class FollowRecommendedCreatorsRequest(
val creatorIds: List<Long>?
)
```
- 응답 래퍼: 기존 관례대로 `ApiResponseWithoutData` 디코딩
#### 동작
- 장르 크리에이터 그룹과 최근 응원이 많은 크리에이터 섹션의 `모두 팔로우하기` 버튼에서 호출한다.
- 요청 `creatorIds`는 해당 섹션에 표시된 크리에이터의 `creatorId` 목록을 사용한다.
- API 호출 중 중복 터치를 막는다.
- 호출 완료 후 `success == true`이면 해당 섹션 버튼 상태를 완료로 전환한다.
- 완료 상태 버튼 문구는 `모두 팔로우 완료`로 표시한다.
- 완료 상태 버튼 아이콘은 `ic_new_following`을 사용한다.
- 실패 시 기존 앱 에러 표시 관례에 맞춰 공통 에러 또는 서버 message를 노출하고 버튼 상태는 변경하지 않는다.
### 7.3 활동 타입 다국어 표시
서버 enum:
```kotlin
enum class RecommendedActivityType(val code: String) {
LIVE("LIVE"),
AUDIO("AUDIO"),
COMMUNITY("COMMUNITY"),
LIVE_REPLAY("LIVE_REPLAY")
}
```
앱 변환:
- `LIVE`, `LIVE_REPLAY` -> `I18n.HomeRecommendation.activityLive`
- `AUDIO` -> `I18n.HomeRecommendation.activityAudio`
- `COMMUNITY` -> `I18n.HomeRecommendation.activityCommunity`
- 알 수 없는 값은 빈 문자열 또는 서버 코드 직접 표시 대신 해당 아이템의 보조 문구를 숨긴다.
다국어 기본 문구:
- ko: `라이브`, `오디오`, `커뮤니티`
- en: `Live`, `Audio`, `Community`
- ja: `ライブ`, `オーディオ`, `コミュニティ`
### 7.4 사업자 정보 더보기/접기
요구사항:
- 외부 라이브러리를 사용하지 않는다.
- 기본 상태는 최대 3줄 표시, 말줄임표 적용, `더보기` 액션 제공
- `더보기` 터치 시 전체 표시로 전환하고 `접기` 액션 제공
- `접기` 터치 시 다시 3줄 말줄임표 상태로 돌아간다.
구현 방향:
- SwiftUI `Text``lineLimit(isExpanded ? nil : 3)`를 사용한다.
- 더보기/접기 버튼은 텍스트 하단 우측 또는 Figma 사업자 정보 섹션 내 자연스러운 위치에 배치한다.
- 실제 텍스트가 3줄 이하이면 더보기 버튼은 숨긴다. 줄 수 판정은 Geometry 기반 측정 또는 제한/무제한 높이 비교 방식으로 구현한다.
### 7.5 커뮤니티 포스트 카드
참조 Figma:
- Text Only: `node-id=446-9688`
- Text + Img, 유료 + 구매하지 않음: `node-id=446-9690`
- Text + Img, 유료/무료 + 구매함: `node-id=446-9691`
요구사항:
- `CommunityPostCard`는 다른 페이지에서도 재사용 가능하도록 `SodaLive/Sources/V2/Component/Card` 아래에 둔다.
- 텍스트 전용, 이미지 포함, 유료 이미지 잠금 상태를 지원한다.
- `imageUrl == nil`이면 Text Only variant로 표시한다.
- `imageUrl != nil && price > 0 && existOrdered == false`이면 이미지 영역에 blur/lock/pay capsule을 표시한다.
- `imageUrl != nil && (price <= 0 || existOrdered == true)`이면 이미지를 일반 노출한다.
- 유료/무료 + 구매함 Figma 카드의 우측 상단 `구매완료` 캡슐은 구현하지 않는다.
- 본문, 작성자, 생성 시간, 좋아요 수, 댓글 수는 표시한다.
## 8. UX / UI Expectations
### 8.1 Figma 기준 화면 구성
참조 Figma:
- 추천 화면: `node-id=24-5514`
- 모두 팔로우 완료 버튼: `node-id=24-9092`
Figma 확인 결과 추천 화면은 검정 배경, 상단 홈 타이틀/탭, 하단 메인 탭바 사이에 세로 스크롤 콘텐츠로 구성된다. 주요 섹션은 카드/가로 스크롤/그리드 조합이며, 반복 프로필과 버튼은 재사용 위젯화가 필요하다.
### 8.2 UI 배치 도식
아래 도식은 구현 대상만 포함한다. `추천 필모그래피`, `또 다른 모습`은 제외한다.
```text
MainHomeView
└─ HomeTitleBar 재사용
└─ 홈 상단 탭(추천/랭킹/팔로잉)
└─ ScrollView
├─ 현재 라이브 섹션
│ └─ MainHomeLiveSection 신규
│ └─ MainHomeLiveItem 신규
├─ 배너 섹션
│ └─ BannerCarousel 신규
├─ 최근 활동 크리에이터 섹션
│ └─ MainHomeActiveCreatorSection 신규
│ └─ MainHomeActiveCreatorItem 신규
├─ 최근 데뷔한 크리에이터 섹션
│ ├─ SectionTitle 재사용
│ └─ CreatorProfileGrid 재사용
│ └─ CreatorProfileItem 재사용
├─ 처음 만나는 오디오 섹션
│ ├─ SectionTitle 재사용
│ └─ AudioContentCard 재사용/확장
├─ AI 캐릭터 섹션
│ ├─ SectionTitle 재사용
│ └─ AiCharacterCard 신규
├─ 장르의 크리에이터 섹션
│ └─ MainHomeCreatorGroupSection 신규
│ ├─ SectionTitle 재사용(size 보정 필요 시 신규 variant)
│ ├─ MainHomeCreatorGrid 신규
│ ├─ CreatorProfileItem 재사용
│ └─ FollowAllButton 재사용
├─ 최근 응원이 많은 크리에이터 섹션
│ └─ MainHomeCreatorGroupSection 재사용
├─ 인기 커뮤니티 섹션
│ ├─ SectionTitle 재사용
│ └─ CommunityPostCard 재사용
└─ 사업자 정보 섹션
└─ MainHomeBusinessInfoSection 신규
└─ ExpandableTextView 재사용
└─ MainTabBarView 재사용 가능 여부 확인 후 적용
```
### 8.3 재사용 컴포넌트 후보
- `SodaLive/Sources/V2/Component/HomeTitleBar.swift`: 홈 상단 로고/메뉴 타이틀바
- `SodaLive/Sources/V2/Component/SectionTitle.swift`: 섹션 타이틀과 우측 액션 아이콘
- `SodaLive/Sources/V2/Component/AudioContentCard.swift`: `firstAudioContents` 카드 기본 구조
- `SodaLive/Sources/V2/Main/MainTabBarView.swift`: 하단 탭바가 V2 홈 구조와 맞는 경우 재사용
- `SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift`: 배너 carousel 패턴 참고. 홈 배너 타입과 이동 규칙이 달라 직접 재사용 여부는 계획 단계에서 재확인한다.
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityItemView.swift`: 커뮤니티 카드의 작성자/본문/리액션 패턴 참고. 이번 홈 추천 응답 타입과 더보기 요구가 달라 직접 재사용보다는 신규 위젯 생성이 우선이다.
### 8.4 신규 파일/그룹 후보
`MainView`의 홈 탭에서 표시되는 페이지 조립 계층은 `SodaLive/Sources/V2/Main/Home` 아래에 둔다.
```text
SodaLive/Sources/V2/Main/Home
├─ MainHomeView.swift
├─ MainHomeViewModel.swift
├─ Repository
│ ├─ MainHomeApi.swift
│ └─ MainHomeRepository.swift
├─ Models
│ ├─ MainHomeRecommendationResponse.swift
│ ├─ FollowRecommendedCreatorsRequest.swift
│ └─ RecommendedActivityType.swift
└─ Components
├─ MainHomeLiveSection.swift
├─ MainHomeLiveItem.swift
├─ MainHomeActiveCreatorSection.swift
├─ MainHomeActiveCreatorItem.swift
├─ MainHomeRecentDebutCreatorSection.swift
├─ MainHomeFirstAudioContentSection.swift
├─ MainHomeAiCharacterSection.swift
├─ MainHomeGenreCreatorSection.swift
├─ MainHomeCheerCreatorSection.swift
├─ MainHomeCreatorGroupSection.swift
├─ MainHomeCreatorGrid.swift
├─ MainHomePopularCommunitySection.swift
└─ MainHomeBusinessInfoSection.swift
```
`MainHome`에서만 사용하는 섹션 조립 컴포넌트는 `SodaLive/Sources/V2/Main/Home/Components` 아래에 둔다. 여러 페이지에서 재사용 가능성이 있는 UI widget은 `SodaLive/Sources/V2/Component` 아래에서 형태별 폴더에 둔다. 공용 widget은 특정 페이지나 API 이름 접두사를 붙이지 않고, 가능한 한 API 모델에 직접 의존하지 않으며 표시용 프로퍼티 또는 작은 display model을 받아 재사용성을 확보한다.
```text
SodaLive/Sources/V2/Component
├─ Banner
│ └─ BannerCarousel.swift
├─ Card
│ ├─ AiCharacterCard.swift
│ └─ CommunityPostCard.swift
├─ Creator
│ ├─ CreatorProfileGrid.swift
│ └─ CreatorProfileItem.swift
├─ Button
│ └─ FollowAllButton.swift
└─ Text
└─ ExpandableTextView.swift
```
단, 구현 중 특정 widget이 홈 탭에서만 의미가 있고 재사용성이 없다고 판단되면 `SodaLive/Sources/V2/Main/Home/Components` 안에 유지한다. 반대로 이미 존재하는 공용 컴포넌트로 충분한 경우 신규 파일을 만들지 않는다.
### 8.5 컴포넌트 위치 결정 기준
- `BannerCarousel`: 다른 페이지에서도 배너 carousel로 재사용 가능하므로 `SodaLive/Sources/V2/Component/Banner`에 둔다.
- 방금 활동한 크리에이터 UI: MainHome에서만 사용하므로 `MainHomeActiveCreatorSection`, `MainHomeActiveCreatorItem``SodaLive/Sources/V2/Main/Home/Components`에 둔다.
- 현재 라이브 UI: 별도 재사용 요구가 없으므로 `MainHomeLiveSection`, `MainHomeLiveItem``SodaLive/Sources/V2/Main/Home/Components`에 둔다.
- 최근 데뷔한 크리에이터 UI: 다른 페이지에서도 재사용 가능하므로 공용 `CreatorProfileGrid`, `CreatorProfileItem`을 사용하고, 섹션 조립만 `MainHomeRecentDebutCreatorSection`에 둔다.
- `AiCharacterCard`: 다른 페이지에서도 캐릭터 카드로 재사용 가능하므로 `SodaLive/Sources/V2/Component/Card`에 둔다.
- 장르/응원이 많은 크리에이터: 그리드 그룹 구조는 MainHome 전용이므로 `MainHomeCreatorGroupSection`, `MainHomeCreatorGrid``SodaLive/Sources/V2/Main/Home/Components`에 둔다. 개별 크리에이터 아이템만 `CreatorProfileItem`으로 재사용한다.
- 커뮤니티 섹션: 섹션 조립은 `MainHomePopularCommunitySection`에 두고, `CommunityPostCard`는 다른 페이지에서도 재사용 가능하므로 `SodaLive/Sources/V2/Component/Card`에 둔다.
- 사업자 정보 섹션: 섹션 wrapper는 `MainHomeBusinessInfoSection`으로 `SodaLive/Sources/V2/Main/Home/Components`에 둔다. 3줄 말줄임표, 더보기, 접기를 담당하는 텍스트 UI는 다른 곳에서도 사용할 수 있는 `ExpandableTextView`로 분리해 `SodaLive/Sources/V2/Component/Text`에 둔다.
## 9. Technical Constraints
- 앱 소스 변경은 기본적으로 `SodaLive/Sources/**`에서 수행한다.
- `MainView` 홈 탭에서 표시되는 페이지 루트, ViewModel, Repository, API, Models와 MainHome 전용 섹션 컴포넌트는 `SodaLive/Sources/V2/Main/Home/**` 아래에 작성한다.
- 여러 페이지에서 재사용 가능한 UI widget은 `SodaLive/Sources/V2/Component/**` 아래에 형태별 폴더로 작성한다.
- 순수 공용성이 더 큰 컴포넌트는 구현 시점에 `SodaLive/Sources/V2/Component/**`의 더 적합한 하위 그룹으로 이동할 수 있다.
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
- 기존 `HomeApi`에 추천 API를 추가하지 않는다.
- 외부 라이브러리를 추가하지 않는다.
- 이미지 로딩은 기존 앱에서 사용하는 이미지 컴포넌트/패턴을 따른다.
- 인증 헤더는 기존 `UserDefaultsKey.token` 기반 패턴을 따른다.
- `creatorId`, `liveRoomId`, `bannerId`, `postId` 등 서버 Long 값은 Swift에서 `Int` 또는 `Int64` 중 기존 라우팅/모델 관례와 맞는 타입을 사용한다. 계획 단계에서 실제 이동 대상 API 타입과 맞춰 확정한다.
- API 날짜 문자열(`beginDateTime`, `activityAt`, `releaseDate`, `createdAt`)은 기존 날짜 포맷 유틸이 있으면 재사용한다.
## 10. Success Criteria
- 추천 탭에서 `/api/v2/home/recommendations`를 호출하고 `success == true` 응답 데이터를 섹션별로 렌더링한다.
- 응답 배열이 비어 있는 섹션은 화면에 표시하지 않는다.
- 제외 섹션인 `추천 필모그래피`, `또 다른 모습`은 코드와 화면에 포함하지 않는다.
- 모두 팔로우 API 성공 시 해당 버튼이 Figma 완료 디자인에 맞게 `모두 팔로우 완료``ic_new_following` 상태로 변경된다.
- 사업자 정보는 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다.
- `LIVE`, `LIVE_REPLAY`, `AUDIO`, `COMMUNITY` 활동 타입이 I18n 문구로 표시된다.
- 반복 UI가 Custom Widget으로 분리되어 같은 프로필/그룹/버튼 구조를 중복 구현하지 않는다.
- 빌드가 성공하고, 가능하면 ViewModel 단위의 응답 디코딩 및 모두 팔로우 성공 상태 테스트가 통과한다.
## 11. Metrics
- 추천 API 성공/실패 여부
- 모두 팔로우 API 호출 성공/실패 여부
- 추천 탭 첫 로딩 완료 시간
- 모두 팔로우 버튼 터치 후 완료 상태 전환 여부
## 12. Open Questions
- 배너 `type`별 이동 규칙(`eventId`, `creatorId`, `seriesId`, `link`)을 어떤 기존 라우팅과 연결할지 확인이 필요하다.
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
- `creatorIds == null` 요청을 허용해야 하는지, 앱에서는 빈 배열/비어 있는 섹션일 때 버튼을 숨기는 것으로 제한할지 확인이 필요하다.
- 모두 팔로우 완료 상태를 앱 세션 동안만 유지할지, 추천 API 재조회 후에도 서버 상태 기반으로 유지할지 확인이 필요하다.
- 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다.
## 13. Verification Plan
- PRD 검증: 요구사항, 제외 범위, API URL, 응답 모델, Figma 노드가 문서에 반영되었는지 확인한다.
- 구현 후 빌드 검증: `docs/agent-guides/build-test-verification.md` 기준 명령으로 iOS 빌드 또는 가능한 최소 검증을 실행한다.
- 구현 후 기능 검증: 추천 API 성공/실패, 빈 섹션, 모두 팔로우 성공/실패, 사업자 정보 더보기/접기, 활동 타입 I18n 표시를 확인한다.

View File

@@ -8,6 +8,21 @@
- API는 `enum ...Api: TargetType`, 저장소는 `final class ...Repository` 형태를 우선 사용한다. - API는 `enum ...Api: TargetType`, 저장소는 `final class ...Repository` 형태를 우선 사용한다.
- 상태 모델은 `struct`/`enum` 중심으로 두고, 화면 상태는 `ObservableObject`에서 관리한다. - 상태 모델은 `struct`/`enum` 중심으로 두고, 화면 상태는 `ObservableObject`에서 관리한다.
## 컴포넌트 위치 규칙
- 여러 페이지에서 재사용 가능한 컴포넌트는 `SodaLive/Sources/V2/Component/**` 아래에 작성한다.
- 공용 컴포넌트는 기능/페이지 접두사가 아니라 UI 형태나 역할 기준 폴더에 배치한다.
- 카드 형태: `SodaLive/Sources/V2/Component/Card`
- 배너/캐러셀 형태: `SodaLive/Sources/V2/Component/Banner`
- 텍스트 표시/확장/축약 형태: `SodaLive/Sources/V2/Component/Text`
- 버튼 형태: `SodaLive/Sources/V2/Component/Button`
- 크리에이터 프로필/그리드 형태: `SodaLive/Sources/V2/Component/Creator`
- 공용 컴포넌트 타입명은 `HomeRecommendation...`, `MainHome...`처럼 특정 페이지나 API 이름을 접두사로 붙이지 않는다. 예: `CommunityPostCard`, `BannerCarousel`, `ExpandableTextView`.
- 공용 컴포넌트는 특정 API 응답 모델에 직접 의존하지 않는 것을 우선한다. 필요한 값은 표시용 프로퍼티 또는 작은 display model로 받는다.
- 특정 페이지 내부에서만 사용하는 컴포넌트는 해당 페이지 폴더 하위 `Components`에 둔다. 예: `SodaLive/Sources/V2/Main/Home/Components/MainHomeLiveSection.swift`.
- 페이지 전용 컴포넌트는 페이지 문맥을 드러내기 위해 `MainHome...`처럼 페이지 접두사를 사용할 수 있다.
- 처음에는 페이지 전용으로 만들었더라도 다른 페이지에서 재사용 요구가 생기면 `SodaLive/Sources/V2/Component/**`의 적절한 형태별 폴더로 이동한다.
- 공용으로 만들지 페이지 전용으로 둘지 애매하면, 현재 요청 범위 기준으로만 판단한다. 재사용 근거가 없으면 페이지 전용으로 시작한다.
## 임포트 규칙 ## 임포트 규칙
- 시스템 프레임워크(`Foundation`, `SwiftUI`, `Combine`)를 먼저 배치한다. - 시스템 프레임워크(`Foundation`, `SwiftUI`, `Combine`)를 먼저 배치한다.
- 서드파티(`Moya`, `CombineMoya`, SDK들)는 이후 배치한다. - 서드파티(`Moya`, `CombineMoya`, SDK들)는 이후 배치한다.

View File

@@ -10,18 +10,24 @@
- 문서 작성 자체, 커밋, 단순 조회처럼 구현을 수반하지 않는 작업은 필요한 최소 문서만 작성한다. - 문서 작성 자체, 커밋, 단순 조회처럼 구현을 수반하지 않는 작업은 필요한 최소 문서만 작성한다.
## PRD 문서 규칙 ## PRD 문서 규칙
- PRD 문서는 `docs/prd/` 아래에 작성한다. - PRD 문서는 `docs/[날짜]_구현할내용한글/prd.md` 작성한다.
- PRD 문서 파일명은 기존 계획 문서 파일명 규칙을 따르되 계획/TASK 문서와 구분되도록 `[날짜]_구현할내용한글_PRD.md` 형식을 사용한다. - 문서 폴더명은 `[날짜]_구현할내용한글` 형식을 사용한다.
- 문서 폴더명에서 원래 띄어쓰기가 들어갈 위치는 공백 대신 `_`를 사용한다.
- 이 경로 규칙은 신규 작성 문서부터 적용하며, 기존 과거 문서는 이동하거나 변경하지 않는다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- PRD 문서는 `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌해 작성한다. - PRD 문서는 `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌해 작성한다.
- PRD에는 최소한 목표, 문제/배경, 성공 기준, 제외 범위, 핵심 요구사항, 기술/운영 제약, 미결정 사항을 포함한다. 해당 없는 항목은 억지로 채우지 않고 제외하거나 `해당 없음`으로 명시한다. - PRD에는 최소한 목표, 문제/배경, 성공 기준, 제외 범위, 핵심 요구사항, 기술/운영 제약, 미결정 사항을 포함한다. 해당 없는 항목은 억지로 채우지 않고 제외하거나 `해당 없음`으로 명시한다.
## 계획/TASK 문서 규칙 ## 계획/TASK 문서 규칙
- 계획/TASK 문서는 `docs/plan-task/` 아래에 작성한다. - 계획/TASK 문서는 PRD와 같은 문서 폴더 아래에 `plan-task.md` 작성한다.
- 계획/TASK 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다. - 계획/TASK 문서 경로는 `docs/[날짜]_구현할내용한글/plan-task.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 계획/TASK 문서는 보강 완료된 PRD를 기준으로 작성하고, 구현 범위가 PRD의 성공 기준과 제외 범위를 벗어나지 않게 한다. - 계획/TASK 문서는 보강 완료된 PRD를 기준으로 작성하고, 구현 범위가 PRD의 성공 기준과 제외 범위를 벗어나지 않게 한다.
- 구현 항목은 기능/작업 단위 체크박스(`- [ ]`)로 작성하고 완료 즉시 `- [x]`로 갱신한다. - 연속된 하나의 작업이라면 별도 새 문서를 만들지 말고 기존 PRD와 계획/TASK 문서에 추가 작업으로 이어서 기록한다.
- 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다.
- 각 phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성하고 완료 즉시 `- [x]`로 갱신한다.
- 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다.
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
- 작업 도중 범위가 변경되면 PRD를 먼저 보강하고 계획/TASK 문서 체크리스트를 업데이트한 뒤 구현한다. - 작업 도중 범위가 변경되면 PRD를 먼저 보강하고 계획/TASK 문서 체크리스트를 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다. - 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다. - 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.

View File

@@ -0,0 +1,129 @@
# 20260519 섹션 타이틀 컴포넌트 계획
> 구현 시 문서 범위를 벗어나지 않는다. 신규 UI 컴포넌트 작업이므로 구현 단계에서는 시각/UI 전용 실행 경로를 사용한다.
**Goal:** Figma 노드 `20:3614`의 42pt 섹션 타이틀을 V2 SwiftUI 재사용 컴포넌트로 구현한다.
**Architecture:** 신규 `SectionTitle` View 하나를 `SodaLive/Sources/V2/Component`에 추가한다. 기존 `TitleBar` 계열은 화면 상단 네비게이션 바 책임을 유지하고, 섹션 헤더는 별도 컴포넌트로 분리한다.
**Tech Stack:** SwiftUI, existing `SodaSpacing`, existing `SodaTypography`, existing asset catalog
---
## 기준 문서
- PRD: `docs/prd/20260519_섹션타이틀컴포넌트_PRD.md`
- 검증 가이드: `docs/agent-guides/build-test-verification.md`
- 코드 스타일: `docs/agent-guides/code-style.md`
## 구현 대상 파일 후보
### 생성
- `SodaLive/Sources/V2/Component/SectionTitle.swift`: Figma 섹션 타이틀의 레이아웃, 스타일, 버튼 동작, Preview 담당
### 참조
- `SodaLive/Sources/V2/Component/TitleBar.swift`: 기존 V2 컴포넌트 스타일 참고
- `SodaLive/Sources/Extensions/FontModifier.swift`: `.appFont(.heading3)`의 20pt bold 매핑 확인
- `SodaLive/Sources/UI/Theme/Spacing.swift`: `SodaSpacing.s20` 확인
- `SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json`: 우측 chevron 에셋 확인
### 수정하지 않음
- `SodaLive/Sources/V2/Component/TitleBar.swift`: 기존 상단 바 동작 유지
- `SodaLive/Sources/V2/Component/DefaultTitleBar.swift`: 기존 상단 바 동작 유지
- `SodaLive/Sources/V2/Component/HomeTitleBar.swift`: 기존 상단 바 동작 유지
- `SodaLive/Resources/Assets.xcassets/**`: 신규 에셋 추가 없음
## 구현 체크리스트
### Task 1: 에셋 확인
**Files:** `SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json`
- [x] `ic_chevron_right.imageset` 아래 `Contents.json``ic_chevron_right.png`가 존재하는지 확인한다.
- [x] `Contents.json``images` 배열이 있고 `ic_chevron_right.png`가 등록되어 있는지 확인한다.
- [x] QA: `Image("ic_chevron_right")`로 참조 가능한 asset catalog 이름인지 확인한다.
### Task 2: SectionTitle 작성
**Files:** `SodaLive/Sources/V2/Component/SectionTitle.swift`
- [x] `struct SectionTitle: View`를 생성한다.
- [x] `title: String`, `action: (() -> Void)? = nil` 초기화 API를 제공한다.
- [x] `HStack(alignment: .center, spacing: 0)`에 제목, `Spacer(minLength: 0)`, 조건부 chevron을 배치한다.
- [x] 최상위 content에 `.padding(.horizontal, SodaSpacing.s20)`, `.frame(maxWidth: .infinity)`, `.frame(height: 42, alignment: .center)`를 적용한다.
- [x] 제목은 `.appFont(.heading3)`, `.foregroundColor(.white)`, `.lineLimit(1)`, `.truncationMode(.tail)`을 적용한다.
- [x] `action`이 있을 때 chevron은 `Image("ic_chevron_right")`를 사용하고 24x24로 표시한다.
- [x] chevron은 plain `Button`으로 감싸고 `.accessibilityLabel(title)`을 적용한다.
- [x] `action`이 없으면 chevron 없이 정적 content만 렌더링한다.
- [x] 제목 영역은 탭해도 `action`이 실행되지 않도록 한다.
### Task 3: Preview 작성
**Files:** `SodaLive/Sources/V2/Component/SectionTitle.swift`
- [x] action이 없는 chevron 미표시 케이스 Preview를 추가한다.
- [x] action이 있는 chevron 표시 케이스 Preview를 추가한다.
- [x] 긴 제목 말줄임 케이스 Preview를 추가한다.
- [x] QA: Preview 배경은 확인용으로만 지정하고 컴포넌트 본문에는 배경을 넣지 않는다.
### Task 4: 정적 진단
**Files:** `SodaLive/Sources/V2/Component/SectionTitle.swift`
- [x] `lsp_diagnostics`로 신규 Swift 파일의 정적 진단을 확인한다.
- [x] SourceKit 단일 파일 문맥 문제로 프로젝트 심볼 미해결이 발생하면 같은 변경을 `xcodebuild`로 검증한다.
- [x] 컴포넌트 구현에서 발생한 실제 Swift 오류가 있으면 `SectionTitle.swift`만 수정한다.
### Task 5: 빌드 검증
**Files:** `SodaLive.xcworkspace`, `SodaLive/Sources/V2/Component/SectionTitle.swift`
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행한다.
- [x] 가능한 경우 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`도 실행한다.
- [x] 환경 문제로 빌드가 실패하면 첫 번째 유효 오류 라인을 검증 기록에 남긴다.
- [x] 코드 문제로 빌드가 실패하면 `SectionTitle.swift`만 수정한 뒤 같은 명령을 재실행한다.
### Task 6: PRD 성공 기준 대조
**Files:** `docs/prd/20260519_섹션타이틀컴포넌트_PRD.md`, `SodaLive/Sources/V2/Component/SectionTitle.swift`
- [x] 높이 42, full width, 좌우 20pt 여백이 구현됐는지 확인한다.
- [x] 제목이 `.appFont(.heading3)`와 흰색으로 표시되는지 확인한다.
- [x] 긴 제목이 한 줄 말줄임 처리되는지 확인한다.
- [x] `action` 유무에 따른 chevron 표시 분기가 구현됐는지 확인한다.
- [x] `action`이 chevron 버튼에만 연결되고 제목 영역에는 연결되지 않았는지 확인한다.
- [x] 기존 `TitleBar`, `DefaultTitleBar`, `HomeTitleBar`를 수정하지 않았는지 확인한다.
## 구현 시 주의사항
- 기존 `TitleBar`, `DefaultTitleBar`, `HomeTitleBar`를 변경하지 않는다.
- 신규 디자인 토큰을 추가하지 않는다.
- `Image(systemName:)`을 사용하지 않는다.
- 화면 적용, 라우팅, API 연동은 별도 요청 전까지 하지 않는다.
- 파일 생성 위치는 `SodaLive/Sources/V2/Component`로 고정한다.
## 검증 기록
- 2026-05-19 문서 작성 전 Figma 노드 `20:3614`의 screenshot/design context를 확인해 높이 42, 좌우 20, 20pt bold title, 우측 24pt chevron 구조를 확인했다.
- 2026-05-19 문서 작성 전 `SodaLive/Sources/V2/Component/TitleBar.swift`, `DefaultTitleBar.swift`, `HomeTitleBar.swift`를 확인해 기존 V2 상단 바 컴포넌트와 책임을 분리해야 함을 확인했다.
- 2026-05-19 문서 작성 전 `SodaLive/Sources/Extensions/FontModifier.swift`를 확인해 `.appFont(.heading3)`가 20pt bold임을 확인했다.
- 2026-05-19 문서 작성 전 `SodaLive/Sources/UI/Theme/Spacing.swift`를 확인해 `SodaSpacing.s20` 사용 가능성을 확인했다.
- 2026-05-19 문서 작성 전 `SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json`을 확인해 `ic_chevron_right` 에셋 존재를 확인했다.
- 2026-05-19 문서 위치 보정: `AGENTS.md``docs/agent-guides/documentation-policy.md` 기준에 맞춰 PRD는 `docs/prd/20260519_섹션타이틀컴포넌트_PRD.md`, 계획/TASK는 `docs/plan-task/20260519_섹션타이틀컴포넌트.md`로 정리했다.
- 2026-05-19 구현: `SodaLive/Sources/V2/Component/SectionTitle.swift`를 추가하고, `SodaLive.xcodeproj/project.pbxproj``SectionTitle.swift``SodaLive`, `SodaLive-dev` 두 앱 타깃 Sources로 등록했다.
- 2026-05-19 수동 QA: `rg``SectionTitle.swift``action: (() -> Void)? = nil`, `Image("ic_chevron_right")`, 24x24 chevron, 42pt height, 프로젝트 Sources 등록 2건을 확인했다.
- 2026-05-19 에셋 검증: `plutil -p 'SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json'` 결과 `ic_chevron_right.png``images` 배열에 등록되어 있음을 확인했다.
- 2026-05-19 프로젝트 검증: `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행 결과 `OK`.
- 2026-05-19 LSP 진단: `SectionTitle.swift` 단일 파일 진단에서 `SodaSpacing`, `.appFont`, `.heading3`, `.white`, `.tail` 프로젝트 문맥 미해결 오류가 보고됐다. 동일 오류는 기존 `TitleBar.swift`에서도 재현되어 SourceKit 단일 파일 문맥 한계로 기록하고, 실제 컴파일 유효성은 아래 빌드로 확인했다.
- 2026-05-19 빌드 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행 결과 `** BUILD SUCCEEDED **`.
- 2026-05-19 빌드 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행 결과 `** BUILD SUCCEEDED **`. Crashlytics dSYM 및 일부 dependency scan 경고가 있었으나 빌드는 성공했다.
- 2026-05-19 테스트 액션 확인: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행 결과 `Scheme SodaLive-dev is not currently configured for the test action.`으로 테스트 타깃 미구성 상태를 확인했다.
- 2026-05-19 동작 수정: 별도 chevron 표시 API를 제거하고, `action`이 있을 때만 `ic_chevron_right` chevron 버튼을 표시하도록 변경했다. `action`은 전체 행이 아니라 chevron 버튼에만 연결해 제목 터치로는 실행되지 않게 했다.
- 2026-05-19 동작 수정 후 수동 QA: `rg``Text(title)``Button(action: action)`이 분리되어 있고, `Image("ic_chevron_right")`, 24x24 chevron, 42pt height, action 없는 Preview/action 있는 Preview가 존재함을 확인했다.
- 2026-05-19 동작 수정 후 LSP 진단: `SectionTitle.swift` 단일 파일 진단에서 기존과 같은 `SodaSpacing`, `.appFont`, `.heading3`, `.white`, `.tail` 프로젝트 문맥 미해결 오류가 보고됐다. 실제 컴파일 유효성은 아래 빌드로 확인했다.
- 2026-05-19 동작 수정 후 빌드 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행 결과 `** BUILD SUCCEEDED **`.
- 2026-05-19 동작 수정 후 빌드 검증: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행 결과 `** BUILD SUCCEEDED **`. Crashlytics dSYM 및 일부 dependency scan 경고가 있었으나 빌드는 성공했다.
- 2026-05-19 동작 수정 후 테스트 액션 확인: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` 실행 결과 `Scheme SodaLive-dev is not currently configured for the test action.`으로 테스트 타깃 미구성 상태를 재확인했다.

View File

@@ -0,0 +1,162 @@
# 20260519 오디오 콘텐츠 카드 컴포넌트 구현 계획
## 기준 문서
- PRD: `docs/prd/20260519_오디오콘텐츠카드컴포넌트_PRD.md`
- Figma source nodes: `20:3800`, `20:3818`, `20:3829`
- 코드 스타일: `docs/agent-guides/code-style.md`
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
## 작업 원칙
- PRD의 성공 기준과 제외 범위를 벗어나지 않는다.
- 기존 구현이 있더라도 PRD 기준으로 재검토하고, 맞지 않는 부분만 최소 수정한다.
- 신규 화면 적용, API 연동, asset 추가, dependency 추가는 하지 않는다.
- `series`, `ORIGINAL`, `FIRST`, `무료` 관련 구현은 이번 범위에서 제거한다.
- 수정 후 검증 기록은 이 문서 하단에 누적한다.
## 구현 대상 파일
- 생성 또는 수정: `SodaLive/Sources/V2/Component/AudioContentCard.swift`
- `AudioContentCardSize`: Figma 세 크기와 typography/spacing 값을 캡슐화한다.
- `AudioContentCard`: 썸네일과 라벨을 조합하는 public 재사용 View다.
- Preview: 세 크기를 한 번에 비교한다.
- 필요 시 수정: `SodaLive.xcodeproj/project.pbxproj`
- 신규 Swift 파일이 Xcode 빌드 대상에 포함되지 않는 경우에만 수정한다.
- 수정: `docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md`
- 구현 계획과 검증 기록을 유지한다.
## TASK 체크리스트
- [x] **Task 1: PRD 기준으로 기존 구현 상태 점검**
- `AudioContentCard.swift`가 존재하는지 확인한다.
- `AudioContentCardSize``large`, `medium`, `small`만 제공하는지 확인한다.
- 각 크기 값이 PRD와 일치하는지 확인한다.
- `large`: width 185, thumbnail 185x185, label width 185, label horizontal padding 0, title `.heading4`, subtitle `.body5`
- `medium`: width 163, thumbnail 163x163, label width 151, label horizontal padding 6, title `.heading4`, subtitle `.body5`
- `small`: width 122, thumbnail 122x122, label width 114, label horizontal padding 4, title `.body1`, subtitle `.caption2`
- 전체 공통: thumbnail-title gap 8, title-subtitle gap 2
- `series`, `ORIGINAL`, `FIRST`, `무료` 관련 API나 UI가 포함되어 있으면 제거 대상으로 표시한다.
- [x] **Task 2: 컴포넌트 API 정리**
- 호출부 API를 아래 형태로 유지한다.
```swift
AudioContentCard(
size: .large,
title: "오디오 콘텐츠 제목",
subtitle: "크리에이터명"
) {
Image("content_thumbnail")
.resizable()
.scaledToFill()
}
```
- `thumbnail``@ViewBuilder`로 주입한다.
- production code에 Figma localhost URL을 넣지 않는다.
- 실제 이미지 로딩 방식은 호출부 책임으로 둔다.
- [x] **Task 3: 시각 스펙 구현 또는 보정**
- 썸네일 영역은 `size.width x size.width`로 고정한다.
- 썸네일 corner radius는 `SodaSpacing.s14`를 사용한다.
- 썸네일과 제목 사이 간격은 모든 크기에서 8pt로 설정한다.
- 제목과 부제 사이 간격은 모든 크기에서 2pt로 설정한다.
- 라벨 horizontal padding은 `large` 0pt, `medium` 6pt, `small` 4pt로 설정한다.
- 제목/부제는 각각 `.lineLimit(1)``.truncationMode(.tail)`을 적용한다.
- 카드 자체는 배경색을 강제하지 않는다.
- `series`, `ORIGINAL`, `FIRST`, `무료` 관련 View, enum case, parameter, Preview 상태를 추가하지 않는다.
- [x] **Task 4: Preview 작성 또는 보정**
- Preview는 `large`, `medium`, `small` 세 크기를 모두 포함한다.
- Preview 썸네일은 외부 네트워크나 Figma localhost asset에 의존하지 않는다.
- Preview에는 태그 노출 조합을 포함하지 않는다.
- [x] **Task 5: Xcode 빌드 대상 포함 여부 확인**
- 신규 파일이 Xcode 프로젝트에 포함되어 있지 않으면 `SodaLive.xcodeproj/project.pbxproj`에 추가한다.
- 이미 포함되어 있거나 파일 시스템 동기화 방식으로 빌드되는 경우 프로젝트 파일을 수정하지 않는다.
- 프로젝트 파일을 수정한 경우 `plutil -lint SodaLive.xcodeproj/project.pbxproj`를 실행한다.
- [x] **Task 6: 검증 실행**
- 변경 Swift 파일에 대해 `lsp_diagnostics`를 실행한다.
- 단일 파일 문맥에서 프로젝트 공통 확장을 찾지 못하는 SourceKit 오류가 나오면 실제 빌드 결과로 최종 판단하되, 오류 내용을 검증 기록에 남긴다.
- 다음 빌드 명령을 실행한다.
```bash
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build
```
- test action 확인이 필요하면 다음 명령을 실행하고, 스킴 미구성 시 그 사실을 검증 기록에 남긴다.
```bash
xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test
```
- [x] **Task 7: 문서와 코드 일치 여부 최종 점검**
- PRD의 Success Criteria 각 항목을 코드와 대조한다.
- 구현 계획의 체크리스트가 실제 상태와 맞는지 갱신한다.
- 검증 기록을 하단에 누적한다.
## 검증 기록
### 2026-05-19 문서 재정리
- 목적: 구현보다 PRD/계획 문서를 먼저 정리하라는 사용자 피드백 반영
- 수행 내용:
- `docs/prd/20260519_오디오콘텐츠카드컴포넌트_PRD.md` 신규 작성
- `docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md`를 구현 결과 요약에서 구현 계획 문서로 재작성
- 아직 수행하지 않은 작업:
- PRD 기준 코드 재수정
- TASK 체크리스트 완료 처리
- 신규 빌드 검증
### 2026-05-19 범위 축소 반영
- 목적: 이번 범위에서 제외할 항목 명시
- 반영 내용:
- `series` 타입 콘텐츠 카드 제외
- `ORIGINAL` 태그 제외
- `FIRST` 태그 제외
- `무료` 태그 제외
- 계획 문서에서 태그 구현/Preview 항목 제거
### 2026-05-19 라벨 패딩 및 간격 스펙 반영
- 목적: Figma 컴포넌트의 라벨 위치와 텍스트 간격을 구현 전에 문서 기준으로 고정
- 반영 내용:
- label horizontal padding: `large` 0pt, `medium` 6pt, `small` 4pt
- thumbnail-title gap: 모든 크기 8pt
- title-subtitle gap: 모든 크기 2pt
### 2026-05-19 PRD 기준 구현
- 목적: PRD와 구현 계획 기준으로 오디오 콘텐츠 카드 컴포넌트 보정
- 수행 내용:
- `AudioContentCard` API에서 `showsFirstTag`, `showsFreeTag` 제거
- `AudioContentTag` 및 태그 관련 UI 제거
- label horizontal padding 적용: `large` 0pt, `medium` 6pt, `small` 4pt
- thumbnail-title gap 8pt, title-subtitle gap 2pt 적용
- Preview에서 태그 노출 상태 제거
- 확인 내용:
- `SodaLive.xcodeproj/project.pbxproj``AudioContentCard.swift`가 두 앱 타깃 Sources에 포함되어 있음
- 아직 수행하지 않은 작업:
- 해당 없음
### 2026-05-19 PRD 기준 구현 검증
- `lsp_diagnostics`:
- 결과: SourceKit 단일 파일 문맥에서 `SodaSpacing`, `SodaTypography`, `appFont`, `Color.gray800`, `Color.gray500` 등 프로젝트 공통 확장을 찾지 못하는 오류 표시
- 판단: 기존 V2 컴포넌트에서도 동일한 단일 파일 문맥 한계가 있어 실제 Xcode 빌드로 최종 판단
- `plutil -lint "SodaLive.xcodeproj/project.pbxproj"`:
- 결과: `SodaLive.xcodeproj/project.pbxproj: OK`
- `git diff --check`:
- 결과: 통과
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`:
- 결과: `BUILD SUCCEEDED`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`:
- 결과: `Scheme SodaLive-dev is not currently configured for the test action.`
- 문서/코드 대조:
- `large`, `medium`, `small` 크기 지원 확인
- label horizontal padding `large` 0pt, `medium` 6pt, `small` 4pt 반영 확인
- thumbnail-title gap 8pt, title-subtitle gap 2pt 반영 확인
- `series`, `ORIGINAL`, `FIRST`, `무료` 관련 API/UI 제거 확인

View File

@@ -0,0 +1,119 @@
# PRD: 섹션 타이틀 컴포넌트
## 1. Overview
Figma 노드 `20:3614`의 42pt 섹션 타이틀을 SwiftUI 재사용 컴포넌트로 정의한다. 신규 UI 컴포넌트 위치 규칙에 따라 구현 대상은 `SodaLive/Sources/V2/Component/**` 아래에 둔다.
---
## 2. Background
Figma 노드 `20:3614`는 402x42 크기의 섹션 타이틀 컴포넌트다. 좌측에는 `Pretendard Variable Bold 20pt` 텍스트가 있고, 우측에는 24x24 chevron-right 아이콘이 조건부로 표시된다.
최근 코드에는 다음 기반 요소가 이미 존재한다.
- `SodaLive/Sources/V2/Component/TitleBar.swift`: V2 상단 바 컴포넌트 스타일 참고 가능
- `SodaLive/Sources/Extensions/FontModifier.swift`: `SodaTypography.heading3``.appFont(_:)` 제공
- `SodaLive/Sources/UI/Theme/Spacing.swift`: `SodaSpacing.s20` 제공
- `SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset`: 우측 chevron 에셋 존재
---
## 3. Goals
- Figma 시안과 동일한 높이, 여백, 타이포그래피를 SwiftUI로 구현한다.
- 기존 V2 컴포넌트 관례와 디자인 토큰을 사용한다.
- 제목 텍스트와 우측 chevron 탭 동작을 호출부에서 제어할 수 있게 한다.
- 네비게이션 상단 바인 `TitleBar`와 책임을 분리한다.
---
## 4. Non-Goals
- 화면 단위 라우팅이나 API 연동은 포함하지 않는다.
- 신규 디자인 토큰을 추가하지 않는다.
- Figma의 React/Tailwind 생성 코드를 그대로 이식하지 않는다.
- 기존 `TitleBar`, `DefaultTitleBar`, `HomeTitleBar`의 동작을 변경하지 않는다.
---
## 5. Core Requirements
### 5.1 컴포넌트 API
- 컴포넌트 이름은 `SectionTitle`로 둔다.
- `title: String`을 호출부에서 전달한다.
- `action: (() -> Void)?`는 기본값 `nil`을 사용한다.
- `action`이 있으면 우측 chevron만 `Button`으로 렌더링한다.
- `action`이 없으면 chevron을 표시하지 않고 정적 `HStack`을 표시한다.
### 5.2 레이아웃
- Width: full
- Height: `42`
- Horizontal padding: `SodaSpacing.s20`
- Alignment: 세로 가운데 정렬
- 좌측 제목, `Spacer(minLength: 0)`, 우측 chevron의 `HStack` 구조를 사용한다.
- 우측 chevron은 `action != nil`일 때만 24x24 크기로 표시한다.
### 5.3 스타일
- 제목은 `Text(title).appFont(.heading3)`를 사용한다.
- 제목 색상은 `Color.white`를 사용한다.
- 제목은 한 줄로 제한하고 말줄임 처리한다.
- 컴포넌트 본문에는 배경색을 지정하지 않는다.
### 5.4 아이콘 및 접근성
- 우측 chevron은 기존 에셋 `ic_chevron_right`를 사용한다.
- SwiftUI `Image(systemName: "chevron.right")`는 앱 에셋 스타일과 다를 수 있으므로 사용하지 않는다.
- `action`이 있을 때 chevron 버튼의 접근성 라벨은 `title`을 사용한다.
- 제목 영역은 탭해도 `action`을 실행하지 않는다.
---
## 6. Recommended Approach
전용 `SectionTitle` 컴포넌트를 신규 추가한다. 기존 `TitleBar`를 확장하지 않는 이유는 `TitleBar`가 화면 상단 네비게이션 바 책임을 갖고 있고 높이도 60pt라, Figma의 42pt 섹션 헤더와 의미 및 치수가 다르기 때문이다.
단순 호출부 인라인 구현도 가능하지만 재사용성과 디자인 일관성이 떨어진다. 따라서 `SodaLive/Sources/V2/Component/SectionTitle.swift`에 작고 독립적인 View로 구현한다.
---
## 7. Technical Constraints
- SwiftUI 기반으로 작성한다.
- 신규 파일은 구현 시 `SodaLive/Sources/V2/Component/**` 아래에 둔다.
- 색상과 spacing은 기존 토큰을 우선 사용한다.
- 폰트는 기존 `.appFont(.heading3)` 사용을 우선한다.
- 프로젝트 설정 변경은 필요한 경우에만 수행한다.
---
## 8. Success Criteria
- 문서 기준 구현 후 `SectionTitle`은 높이 42, full width, 좌우 20pt 여백을 갖는다.
- 제목은 20pt bold에 해당하는 `.appFont(.heading3)`와 흰색으로 표시된다.
- 긴 제목은 한 줄 말줄임 처리된다.
- `action`이 있으면 우측 24x24 chevron이 표시되고, 없으면 표시되지 않는다.
- `action`은 chevron을 탭했을 때만 실행되고, 제목 영역을 탭했을 때는 실행되지 않는다.
- 기존 `TitleBar`, `DefaultTitleBar`, `HomeTitleBar` 동작은 바뀌지 않는다.
---
## 9. Decisions
- 파일 생성 위치는 `SodaLive/Sources/V2/Component/SectionTitle.swift`로 결정한다.
- 우측 chevron은 `action`이 있을 때만 `ic_chevron_right`를 사용한다.
- 신규 디자인 토큰은 추가하지 않는다.
- 이번 PRD는 Figma 노드 `20:3614` 단일 컴포넌트만 다룬다.
---
## 10. Verification Notes
- Figma 노드 `20:3614`의 스크린샷과 생성 코드에서 높이 42, 좌우 20, 20pt bold title, 우측 24pt chevron 구조를 확인했다.
- `SodaLive/Sources/V2/Component/TitleBar.swift`를 확인해 V2 컴포넌트의 SwiftUI 스타일을 확인했다.
- `SodaLive/Sources/Extensions/FontModifier.swift`를 확인해 `.appFont(.heading3)`가 20pt bold임을 확인했다.
- `SodaLive/Sources/UI/Theme/Spacing.swift`를 확인해 `SodaSpacing.s20` 사용 가능성을 확인했다.
- `SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json`을 확인해 `ic_chevron_right` 에셋 존재를 확인했다.

View File

@@ -0,0 +1,125 @@
# PRD: 오디오 콘텐츠 카드 컴포넌트
## 1. Overview
Figma의 오디오 콘텐츠 카드 3종(`large`, `medium`, `small`)을 iOS SwiftUI에서 재사용 가능한 V2 컴포넌트로 제공한다.
---
## 2. Problem
- Figma에 정의된 3개 컴포넌트는 형태가 동일하고 크기만 다르지만, 구현 전에 요구사항 문서가 충분히 정리되지 않아 구현 기준이 불명확했다.
- 크기, 라벨, 제외 범위, 에셋 처리 기준이 문서화되지 않으면 구현자가 임의로 API나 시각 요소를 해석할 수 있다.
- 향후 메인/콘텐츠 영역에서 동일한 오디오 카드가 반복 사용될 가능성이 있어 단일 기준 컴포넌트가 필요하다.
---
## 3. Goals
- Figma 노드 `20:3800`, `20:3818`, `20:3829`의 오디오 카드 공통 구조를 하나의 SwiftUI 컴포넌트로 표현한다.
- 호출부는 크기, 제목, 부제, 썸네일 표시 방식을 명시적으로 주입할 수 있다.
- 크기별 썸네일/라벨/폰트/간격 스펙을 문서와 코드에서 일관되게 유지한다.
- 외부 Figma localhost asset URL을 production code에 하드코딩하지 않는다.
- 신규 컴포넌트는 `SodaLive/Sources/V2/Component/**`의 기존 SwiftUI 컴포넌트 스타일을 따른다.
---
## 4. Non-Goals
- 이번 작업에서 실제 콘텐츠 API 연동, 네트워크 로딩 정책, 캐싱 정책은 구현하지 않는다.
- Figma의 임시 썸네일 이미지를 앱 asset으로 추가하지 않는다.
- `series` 타입 콘텐츠 카드는 구현하지 않는다.
- `ORIGINAL` 태그는 구현하지 않는다.
- `FIRST` 태그는 구현하지 않는다.
- `무료` 태그는 구현하지 않는다.
- 태그 아이콘 에셋은 이번 범위에서 추가하지 않는다.
- 시리즈 카드(`type = series`) 또는 세로형 콘텐츠 카드 전체를 함께 일반화하지 않는다.
- 기존 화면에 컴포넌트를 배치하거나 사용자 플로우를 변경하지 않는다.
---
## 5. Target Users
- V2 SwiftUI 화면을 구현하는 iOS 개발자
- 콘텐츠 카드 UI를 재사용해야 하는 홈/콘텐츠/오디오 영역 구현자
- Figma와 앱 구현 사이의 시각 기준을 확인해야 하는 리뷰어
---
## 6. User Stories
- 개발자는 `large`, `medium`, `small` 중 하나를 선택해 동일한 구조의 오디오 카드를 표시할 수 있다.
- 개발자는 실제 이미지, placeholder, `AsyncImage`, Kingfisher 기반 이미지 View 등 썸네일 표시 방식을 호출부에서 결정할 수 있다.
- 리뷰어는 PRD와 계획 문서만 보고 구현 범위와 제외 범위를 확인할 수 있다.
---
## 7. Core Features
### 7.1 단일 오디오 콘텐츠 카드 컴포넌트
#### Requirements
- 컴포넌트 파일은 `SodaLive/Sources/V2/Component/AudioContentCard.swift`에 둔다.
- 크기는 `AudioContentCardSize` enum으로 표현한다.
- 카드 구조는 썸네일 영역과 라벨 영역으로 분리한다.
- 제목과 부제는 각각 한 줄로 표시하고 초과 시 tail truncation 처리한다.
- 썸네일은 호출부가 `@ViewBuilder`로 주입한다.
#### Edge Cases
- 제목/부제가 긴 경우 한 줄 말줄임 처리한다.
- 태그는 이번 범위에서 표시하지 않는다.
### 7.2 크기별 스펙
| Size | Figma node | Card width | Thumbnail | Label width | Label horizontal padding | Title | Subtitle |
| --- | --- | ---: | ---: | ---: | ---: | --- | --- |
| `large` | `20:3800` | 185 | 185x185 | 185 | 0 | 18 bold | 14 medium |
| `medium` | `20:3818` | 163 | 163x163 | 151 | 6 | 18 bold | 14 medium |
| `small` | `20:3829` | 122 | 122x122 | 114 | 4 | 16 bold | 12 medium |
#### Requirements
- 썸네일은 모든 크기에서 정사각형이다.
- 썸네일 corner radius는 14pt다.
- `large`, `medium` 제목은 기존 typography 기준 `.heading4`를 사용한다.
- `small` 제목은 기존 typography 기준 `.body1`을 사용한다.
- `large`, `medium` 부제는 기존 typography 기준 `.body5`를 사용한다.
- `small` 부제는 기존 typography 기준 `.caption2`를 사용한다.
- 썸네일과 제목 사이 간격은 모든 크기에서 8pt다.
- 제목과 부제 사이 간격은 모든 크기에서 2pt다.
- 라벨 영역의 horizontal padding은 `large` 0pt, `medium` 6pt, `small` 4pt다.
## 8. UX / UI Expectations
- Figma와 동일한 카드 외곽 크기, 썸네일 크기, 라벨 폭을 유지한다.
- 썸네일은 호출부에서 전달한 View가 카드 영역을 채우도록 렌더링할 수 있어야 한다.
- 카드 자체는 배경색을 강제하지 않는다. 배경은 사용하는 화면이 결정한다.
- SwiftUI Preview는 세 크기를 한 화면에서 비교할 수 있게 제공한다.
---
## 9. Technical Constraints
- SwiftUI 기반으로 구현한다.
- 기존 디자인 토큰과 확장(`SodaSpacing`, `Color`, `SodaTypography`, `appFont`)을 우선 사용한다.
- 신규 dependency를 추가하지 않는다.
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
- production code에 Figma localhost asset URL을 넣지 않는다.
- 프로젝트 파일 수정은 신규 Swift 파일이 Xcode 빌드 대상에 포함되어야 할 때만 수행한다.
---
## 10. Success Criteria
- `AudioContentCard``large`, `medium`, `small` 세 크기를 모두 지원한다.
- 각 크기별 수치와 라벨 간격/패딩이 PRD의 size spec과 일치한다.
- 컴포넌트 API가 썸네일 View 주입, 제목, 부제를 명시적으로 제공한다.
- `series`, `ORIGINAL`, `FIRST`, `무료` 관련 API나 UI가 포함되지 않는다.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 성공한다.
- `docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md`에 구현 작업 순서와 검증 기록이 남는다.
---
## 11. Open Questions
- 컴포넌트를 어느 실제 화면에 처음 적용할지는 이번 PRD 범위 밖이다.
- 카드 접근성 label 조합 규칙은 실제 화면 적용 시 데이터 정책에 맞춰 확정한다.