Compare commits
9 Commits
942c581eaf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d6e32f5fb | ||
|
|
bc0bc55e3a | ||
|
|
ed5e92e1d6 | ||
|
|
016a8bcca3 | ||
|
|
606db35de8 | ||
|
|
90003f6ce1 | ||
|
|
6f8ccc7b1a | ||
|
|
d9b3990e06 | ||
|
|
ca8da51991 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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`를 참조한다.
|
||||||
|
|||||||
7461
SodaLive.xcodeproj/project.pbxproj
Normal file
7461
SodaLive.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
77
SodaLive.xcodeproj/xcshareddata/xcschemes/SodaLive.xcscheme
Normal file
77
SodaLive.xcodeproj/xcshareddata/xcschemes/SodaLive.xcscheme
Normal 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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
21
SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/ic_chevron_right.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_chevron_right.imageset/ic_chevron_right.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/ic_new_community_lock.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_community_lock.imageset/ic_new_community_lock.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 427 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/ic_new_follow.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_follow.imageset/ic_new_follow.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 386 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/ic_new_following.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_new_following.imageset/ic_new_following.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 364 B |
@@ -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>
|
||||||
|
|||||||
BIN
SodaLive/Resources/Font/Pattaya-Regular.ttf
Normal file
BIN
SodaLive/Resources/Font/Pattaya-Regular.ttf
Normal file
Binary file not shown.
BIN
SodaLive/Resources/Font/Phosphate-Solid.ttf
Normal file
BIN
SodaLive/Resources/Font/Phosphate-Solid.ttf
Normal file
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "チャンネル")
|
||||||
|
|||||||
154
SodaLive/Sources/V2/Component/AudioContentCard.swift
Normal file
154
SodaLive/Sources/V2/Component/AudioContentCard.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift
Normal file
58
SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
SodaLive/Sources/V2/Component/Button/FollowAllButton.swift
Normal file
58
SodaLive/Sources/V2/Component/Button/FollowAllButton.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift
Normal file
66
SodaLive/Sources/V2/Component/Card/AiCharacterCard.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift
Normal file
130
SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
SodaLive/Sources/V2/Component/SectionTitle.swift
Normal file
63
SodaLive/Sources/V2/Component/SectionTitle.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift
Normal file
81
SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift
Normal file
107
SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FollowRecommendedCreatorsRequest: Encodable {
|
||||||
|
let creatorIds: [Int]?
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift
Normal file
47
SodaLive/Sources/V2/Main/Home/Repository/MainHomeApi.swift
Normal 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))"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
460
docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md
Normal file
460
docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md
Normal 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` 확인
|
||||||
300
docs/20260602_메인_홈_추천_UI_API_연동/prd.md
Normal file
300
docs/20260602_메인_홈_추천_UI_API_연동/prd.md
Normal 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 표시를 확인한다.
|
||||||
@@ -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들)는 이후 배치한다.
|
||||||
|
|||||||
@@ -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 문서 체크리스트를 업데이트한 뒤 구현한다.
|
||||||
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
|
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
|
||||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
|
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
|
||||||
|
|||||||
129
docs/plan-task/20260519_섹션타이틀컴포넌트.md
Normal file
129
docs/plan-task/20260519_섹션타이틀컴포넌트.md
Normal 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.`으로 테스트 타깃 미구성 상태를 재확인했다.
|
||||||
162
docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md
Normal file
162
docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md
Normal 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 제거 확인
|
||||||
119
docs/prd/20260519_섹션타이틀컴포넌트_PRD.md
Normal file
119
docs/prd/20260519_섹션타이틀컴포넌트_PRD.md
Normal 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` 에셋 존재를 확인했다.
|
||||||
125
docs/prd/20260519_오디오콘텐츠카드컴포넌트_PRD.md
Normal file
125
docs/prd/20260519_오디오콘텐츠카드컴포넌트_PRD.md
Normal 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 조합 규칙은 실제 화면 적용 시 데이터 정책에 맞춰 확정한다.
|
||||||
Reference in New Issue
Block a user