feat(home): 팔로잉 스케줄 섹션을 추가한다
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct MainHomeFollowingScheduleSection: View {
|
||||
let monthlySchedules: [FollowingScheduleResponse]
|
||||
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
||||
|
||||
init(
|
||||
monthlySchedules: [FollowingScheduleResponse],
|
||||
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in }
|
||||
) {
|
||||
self.monthlySchedules = monthlySchedules
|
||||
self.onTapSchedule = onTapSchedule
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !monthlySchedules.isEmpty {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||
SectionTitle(title: I18n.HomeFollowing.monthlyScheduleTitle)
|
||||
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
|
||||
ForEach(monthlySchedules) { schedule in
|
||||
MainHomeFollowingScheduleItemView(schedule: schedule) {
|
||||
onTapSchedule(schedule.type, schedule.targetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(alignment: .leading) {
|
||||
ScheduleTimelineLine()
|
||||
.padding(.leading, 3)
|
||||
.padding(.vertical, 52)
|
||||
.opacity(monthlySchedules.count > 1 ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MainHomeFollowingScheduleItemView: View {
|
||||
let schedule: FollowingScheduleResponse
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s14) {
|
||||
TimelineDotView(isHighlighted: schedule.isToday || schedule.isOnAir)
|
||||
|
||||
Button(action: action) {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s12) {
|
||||
DateBoxView(schedule: schedule)
|
||||
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||
creatorInfo
|
||||
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||
Text(schedule.title)
|
||||
.appFont(size: 16, weight: .medium)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||
ActivityTypeTagView(type: schedule.type)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text(schedule.statusText)
|
||||
.appFont(size: 16, weight: .regular)
|
||||
.foregroundColor(schedule.isOnAir ? Color.soda400 : Color.gray500)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(SodaSpacing.s14)
|
||||
.padding(.leading, schedule.isOnAir ? SodaSpacing.s8 : 0)
|
||||
.background(Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(alignment: .leading) {
|
||||
if schedule.isOnAir {
|
||||
Color.soda400
|
||||
.frame(width: SodaSpacing.s4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var creatorInfo: some View {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s6) {
|
||||
DownsampledKFImage(url: URL(string: schedule.creatorProfileImageUrl), size: CGSize(width: 20, height: 20))
|
||||
.clipShape(Circle())
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text(schedule.creatorNickname)
|
||||
.appFont(size: 12, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScheduleTimelineLine: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.gray700)
|
||||
.frame(width: 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineDotView: View {
|
||||
let isHighlighted: Bool
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isHighlighted ? Color.soda400 : Color.gray500)
|
||||
.frame(width: SodaSpacing.s8, height: SodaSpacing.s8)
|
||||
.frame(width: SodaSpacing.s8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DateBoxView: View {
|
||||
let schedule: FollowingScheduleResponse
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: SodaSpacing.s4) {
|
||||
if schedule.isToday {
|
||||
Text("오늘")
|
||||
.appFont(size: 22, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(schedule.dayText)
|
||||
.appFont(size: 22, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(schedule.weekdayText)
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 53, height: 53)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityTypeTagView: View {
|
||||
let type: CreatorActivityType
|
||||
|
||||
var body: some View {
|
||||
Text(type.scheduleTagTitle)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray100)
|
||||
.padding(.horizontal, SodaSpacing.s4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.gray700)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s4, style: .continuous))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private extension FollowingScheduleResponse {
|
||||
var scheduledDate: Date? {
|
||||
DateParser.parse(scheduledAtUtc)
|
||||
}
|
||||
|
||||
var isToday: Bool {
|
||||
guard let scheduledDate else { return false }
|
||||
return Calendar.current.isDateInToday(scheduledDate)
|
||||
}
|
||||
|
||||
var dayText: String {
|
||||
guard let scheduledDate else { return scheduledAtUtc }
|
||||
return ScheduleDateFormatter.day.string(from: scheduledDate)
|
||||
}
|
||||
|
||||
var weekdayText: String {
|
||||
guard let scheduledDate else { return "" }
|
||||
return ScheduleDateFormatter.weekday.string(from: scheduledDate)
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
if isOnAir {
|
||||
return "On Air"
|
||||
}
|
||||
|
||||
guard let scheduledDate else { return scheduledAtUtc }
|
||||
return ScheduleDateFormatter.time.string(from: scheduledDate)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CreatorActivityType {
|
||||
var scheduleTagTitle: String {
|
||||
switch self {
|
||||
case .live:
|
||||
return "라이브"
|
||||
case .liveReplay:
|
||||
return "다시보기"
|
||||
case .audio:
|
||||
return "오디오"
|
||||
case .community:
|
||||
return "커뮤니티"
|
||||
case .unknown(let rawValue):
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ScheduleDateFormatter {
|
||||
static let day: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "ko_KR")
|
||||
formatter.dateFormat = "d"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let weekday: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "ko_KR")
|
||||
formatter.dateFormat = "E"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let time: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "ko_KR")
|
||||
formatter.dateFormat = "a hh:mm"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
struct MainHomeFollowingScheduleSection_Previews: PreviewProvider {
|
||||
private static let previewImageUrl = "https://picsum.photos/500/500"
|
||||
|
||||
static var previews: some View {
|
||||
MainHomeFollowingScheduleSection(
|
||||
monthlySchedules: [
|
||||
FollowingScheduleResponse(
|
||||
scheduleId: "today-on-air",
|
||||
creatorId: 1,
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
creatorNickname: "크리에이터",
|
||||
title: "오늘 밤 같이 이야기해요",
|
||||
type: .live,
|
||||
targetId: 100,
|
||||
scheduledAtUtc: ISO8601DateFormatter().string(from: Date()),
|
||||
isOnAir: false
|
||||
),
|
||||
FollowingScheduleResponse(
|
||||
scheduleId: "normal-schedule",
|
||||
creatorId: 2,
|
||||
creatorProfileImageUrl: previewImageUrl,
|
||||
creatorNickname: "소다",
|
||||
title: "신규 오디오 콘텐츠 공개",
|
||||
type: .audio,
|
||||
targetId: 200,
|
||||
scheduledAtUtc: "2026-07-12T12:30:00Z",
|
||||
isOnAir: false
|
||||
)
|
||||
]
|
||||
)
|
||||
.padding(.vertical, SodaSpacing.s20)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ struct MainHomeFollowingView: View {
|
||||
let onTapFollowingAll: () -> Void
|
||||
let onTapChatTab: () -> Void
|
||||
let onTapChatRoom: (Int) -> Void
|
||||
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
||||
|
||||
@StateObject private var viewModel = MainHomeFollowingViewModel()
|
||||
|
||||
@@ -14,13 +15,15 @@ struct MainHomeFollowingView: View {
|
||||
onTapCreator: @escaping (Int) -> Void = { _ in },
|
||||
onTapFollowingAll: @escaping () -> Void = {},
|
||||
onTapChatTab: @escaping () -> Void = {},
|
||||
onTapChatRoom: @escaping (Int) -> Void = { _ in }
|
||||
onTapChatRoom: @escaping (Int) -> Void = { _ in },
|
||||
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in }
|
||||
) {
|
||||
self.onTapLive = onTapLive
|
||||
self.onTapCreator = onTapCreator
|
||||
self.onTapFollowingAll = onTapFollowingAll
|
||||
self.onTapChatTab = onTapChatTab
|
||||
self.onTapChatRoom = onTapChatRoom
|
||||
self.onTapSchedule = onTapSchedule
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +47,13 @@ struct MainHomeFollowingView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if let monthlySchedules = viewModel.response?.monthlySchedules, !monthlySchedules.isEmpty {
|
||||
MainHomeFollowingScheduleSection(
|
||||
monthlySchedules: monthlySchedules,
|
||||
onTapSchedule: onTapSchedule
|
||||
)
|
||||
}
|
||||
|
||||
MainPlaceholderTabView(title: "팔로잉")
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
Reference in New Issue
Block a user