feat(main-home): 추천 홈 공용 컴포넌트를 추가한다
This commit is contained in:
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 |
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
|
|
||||||
### Phase 2: ViewModel 상태와 공용 UI 컴포넌트 작성
|
### Phase 2: ViewModel 상태와 공용 UI 컴포넌트 작성
|
||||||
|
|
||||||
- [ ] **Task 2.1: MainHomeViewModel 작성**
|
- [x] **Task 2.1: MainHomeViewModel 작성**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 생성: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
|
- 생성: `SodaLive/Sources/V2/Main/Home/MainHomeViewModel.swift`
|
||||||
- 확인: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift`
|
- 확인: `SodaLive/Sources/V2/Main/Home/Repository/MainHomeRepository.swift`
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
- 기대 결과: ViewModel 상태와 API 처리 메서드가 검색된다.
|
- 기대 결과: ViewModel 상태와 API 처리 메서드가 검색된다.
|
||||||
- 수동 확인: 실패 처리에서 빈 `catch`를 사용하지 않아야 한다.
|
- 수동 확인: 실패 처리에서 빈 `catch`를 사용하지 않아야 한다.
|
||||||
|
|
||||||
- [ ] **Task 2.2: 공용 `ExpandableTextView` 작성**
|
- [x] **Task 2.2: 공용 `ExpandableTextView` 작성**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 생성: `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
|
- 생성: `SodaLive/Sources/V2/Component/Text/ExpandableTextView.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
- 기대 결과: 확장/접기 상태와 3줄 제한 구현 키워드가 검색된다.
|
- 기대 결과: 확장/접기 상태와 3줄 제한 구현 키워드가 검색된다.
|
||||||
- 수동 확인: 외부 라이브러리 import 없이 `SwiftUI` 기반으로 구현되어야 한다.
|
- 수동 확인: 외부 라이브러리 import 없이 `SwiftUI` 기반으로 구현되어야 한다.
|
||||||
|
|
||||||
- [ ] **Task 2.3: 공용 Creator 컴포넌트 작성**
|
- [x] **Task 2.3: 공용 Creator 컴포넌트 작성**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
|
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
|
||||||
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
|
- 생성: `SodaLive/Sources/V2/Component/Creator/CreatorProfileGrid.swift`
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
- 기대 결과: `CreatorProfileItem`, `CreatorProfileGrid`는 검색되고 API 응답 타입 의존은 없어야 한다.
|
- 기대 결과: `CreatorProfileItem`, `CreatorProfileGrid`는 검색되고 API 응답 타입 의존은 없어야 한다.
|
||||||
- 수동 확인: 공용 컴포넌트 타입명에 `MainHome` 또는 `HomeRecommendation` 접두사를 붙이지 않아야 한다.
|
- 수동 확인: 공용 컴포넌트 타입명에 `MainHome` 또는 `HomeRecommendation` 접두사를 붙이지 않아야 한다.
|
||||||
|
|
||||||
- [ ] **Task 2.4: 공용 Button/Banner/Card 컴포넌트 작성**
|
- [x] **Task 2.4: 공용 Button/Banner/Card 컴포넌트 작성**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 생성: `SodaLive/Sources/V2/Component/Button/FollowAllButton.swift`
|
- 생성: `SodaLive/Sources/V2/Component/Button/FollowAllButton.swift`
|
||||||
- 생성: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
|
- 생성: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
|
||||||
@@ -412,3 +412,49 @@
|
|||||||
- 아직 수행하지 않은 작업:
|
- 아직 수행하지 않은 작업:
|
||||||
- Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결
|
- Phase 2 이후 ViewModel, UI 컴포넌트, 홈 탭 연결
|
||||||
- 테스트 타깃이 없어 Phase 1 전용 RED/GREEN 단위 테스트는 추가하지 않음
|
- 테스트 타깃이 없어 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` 확인
|
||||||
|
|||||||
Reference in New Issue
Block a user