From a3b75674c87bd11e6402b1c7c8f333d0cba04a88 Mon Sep 17 00:00:00 2001 From: Jong MIn Date: Tue, 6 Jan 2026 02:02:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat]:=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MainFeature TCA Reducer 구현 - RelayList, ChartSong, Artist, Playlist, YouTubeVideo 모델 정의 - MainClient Dependency 및 Mock 데이터 추가 - 타이머 기반 릴레이리스트 카운트다운 구현 - MainView 구현 - 릴레이리스트 카드 섹션 (페이지 인디케이터 포함) - 위플리 TOP 100 차트 섹션 - 배너 섹션 (가로 스크롤) - 위플리 인기 랭킹 (아티스트 프로필) - 위플리 추천 플레이리스트 - 테마별 플레이리스트 - YouTube Music 섹션 --- .../Sources/Features/Main/MainFeature.swift | 332 +++++++++ .../App/Sources/Features/Main/MainView.swift | 700 ++++++++++++++++++ 2 files changed, 1032 insertions(+) create mode 100644 Projects/App/Sources/Features/Main/MainFeature.swift create mode 100644 Projects/App/Sources/Features/Main/MainView.swift diff --git a/Projects/App/Sources/Features/Main/MainFeature.swift b/Projects/App/Sources/Features/Main/MainFeature.swift new file mode 100644 index 0000000..514f447 --- /dev/null +++ b/Projects/App/Sources/Features/Main/MainFeature.swift @@ -0,0 +1,332 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct MainFeature { + @ObservableState + struct State: Equatable { + var relayLists: [RelayList] = [] + var currentRelayListIndex: Int = 0 + var chartSongs: [ChartSong] = [] + var popularArtists: [Artist] = [] + var recommendedPlaylists: [Playlist] = [] + var themedPlaylists: [Playlist] = [] + var youtubeVideos: [YouTubeVideo] = [] + var banners: [Banner] = [] + var isLoading: Bool = false + var chartUpdateTime: String = "" + } + + enum Action { + case onAppear + case loadData + case dataLoaded(MainData) + case relayListPageChanged(Int) + case searchButtonTapped + case notificationButtonTapped + case chartSongTapped(ChartSong) + case artistTapped(Artist) + case playlistTapped(Playlist) + case youtubeVideoTapped(YouTubeVideo) + case bannerTapped(Banner) + case seeAllChartTapped + case seeAllPlaylistTapped + case seeAllThemedPlaylistTapped + case seeAllYoutubeTapped + case timerTick + } + + @Dependency(\.mainClient) + var mainClient + + @Dependency(\.continuousClock) + var clock + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + return .send(.loadData) + + case .loadData: + state.isLoading = true + return .run { send in + let data = await mainClient.fetchMainData() + await send(.dataLoaded(data)) + } + + case let .dataLoaded(data): + state.isLoading = false + state.relayLists = data.relayLists + state.chartSongs = data.chartSongs + state.popularArtists = data.popularArtists + state.recommendedPlaylists = data.recommendedPlaylists + state.themedPlaylists = data.themedPlaylists + state.youtubeVideos = data.youtubeVideos + state.banners = data.banners + state.chartUpdateTime = data.chartUpdateTime + return .run { send in + for await _ in clock.timer(interval: .seconds(1)) { + await send(.timerTick) + } + } + + case let .relayListPageChanged(index): + state.currentRelayListIndex = index + return .none + + case .searchButtonTapped: + return .none + + case .notificationButtonTapped: + return .none + + case .chartSongTapped: + return .none + + case .artistTapped: + return .none + + case .playlistTapped: + return .none + + case .youtubeVideoTapped: + return .none + + case .bannerTapped: + return .none + + case .seeAllChartTapped: + return .none + + case .seeAllPlaylistTapped: + return .none + + case .seeAllThemedPlaylistTapped: + return .none + + case .seeAllYoutubeTapped: + return .none + + case .timerTick: + for index in state.relayLists.indices { + if state.relayLists[index].remainingSeconds > 0 { + state.relayLists[index].remainingSeconds -= 1 + } + } + return .none + } + } + } +} + +// MARK: - Models + +struct RelayList: Equatable, Identifiable { + let id: String + let title: String + let participantCount: Int + let firstSong: Song? + let backgroundImageURL: String? + var remainingSeconds: Int + + var isCompleted: Bool { + remainingSeconds <= 0 + } + + var remainingTimeText: String { + guard remainingSeconds > 0 else { return "" } + let days = remainingSeconds / 86400 + let hours = (remainingSeconds % 86400) / 3600 + let minutes = (remainingSeconds % 3600) / 60 + let seconds = remainingSeconds % 60 + return "\(days)일 \(hours)시간 \(minutes)분 \(seconds)초" + } +} + +struct Song: Equatable, Identifiable { + let id: String + let title: String + let artist: String + let albumName: String? + let releaseYear: String? + let albumArtURL: String? +} + +struct ChartSong: Equatable, Identifiable { + let id: String + let rank: Int + let song: Song +} + +struct Artist: Equatable, Identifiable { + let id: String + let name: String + let profileImageURL: String? +} + +struct Playlist: Equatable, Identifiable { + let id: String + let title: String + let coverImageURL: String? +} + +struct YouTubeVideo: Equatable, Identifiable { + let id: String + let title: String + let channelName: String + let thumbnailURL: String? +} + +struct Banner: Equatable, Identifiable { + let id: String + let title: String + let subtitle: String + let imageURL: String? + let actionURL: String? +} + +struct MainData: Equatable { + let relayLists: [RelayList] + let chartSongs: [ChartSong] + let popularArtists: [Artist] + let recommendedPlaylists: [Playlist] + let themedPlaylists: [Playlist] + let youtubeVideos: [YouTubeVideo] + let banners: [Banner] + let chartUpdateTime: String +} + +// MARK: - MainClient Dependency + +struct MainClient { + var fetchMainData: @Sendable () async -> MainData +} + +extension MainClient: DependencyKey { + static let liveValue = MainClient( + fetchMainData: { + // TODO: Implement actual API call + try? await Task.sleep(nanoseconds: 500000000) + return .mock + } + ) + + static let testValue = MainClient( + fetchMainData: { .mock } + ) + + static let previewValue = MainClient( + fetchMainData: { .mock } + ) +} + +extension DependencyValues { + var mainClient: MainClient { + get { self[MainClient.self] } + set { self[MainClient.self] = newValue } + } +} + +// MARK: - Mock Data + +extension MainData { + static let mock = MainData( + relayLists: [ + RelayList( + id: "1", + title: "손님들이 물어보는\n카페 BGM", + participantCount: 3012, + firstSong: Song( + id: "1", + title: "Radical Optimism", + artist: "Dua Lipa", + albumName: "Album", + releaseYear: "2024", + albumArtURL: nil + ), + backgroundImageURL: nil, + remainingSeconds: 130215 + ), + RelayList( + id: "2", + title: "비 오는 날 듣기 좋은\n감성 플레이리스트", + participantCount: 2456, + firstSong: nil, + backgroundImageURL: nil, + remainingSeconds: 0 + ), + ], + chartSongs: [ + ChartSong( + id: "1", + rank: 1, + song: Song( + id: "s1", + title: "Small girl (feat. 도경수 (D.O)", + artist: "이영지", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ChartSong( + id: "2", + rank: 2, + song: Song(id: "s2", title: "Supernova", artist: "aespa", albumName: nil, releaseYear: nil, albumArtURL: nil) + ), + ChartSong( + id: "3", + rank: 3, + song: Song(id: "s3", title: "How Sweet", artist: "NewJeans", albumName: nil, releaseYear: nil, albumArtURL: nil) + ), + ChartSong( + id: "4", + rank: 4, + song: Song( + id: "s4", + title: "해야 (HEYA)", + artist: "IVE (아이브)", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ChartSong( + id: "5", + rank: 5, + song: Song(id: "s5", title: "소나기", artist: "이클립스 (ECLIPSE)", albumName: nil, releaseYear: nil, albumArtURL: nil) + ), + ], + popularArtists: [ + Artist(id: "a1", name: "BIGBANG (빅뱅)", profileImageURL: nil), + Artist(id: "a2", name: "비투비", profileImageURL: nil), + Artist(id: "a3", name: "윤하(Younha/ユンナ)", profileImageURL: nil), + Artist(id: "a4", name: "엔플라잉(N.Flying)", profileImageURL: nil), + ], + recommendedPlaylists: [ + Playlist(id: "p1", title: "끈적달달한 체리위스키를 머금은 힙합 R&B", coverImageURL: nil), + Playlist(id: "p2", title: "노을처럼 번지는 아날로그 무드", coverImageURL: nil), + Playlist(id: "p3", title: "추위를 녹이는\n음색의 보이스", coverImageURL: nil), + ], + themedPlaylists: [ + Playlist(id: "t1", title: "초여름 청량한 케이팝 댄스", coverImageURL: nil), + Playlist(id: "t2", title: "청량함 가득 여름 국힙", coverImageURL: nil), + Playlist(id: "t3", title: "뼛속까지 청량해지는 K-pop", coverImageURL: nil), + ], + youtubeVideos: [ + YouTubeVideo(id: "y1", title: "Falling - 로이킴 [더 시즌즈-최정훈의 밤의공원]", channelName: "KBS Kpop", thumbnailURL: nil), + YouTubeVideo( + id: "y2", + title: "ZICO (지코) 'SPOT! (feat. JENNIE)' Official MV", + channelName: "HYBE LABELS", + thumbnailURL: nil + ), + ], + banners: [ + Banner(id: "b1", title: "실시간 통합 순위를 한 눈에!", subtitle: "오직 위플리에서만", imageURL: nil, actionURL: nil), + Banner(id: "b2", title: "감성 가득한 일상 보러가기", subtitle: "위플리 인스타그램 OPEN!", imageURL: nil, actionURL: nil), + ], + chartUpdateTime: "6월 23일 오전 7시 업데이트" + ) +} diff --git a/Projects/App/Sources/Features/Main/MainView.swift b/Projects/App/Sources/Features/Main/MainView.swift new file mode 100644 index 0000000..63d7fe1 --- /dev/null +++ b/Projects/App/Sources/Features/Main/MainView.swift @@ -0,0 +1,700 @@ +import ComposableArchitecture +import SwiftUI + +struct MainView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + ZStack { + Color.black + .ignoresSafeArea() + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 24) { + // 릴레이리스트 섹션 + RelayListSection( + relayLists: store.relayLists, + currentIndex: store.currentRelayListIndex, + onPageChanged: { store.send(.relayListPageChanged($0)) } + ) + + // 위플리 TOP 100 + ChartSection( + songs: store.chartSongs, + updateTime: store.chartUpdateTime, + onSongTapped: { store.send(.chartSongTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllChartTapped) } + ) + + // 배너 섹션 + BannerSection( + banners: store.banners, + onBannerTapped: { store.send(.bannerTapped($0)) } + ) + + // 위플리 인기 랭킹 + ArtistRankingSection( + artists: store.popularArtists, + onArtistTapped: { store.send(.artistTapped($0)) } + ) + + // 위플리 추천 플레이리스트 + PlaylistSection( + title: "위플리 추천 플레이리스트", + playlists: store.recommendedPlaylists, + onPlaylistTapped: { store.send(.playlistTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllPlaylistTapped) } + ) + + // 테마별 플레이리스트 + PlaylistSection( + title: "테마별 플레이리스트", + playlists: store.themedPlaylists, + onPlaylistTapped: { store.send(.playlistTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllThemedPlaylistTapped) } + ) + + // YouTube Music + YouTubeSection( + videos: store.youtubeVideos, + onVideoTapped: { store.send(.youtubeVideoTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllYoutubeTapped) } + ) + + Spacer() + .frame(height: 100) + } + } + + // 상단 앱바 (블러 배경) + VStack { + MainAppBar( + onSearchTapped: { store.send(.searchButtonTapped) }, + onNotificationTapped: { store.send(.notificationButtonTapped) } + ) + Spacer() + } + } + .onAppear { + store.send(.onAppear) + } + } + } +} + +// MARK: - MainAppBar + +private struct MainAppBar: View { + let onSearchTapped: () -> Void + let onNotificationTapped: () -> Void + + var body: some View { + HStack { + // 로고 + Text("w.") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Spacer() + + // 검색 버튼 + Button(action: onSearchTapped) { + Image(systemName: "magnifyingglass") + .font(.system(size: 20)) + .foregroundColor(.white) + } + .frame(width: 40, height: 40) + + // 알림 버튼 + Button(action: onNotificationTapped) { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + .font(.system(size: 20)) + .foregroundColor(.white) + + Circle() + .fill(Color.red) + .frame(width: 5, height: 5) + .offset(x: 2, y: -2) + } + } + .frame(width: 40, height: 40) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + LinearGradient( + colors: [Color.black.opacity(0.8), Color.black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .blur(radius: 10) + ) + } +} + +// MARK: - RelayListSection + +private struct RelayListSection: View { + let relayLists: [RelayList] + let currentIndex: Int + let onPageChanged: (Int) -> Void + + var body: some View { + VStack(spacing: 16) { + TabView(selection: Binding( + get: { currentIndex }, + set: { onPageChanged($0) } + )) { + ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in + RelayListCard(relayList: relayList) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 392) + + // 커스텀 인디케이터 + HStack(spacing: 4) { + ForEach(0 ..< relayLists.count, id: \.self) { index in + Capsule() + .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) + .frame(width: index == currentIndex ? 20 : 4, height: 4) + } + } + } + } +} + +// MARK: - RelayListCard + +private struct RelayListCard: View { + let relayList: RelayList + + var body: some View { + ZStack(alignment: .bottom) { + // 배경 이미지 (플레이스홀더) + LinearGradient( + colors: [Color.purple.opacity(0.6), Color.blue.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // 하단 그라디언트 + LinearGradient( + colors: [ + Color.black.opacity(0), + Color.black.opacity(0.1), + Color.black.opacity(0.2), + Color.black.opacity(0.6), + Color.black, + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 342) + + // 콘텐츠 + VStack(alignment: .leading, spacing: 0) { + Spacer() + .frame(height: 100) + + VStack(alignment: .leading, spacing: 4) { + // 제목 + Text(relayList.title) + .font(.system(size: 28, weight: .semibold)) + .foregroundColor(.white) + .lineSpacing(6) + + // 참여자 수 + Text("\(relayList.participantCount.formatted())명 참여") + .font(.system(size: 14, weight: .regular)) + .foregroundColor(Color(hex: "D8D8DD")) + } + + Spacer() + .frame(height: 32) + + // 첫 곡 정보 + if let firstSong = relayList.firstSong { + FirstSongView(song: firstSong) + } + + Spacer() + .frame(height: 16) + + // 타이머 또는 완성 상태 + TimerView(relayList: relayList) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .clipShape(RoundedRectangle(cornerRadius: 0)) + } +} + +// MARK: - FirstSongView + +private struct FirstSongView: View { + let song: Song + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(song.title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + + HStack(spacing: 8) { + if let albumName = song.albumName { + Text(albumName) + .font(.system(size: 14, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + } + + if song.albumName != nil, song.releaseYear != nil { + Circle() + .fill(Color(hex: "B5B5C0")) + .frame(width: 4, height: 4) + } + + if let year = song.releaseYear { + Text(year) + .font(.system(size: 14, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + } + } + } + + Spacer() + + // 앨범 아트 (플레이스홀더) + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [Color.orange, Color.pink], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 60, height: 60) + } + } +} + +// MARK: - TimerView + +private struct TimerView: View { + let relayList: RelayList + + var body: some View { + HStack { + if relayList.isCompleted { + Text("릴레이리스트가 완성되었어요 🎉") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + } else { + Text("플리 완성까지") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Spacer() + + Text(relayList.remainingTimeText) + .font(.system(size: 14, weight: .light)) + .foregroundColor(Color(hex: "C2C2C2")) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - ChartSection + +private struct ChartSection: View { + let songs: [ChartSong] + let updateTime: String + let onSongTapped: (ChartSong) -> Void + let onSeeAllTapped: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // 헤더 + VStack(alignment: .leading, spacing: 4) { + Text("위플리 TOP 100") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Text(updateTime) + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color(hex: "A3A3A3")) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 노래 목록 + VStack(spacing: 8) { + ForEach(songs) { chartSong in + ChartSongRow(chartSong: chartSong) + .onTapGesture { onSongTapped(chartSong) } + } + } + .padding(.vertical, 12) + } + } +} + +// MARK: - ChartSongRow + +private struct ChartSongRow: View { + let chartSong: ChartSong + + var body: some View { + HStack(spacing: 0) { + // 앨범 아트 + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [Color.blue.opacity(0.6), Color.purple.opacity(0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 52, height: 52) + .padding(.leading, 20) + + // 순위 + Text("\(chartSong.rank)") + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color.white.opacity(0.8)) + .frame(width: 26) + + // 노래 정보 + VStack(alignment: .leading, spacing: 4) { + Text(chartSong.song.title) + .font(.system(size: 14, weight: .light)) + .foregroundColor(.white) + .lineLimit(1) + + Text(chartSong.song.artist) + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color.white.opacity(0.7)) + .lineLimit(1) + } + + Spacer() + + // 더보기 버튼 + Button(action: {}) { + Image(systemName: "ellipsis") + .font(.system(size: 16)) + .foregroundColor(.white) + } + .frame(width: 44, height: 44) + .padding(.trailing, 20) + } + .frame(height: 52) + } +} + +// MARK: - BannerSection + +private struct BannerSection: View { + let banners: [Banner] + let onBannerTapped: (Banner) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(banners) { banner in + BannerCard(banner: banner) + .onTapGesture { onBannerTapped(banner) } + } + } + .padding(.horizontal, 20) + } + } +} + +// MARK: - BannerCard + +private struct BannerCard: View { + let banner: Banner + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "141417")) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(banner.subtitle) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Color(hex: "F0F0F0")) + + Text(banner.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.leading, 20) + + Spacer() + + // 배너 이미지 영역 (플레이스홀더) + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.3)) + .frame(width: 100, height: 76) + .padding(.trailing, 12) + } + } + .frame(width: 335, height: 84) + } +} + +// MARK: - ArtistRankingSection + +private struct ArtistRankingSection: View { + let artists: [Artist] + let onArtistTapped: (Artist) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // 헤더 + VStack(alignment: .leading, spacing: 4) { + Text("위플리 인기 랭킹") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Text("위플리 차트에서 인기가 많은 가수들을 모아봤어요") + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color(hex: "A3A3A3")) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 아티스트 목록 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(artists) { artist in + ArtistProfileView(artist: artist) + .onTapGesture { onArtistTapped(artist) } + } + } + .padding(.horizontal, 20) + } + } + } +} + +// MARK: - ArtistProfileView + +private struct ArtistProfileView: View { + let artist: Artist + + var body: some View { + VStack(spacing: 4) { + // 프로필 이미지 (원형 마스크) + Circle() + .fill( + LinearGradient( + colors: [Color.gray.opacity(0.5), Color.gray.opacity(0.3)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 80, height: 80) + + // 아티스트 이름 + Text(artist.name) + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + .lineLimit(1) + .frame(width: 92) + .multilineTextAlignment(.center) + } + } +} + +// MARK: - PlaylistSection + +private struct PlaylistSection: View { + let title: String + let playlists: [Playlist] + let onPlaylistTapped: (Playlist) -> Void + let onSeeAllTapped: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 헤더 + Button(action: onSeeAllTapped) { + HStack(spacing: 4) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "D8D8DD")) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 플레이리스트 목록 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(playlists) { playlist in + PlaylistItemView(playlist: playlist) + .onTapGesture { onPlaylistTapped(playlist) } + } + } + .padding(.horizontal, 20) + } + } + } +} + +// MARK: - PlaylistItemView + +private struct PlaylistItemView: View { + let playlist: Playlist + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 커버 이미지 (플레이스홀더) + RoundedRectangle(cornerRadius: 12) + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.6), Color.pink.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 136, height: 136) + + // 제목 + Text(playlist.title) + .font(.system(size: 13, weight: .light)) + .foregroundColor(.white) + .lineLimit(2) + .frame(width: 136, alignment: .leading) + .padding(.top, 12) + } + } +} + +// MARK: - YouTubeSection + +private struct YouTubeSection: View { + let videos: [YouTubeVideo] + let onVideoTapped: (YouTubeVideo) -> Void + let onSeeAllTapped: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 헤더 + Button(action: onSeeAllTapped) { + HStack(spacing: 4) { + // YouTube 아이콘 (플레이스홀더) + Image(systemName: "play.rectangle.fill") + .font(.system(size: 12)) + .foregroundColor(.red) + + Text("Youtube Music") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "D8D8DD")) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 영상 목록 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(videos) { video in + YouTubeVideoItemView(video: video) + .onTapGesture { onVideoTapped(video) } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + } +} + +// MARK: - YouTubeVideoItemView + +private struct YouTubeVideoItemView: View { + let video: YouTubeVideo + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 썸네일 (플레이스홀더) + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.3)) + .frame(width: 240, height: 135) + + // 제목 + Text(video.title) + .font(.system(size: 15, weight: .regular)) + .foregroundColor(Color(hex: "F0F0F0")) + .lineLimit(1) + .frame(width: 240, alignment: .leading) + .padding(.top, 11) + + // 채널명 + Text(video.channelName) + .font(.system(size: 13, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + .padding(.top, 4) + } + } +} + +// MARK: - Color Extension + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - Preview + +#Preview { + MainView( + store: Store(initialState: MainFeature.State()) { + MainFeature() + } + ) +} From 53619381e2023118cdf25319f7e02f621cb2dbe7 Mon Sep 17 00:00:00 2001 From: Jong MIn Date: Tue, 6 Jan 2026 02:37:46 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Feat]:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=E2=86=92=20=EB=A9=94=EC=9D=B8=20=ED=99=94=EB=A9=B4=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppFeature 추가: 앱 레벨 상태 관리 (isLoggedIn) - AppView 추가: 로그인 상태에 따라 LoginView/MainView 전환 - 소셜 로그인 버튼 클릭 시 메인 화면으로 이동 (임시) --- Projects/App/Resources/Localizable.xcstrings | 27 ++++++++++++ Projects/App/Sources/ContentView.swift | 6 +-- .../App/Sources/Features/App/AppFeature.swift | 42 +++++++++++++++++++ .../App/Sources/Features/App/AppView.swift | 36 ++++++++++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 Projects/App/Sources/Features/App/AppFeature.swift create mode 100644 Projects/App/Sources/Features/App/AppView.swift diff --git a/Projects/App/Resources/Localizable.xcstrings b/Projects/App/Resources/Localizable.xcstrings index d68a2f6..7c7844d 100644 --- a/Projects/App/Resources/Localizable.xcstrings +++ b/Projects/App/Resources/Localizable.xcstrings @@ -1,6 +1,18 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + + }, + "%@명 참여" : { + + }, + "w." : { + + }, + "Youtube Music" : { + + }, "개인정보처리방침" : { }, @@ -12,15 +24,30 @@ }, "다양한 사람들과 음악으로 연결되는\n특별한 순간을 경험해보세요" : { + }, + "릴레이리스트가 완성되었어요 🎉" : { + }, "서비스 조건" : { }, "에 동의하게 됩니다." : { + }, + "위플리 TOP 100" : { + + }, + "위플리 인기 랭킹" : { + + }, + "위플리 차트에서 인기가 많은 가수들을 모아봤어요" : { + }, "최초 로그인은 계정을 생성하며," : { + }, + "플리 완성까지" : { + }, "함께 만드는\n플레이리스트" : { diff --git a/Projects/App/Sources/ContentView.swift b/Projects/App/Sources/ContentView.swift index 735c29c..118595a 100644 --- a/Projects/App/Sources/ContentView.swift +++ b/Projects/App/Sources/ContentView.swift @@ -3,9 +3,9 @@ import SwiftUI struct ContentView: View { var body: some View { - LoginView( - store: Store(initialState: LoginFeature.State()) { - LoginFeature() + AppView( + store: Store(initialState: AppFeature.State()) { + AppFeature() } ) } diff --git a/Projects/App/Sources/Features/App/AppFeature.swift b/Projects/App/Sources/Features/App/AppFeature.swift new file mode 100644 index 0000000..67aa088 --- /dev/null +++ b/Projects/App/Sources/Features/App/AppFeature.swift @@ -0,0 +1,42 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct AppFeature { + @ObservableState + struct State: Equatable { + var isLoggedIn: Bool = false + var login: LoginFeature.State = .init() + var main: MainFeature.State = .init() + } + + enum Action { + case login(LoginFeature.Action) + case main(MainFeature.Action) + } + + var body: some ReducerOf { + Scope(state: \.login, action: \.login) { + LoginFeature() + } + + Scope(state: \.main, action: \.main) { + MainFeature() + } + + Reduce { state, action in + switch action { + // 로그인 성공 시 메인 화면으로 전환 + case .login(.loginResponse(.success)): + state.isLoggedIn = true + return .none + + case .login: + return .none + + case .main: + return .none + } + } + } +} diff --git a/Projects/App/Sources/Features/App/AppView.swift b/Projects/App/Sources/Features/App/AppView.swift new file mode 100644 index 0000000..12a4838 --- /dev/null +++ b/Projects/App/Sources/Features/App/AppView.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import SwiftUI + +struct AppView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.isLoggedIn { + MainView( + store: store.scope(state: \.main, action: \.main) + ) + } else { + LoginView( + store: store.scope(state: \.login, action: \.login) + ) + } + } + } +} + +#Preview("Logged Out") { + AppView( + store: Store(initialState: AppFeature.State()) { + AppFeature() + } + ) +} + +#Preview("Logged In") { + AppView( + store: Store(initialState: AppFeature.State(isLoggedIn: true)) { + AppFeature() + } + ) +} From 0c7ff2bf74d957580aca3e93cf9b4500f9c730d7 Mon Sep 17 00:00:00 2001 From: Jong MIn Date: Tue, 6 Jan 2026 02:50:23 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Design]:=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=81=EB=8B=A8=20safe=20area=20=EC=9E=90?= =?UTF-8?q?=EC=97=B0=EC=8A=A4=EB=9F=BD=EA=B2=8C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScrollView가 상단 safe area까지 확장되도록 수정 - MainAppBar 배경 그라디언트가 상단까지 이어지도록 개선 - RelayListCard가 safe area를 고려하여 콘텐츠 위치 조정 --- .../App/Sources/Features/Main/MainView.swift | 131 ++++++++++-------- 1 file changed, 71 insertions(+), 60 deletions(-) diff --git a/Projects/App/Sources/Features/Main/MainView.swift b/Projects/App/Sources/Features/Main/MainView.swift index 63d7fe1..0388605 100644 --- a/Projects/App/Sources/Features/Main/MainView.swift +++ b/Projects/App/Sources/Features/Main/MainView.swift @@ -6,13 +6,13 @@ struct MainView: View { var body: some View { WithPerceptionTracking { - ZStack { + ZStack(alignment: .top) { Color.black .ignoresSafeArea() ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 24) { - // 릴레이리스트 섹션 + // 릴레이리스트 섹션 (상단 safe area까지 확장) RelayListSection( relayLists: store.relayLists, currentIndex: store.currentRelayListIndex, @@ -66,15 +66,13 @@ struct MainView: View { .frame(height: 100) } } + .ignoresSafeArea(edges: .top) // 상단 앱바 (블러 배경) - VStack { - MainAppBar( - onSearchTapped: { store.send(.searchButtonTapped) }, - onNotificationTapped: { store.send(.notificationButtonTapped) } - ) - Spacer() - } + MainAppBar( + onSearchTapped: { store.send(.searchButtonTapped) }, + onNotificationTapped: { store.send(.notificationButtonTapped) } + ) } .onAppear { store.send(.onAppear) @@ -90,46 +88,54 @@ private struct MainAppBar: View { let onNotificationTapped: () -> Void var body: some View { - HStack { - // 로고 - Text("w.") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) - - Spacer() - - // 검색 버튼 - Button(action: onSearchTapped) { - Image(systemName: "magnifyingglass") - .font(.system(size: 20)) + VStack(spacing: 0) { + HStack { + // 로고 + Text("w.") + .font(.system(size: 24, weight: .bold)) .foregroundColor(.white) - } - .frame(width: 40, height: 40) - // 알림 버튼 - Button(action: onNotificationTapped) { - ZStack(alignment: .topTrailing) { - Image(systemName: "bell") + Spacer() + + // 검색 버튼 + Button(action: onSearchTapped) { + Image(systemName: "magnifyingglass") .font(.system(size: 20)) .foregroundColor(.white) + } + .frame(width: 40, height: 40) + + // 알림 버튼 + Button(action: onNotificationTapped) { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + .font(.system(size: 20)) + .foregroundColor(.white) - Circle() - .fill(Color.red) - .frame(width: 5, height: 5) - .offset(x: 2, y: -2) + Circle() + .fill(Color.red) + .frame(width: 5, height: 5) + .offset(x: 2, y: -2) + } } + .frame(width: 40, height: 40) } - .frame(width: 40, height: 40) + .padding(.horizontal, 16) + .padding(.vertical, 8) + + Spacer() } - .padding(.horizontal, 16) - .padding(.vertical, 8) .background( - LinearGradient( - colors: [Color.black.opacity(0.8), Color.black.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - .blur(radius: 10) + VStack { + LinearGradient( + colors: [Color.black.opacity(0.7), Color.black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 120) + Spacer() + } + .ignoresSafeArea(edges: .top) ) } } @@ -142,28 +148,32 @@ private struct RelayListSection: View { let onPageChanged: (Int) -> Void var body: some View { - VStack(spacing: 16) { - TabView(selection: Binding( - get: { currentIndex }, - set: { onPageChanged($0) } - )) { - ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in - RelayListCard(relayList: relayList) - .tag(index) + GeometryReader { geometry in + let topSafeArea = geometry.safeAreaInsets.top + VStack(spacing: 16) { + TabView(selection: Binding( + get: { currentIndex }, + set: { onPageChanged($0) } + )) { + ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in + RelayListCard(relayList: relayList, topSafeArea: topSafeArea) + .tag(index) + } } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 392) - - // 커스텀 인디케이터 - HStack(spacing: 4) { - ForEach(0 ..< relayLists.count, id: \.self) { index in - Capsule() - .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) - .frame(width: index == currentIndex ? 20 : 4, height: 4) + .tabViewStyle(.page(indexDisplayMode: .never)) + + // 커스텀 인디케이터 + HStack(spacing: 4) { + ForEach(0 ..< relayLists.count, id: \.self) { index in + Capsule() + .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) + .frame(width: index == currentIndex ? 20 : 4, height: 4) + } } + .padding(.bottom, 8) } } + .frame(height: 450) } } @@ -171,6 +181,7 @@ private struct RelayListSection: View { private struct RelayListCard: View { let relayList: RelayList + var topSafeArea: CGFloat = 0 var body: some View { ZStack(alignment: .bottom) { @@ -197,8 +208,9 @@ private struct RelayListCard: View { // 콘텐츠 VStack(alignment: .leading, spacing: 0) { + // 상단 safe area + 앱바 높이만큼 여백 Spacer() - .frame(height: 100) + .frame(height: topSafeArea + 60) VStack(alignment: .leading, spacing: 4) { // 제목 @@ -230,7 +242,6 @@ private struct RelayListCard: View { .padding(.horizontal, 20) .padding(.bottom, 20) } - .clipShape(RoundedRectangle(cornerRadius: 0)) } } From 3f3235fa7aaecd61da836bcabfbeccc471597c2a Mon Sep 17 00:00:00 2001 From: Jong MIn Date: Tue, 6 Jan 2026 03:21:46 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Fix]:=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=81=EB=8B=A8=20safe=20area=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeometryReader를 최상위로 이동하여 safe area 값을 정확히 전달 - MainAppBar 그라디언트 제거로 이상한 그림자 효과 해결 - RelayListSection이 처음부터 노치까지 확장되도록 수정 --- .../App/Sources/Features/Main/MainView.swift | 188 +++++++++--------- 1 file changed, 90 insertions(+), 98 deletions(-) diff --git a/Projects/App/Sources/Features/Main/MainView.swift b/Projects/App/Sources/Features/Main/MainView.swift index 0388605..b7d24e8 100644 --- a/Projects/App/Sources/Features/Main/MainView.swift +++ b/Projects/App/Sources/Features/Main/MainView.swift @@ -6,73 +6,76 @@ struct MainView: View { var body: some View { WithPerceptionTracking { - ZStack(alignment: .top) { - Color.black - .ignoresSafeArea() - - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 24) { - // 릴레이리스트 섹션 (상단 safe area까지 확장) - RelayListSection( - relayLists: store.relayLists, - currentIndex: store.currentRelayListIndex, - onPageChanged: { store.send(.relayListPageChanged($0)) } - ) - - // 위플리 TOP 100 - ChartSection( - songs: store.chartSongs, - updateTime: store.chartUpdateTime, - onSongTapped: { store.send(.chartSongTapped($0)) }, - onSeeAllTapped: { store.send(.seeAllChartTapped) } - ) - - // 배너 섹션 - BannerSection( - banners: store.banners, - onBannerTapped: { store.send(.bannerTapped($0)) } - ) - - // 위플리 인기 랭킹 - ArtistRankingSection( - artists: store.popularArtists, - onArtistTapped: { store.send(.artistTapped($0)) } - ) - - // 위플리 추천 플레이리스트 - PlaylistSection( - title: "위플리 추천 플레이리스트", - playlists: store.recommendedPlaylists, - onPlaylistTapped: { store.send(.playlistTapped($0)) }, - onSeeAllTapped: { store.send(.seeAllPlaylistTapped) } - ) - - // 테마별 플레이리스트 - PlaylistSection( - title: "테마별 플레이리스트", - playlists: store.themedPlaylists, - onPlaylistTapped: { store.send(.playlistTapped($0)) }, - onSeeAllTapped: { store.send(.seeAllThemedPlaylistTapped) } - ) - - // YouTube Music - YouTubeSection( - videos: store.youtubeVideos, - onVideoTapped: { store.send(.youtubeVideoTapped($0)) }, - onSeeAllTapped: { store.send(.seeAllYoutubeTapped) } - ) - - Spacer() - .frame(height: 100) + GeometryReader { geometry in + let topSafeArea = geometry.safeAreaInsets.top + + ZStack(alignment: .top) { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 24) { + // 릴레이리스트 섹션 (상단 safe area까지 확장) + RelayListSection( + relayLists: store.relayLists, + currentIndex: store.currentRelayListIndex, + topSafeArea: topSafeArea, + onPageChanged: { store.send(.relayListPageChanged($0)) } + ) + + // 위플리 TOP 100 + ChartSection( + songs: store.chartSongs, + updateTime: store.chartUpdateTime, + onSongTapped: { store.send(.chartSongTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllChartTapped) } + ) + + // 배너 섹션 + BannerSection( + banners: store.banners, + onBannerTapped: { store.send(.bannerTapped($0)) } + ) + + // 위플리 인기 랭킹 + ArtistRankingSection( + artists: store.popularArtists, + onArtistTapped: { store.send(.artistTapped($0)) } + ) + + // 위플리 추천 플레이리스트 + PlaylistSection( + title: "위플리 추천 플레이리스트", + playlists: store.recommendedPlaylists, + onPlaylistTapped: { store.send(.playlistTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllPlaylistTapped) } + ) + + // 테마별 플레이리스트 + PlaylistSection( + title: "테마별 플레이리스트", + playlists: store.themedPlaylists, + onPlaylistTapped: { store.send(.playlistTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllThemedPlaylistTapped) } + ) + + // YouTube Music + YouTubeSection( + videos: store.youtubeVideos, + onVideoTapped: { store.send(.youtubeVideoTapped($0)) }, + onSeeAllTapped: { store.send(.seeAllYoutubeTapped) } + ) + + Spacer() + .frame(height: 100) + } } + + // 상단 앱바 + MainAppBar( + topSafeArea: topSafeArea, + onSearchTapped: { store.send(.searchButtonTapped) }, + onNotificationTapped: { store.send(.notificationButtonTapped) } + ) } .ignoresSafeArea(edges: .top) - - // 상단 앱바 (블러 배경) - MainAppBar( - onSearchTapped: { store.send(.searchButtonTapped) }, - onNotificationTapped: { store.send(.notificationButtonTapped) } - ) } .onAppear { store.send(.onAppear) @@ -84,11 +87,15 @@ struct MainView: View { // MARK: - MainAppBar private struct MainAppBar: View { + let topSafeArea: CGFloat let onSearchTapped: () -> Void let onNotificationTapped: () -> Void var body: some View { VStack(spacing: 0) { + Spacer() + .frame(height: topSafeArea) + HStack { // 로고 Text("w.") @@ -125,18 +132,6 @@ private struct MainAppBar: View { Spacer() } - .background( - VStack { - LinearGradient( - colors: [Color.black.opacity(0.7), Color.black.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 120) - Spacer() - } - .ignoresSafeArea(edges: .top) - ) } } @@ -145,35 +140,32 @@ private struct MainAppBar: View { private struct RelayListSection: View { let relayLists: [RelayList] let currentIndex: Int + let topSafeArea: CGFloat let onPageChanged: (Int) -> Void var body: some View { - GeometryReader { geometry in - let topSafeArea = geometry.safeAreaInsets.top - VStack(spacing: 16) { - TabView(selection: Binding( - get: { currentIndex }, - set: { onPageChanged($0) } - )) { - ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in - RelayListCard(relayList: relayList, topSafeArea: topSafeArea) - .tag(index) - } + VStack(spacing: 16) { + TabView(selection: Binding( + get: { currentIndex }, + set: { onPageChanged($0) } + )) { + ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in + RelayListCard(relayList: relayList, topSafeArea: topSafeArea) + .tag(index) } - .tabViewStyle(.page(indexDisplayMode: .never)) - - // 커스텀 인디케이터 - HStack(spacing: 4) { - ForEach(0 ..< relayLists.count, id: \.self) { index in - Capsule() - .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) - .frame(width: index == currentIndex ? 20 : 4, height: 4) - } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 400 + topSafeArea) + + // 커스텀 인디케이터 + HStack(spacing: 4) { + ForEach(0 ..< relayLists.count, id: \.self) { index in + Capsule() + .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) + .frame(width: index == currentIndex ? 20 : 4, height: 4) } - .padding(.bottom, 8) } } - .frame(height: 450) } } From 2ffa97a218630091a5423ce21d429c1800786086 Mon Sep 17 00:00:00 2001 From: Jong MIn Date: Tue, 6 Jan 2026 14:19:59 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Refactor]:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Models: MainModels.swift로 모델 분리 (RelayList, Song, ChartSong 등) - Client: MainClient.swift로 Dependency 분리 - Client: MainMockData.swift로 목데이터 분리 - Components: 섹션별 뷰 컴포넌트 분리 - MainAppBar, RelayListSection, ChartSection - BannerSection, ArtistRankingSection - PlaylistSection, YouTubeSection - Extensions: Color+Hex.swift로 공용 Color extension 분리 - MainFeature.swift: Reducer 로직만 유지 - MainView.swift: 메인 뷰 조합만 담당 --- .../App/Sources/Extensions/Color+Hex.swift | 27 + .../Features/Main/Client/MainClient.swift | 37 ++ .../Features/Main/Client/MainMockData.swift | 180 ++++++ .../Components/ArtistRankingSection.swift | 65 ++ .../Main/Components/BannerSection.swift | 55 ++ .../Main/Components/ChartSection.swift | 89 +++ .../Features/Main/Components/MainAppBar.swift | 50 ++ .../Main/Components/PlaylistSection.swift | 69 ++ .../Main/Components/RelayListSection.swift | 183 ++++++ .../Main/Components/YouTubeSection.swift | 74 +++ .../Sources/Features/Main/MainFeature.swift | 211 ------ .../App/Sources/Features/Main/MainView.swift | 610 +----------------- .../Features/Main/Models/MainModels.swift | 92 +++ 13 files changed, 922 insertions(+), 820 deletions(-) create mode 100644 Projects/App/Sources/Extensions/Color+Hex.swift create mode 100644 Projects/App/Sources/Features/Main/Client/MainClient.swift create mode 100644 Projects/App/Sources/Features/Main/Client/MainMockData.swift create mode 100644 Projects/App/Sources/Features/Main/Components/ArtistRankingSection.swift create mode 100644 Projects/App/Sources/Features/Main/Components/BannerSection.swift create mode 100644 Projects/App/Sources/Features/Main/Components/ChartSection.swift create mode 100644 Projects/App/Sources/Features/Main/Components/MainAppBar.swift create mode 100644 Projects/App/Sources/Features/Main/Components/PlaylistSection.swift create mode 100644 Projects/App/Sources/Features/Main/Components/RelayListSection.swift create mode 100644 Projects/App/Sources/Features/Main/Components/YouTubeSection.swift create mode 100644 Projects/App/Sources/Features/Main/Models/MainModels.swift diff --git a/Projects/App/Sources/Extensions/Color+Hex.swift b/Projects/App/Sources/Extensions/Color+Hex.swift new file mode 100644 index 0000000..3af4dcd --- /dev/null +++ b/Projects/App/Sources/Extensions/Color+Hex.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/Projects/App/Sources/Features/Main/Client/MainClient.swift b/Projects/App/Sources/Features/Main/Client/MainClient.swift new file mode 100644 index 0000000..1ab2854 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Client/MainClient.swift @@ -0,0 +1,37 @@ +import ComposableArchitecture +import Foundation + +// MARK: - MainClient + +struct MainClient { + var fetchMainData: @Sendable () async -> MainData +} + +// MARK: - DependencyKey + +extension MainClient: DependencyKey { + static let liveValue = MainClient( + fetchMainData: { + // TODO: Implement actual API call + try? await Task.sleep(nanoseconds: 500000000) + return .mock + } + ) + + static let testValue = MainClient( + fetchMainData: { .mock } + ) + + static let previewValue = MainClient( + fetchMainData: { .mock } + ) +} + +// MARK: - DependencyValues + +extension DependencyValues { + var mainClient: MainClient { + get { self[MainClient.self] } + set { self[MainClient.self] = newValue } + } +} diff --git a/Projects/App/Sources/Features/Main/Client/MainMockData.swift b/Projects/App/Sources/Features/Main/Client/MainMockData.swift new file mode 100644 index 0000000..3b6e91b --- /dev/null +++ b/Projects/App/Sources/Features/Main/Client/MainMockData.swift @@ -0,0 +1,180 @@ +import Foundation + +// MARK: - MainData Mock + +extension MainData { + static let mock = MainData( + relayLists: RelayList.mockList, + chartSongs: ChartSong.mockList, + popularArtists: Artist.mockList, + recommendedPlaylists: Playlist.recommendedMockList, + themedPlaylists: Playlist.themedMockList, + youtubeVideos: YouTubeVideo.mockList, + banners: Banner.mockList, + chartUpdateTime: "6월 23일 오전 7시 업데이트" + ) +} + +// MARK: - RelayList Mock + +extension RelayList { + static let mockList: [RelayList] = [ + RelayList( + id: "1", + title: "손님들이 물어보는\n카페 BGM", + participantCount: 3012, + firstSong: Song( + id: "1", + title: "Radical Optimism", + artist: "Dua Lipa", + albumName: "Album", + releaseYear: "2024", + albumArtURL: nil + ), + backgroundImageURL: nil, + remainingSeconds: 130215 + ), + RelayList( + id: "2", + title: "비 오는 날 듣기 좋은\n감성 플레이리스트", + participantCount: 2456, + firstSong: nil, + backgroundImageURL: nil, + remainingSeconds: 0 + ), + ] +} + +// MARK: - ChartSong Mock + +extension ChartSong { + static let mockList: [ChartSong] = [ + ChartSong( + id: "1", + rank: 1, + song: Song( + id: "s1", + title: "Small girl (feat. 도경수 (D.O)", + artist: "이영지", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ChartSong( + id: "2", + rank: 2, + song: Song( + id: "s2", + title: "Supernova", + artist: "aespa", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ChartSong( + id: "3", + rank: 3, + song: Song( + id: "s3", + title: "How Sweet", + artist: "NewJeans", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ChartSong( + id: "4", + rank: 4, + song: Song( + id: "s4", + title: "해야 (HEYA)", + artist: "IVE (아이브)", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ChartSong( + id: "5", + rank: 5, + song: Song( + id: "s5", + title: "소나기", + artist: "이클립스 (ECLIPSE)", + albumName: nil, + releaseYear: nil, + albumArtURL: nil + ) + ), + ] +} + +// MARK: - Artist Mock + +extension Artist { + static let mockList: [Artist] = [ + Artist(id: "a1", name: "BIGBANG (빅뱅)", profileImageURL: nil), + Artist(id: "a2", name: "비투비", profileImageURL: nil), + Artist(id: "a3", name: "윤하(Younha/ユンナ)", profileImageURL: nil), + Artist(id: "a4", name: "엔플라잉(N.Flying)", profileImageURL: nil), + ] +} + +// MARK: - Playlist Mock + +extension Playlist { + static let recommendedMockList: [Playlist] = [ + Playlist(id: "p1", title: "끈적달달한 체리위스키를 머금은 힙합 R&B", coverImageURL: nil), + Playlist(id: "p2", title: "노을처럼 번지는 아날로그 무드", coverImageURL: nil), + Playlist(id: "p3", title: "추위를 녹이는\n음색의 보이스", coverImageURL: nil), + ] + + static let themedMockList: [Playlist] = [ + Playlist(id: "t1", title: "초여름 청량한 케이팝 댄스", coverImageURL: nil), + Playlist(id: "t2", title: "청량함 가득 여름 국힙", coverImageURL: nil), + Playlist(id: "t3", title: "뼛속까지 청량해지는 K-pop", coverImageURL: nil), + ] +} + +// MARK: - YouTubeVideo Mock + +extension YouTubeVideo { + static let mockList: [YouTubeVideo] = [ + YouTubeVideo( + id: "y1", + title: "Falling - 로이킴 [더 시즌즈-최정훈의 밤의공원]", + channelName: "KBS Kpop", + thumbnailURL: nil + ), + YouTubeVideo( + id: "y2", + title: "ZICO (지코) 'SPOT! (feat. JENNIE)' Official MV", + channelName: "HYBE LABELS", + thumbnailURL: nil + ), + ] +} + +// MARK: - Banner Mock + +extension Banner { + static let mockList: [Banner] = [ + Banner( + id: "b1", + title: "실시간 통합 순위를 한 눈에!", + subtitle: "오직 위플리에서만", + imageURL: nil, + actionURL: nil + ), + Banner( + id: "b2", + title: "감성 가득한 일상 보러가기", + subtitle: "위플리 인스타그램 OPEN!", + imageURL: nil, + actionURL: nil + ), + ] +} diff --git a/Projects/App/Sources/Features/Main/Components/ArtistRankingSection.swift b/Projects/App/Sources/Features/Main/Components/ArtistRankingSection.swift new file mode 100644 index 0000000..42cf0a0 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/ArtistRankingSection.swift @@ -0,0 +1,65 @@ +import SwiftUI + +// MARK: - ArtistRankingSection + +struct ArtistRankingSection: View { + let artists: [Artist] + let onArtistTapped: (Artist) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // 헤더 + VStack(alignment: .leading, spacing: 4) { + Text("위플리 인기 랭킹") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Text("위플리 차트에서 인기가 많은 가수들을 모아봤어요") + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color(hex: "A3A3A3")) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 아티스트 목록 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(artists) { artist in + ArtistProfileView(artist: artist) + .onTapGesture { onArtistTapped(artist) } + } + } + .padding(.horizontal, 20) + } + } + } +} + +// MARK: - ArtistProfileView + +struct ArtistProfileView: View { + let artist: Artist + + var body: some View { + VStack(spacing: 4) { + // 프로필 이미지 (원형 마스크) + Circle() + .fill( + LinearGradient( + colors: [Color.gray.opacity(0.5), Color.gray.opacity(0.3)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 80, height: 80) + + // 아티스트 이름 + Text(artist.name) + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + .lineLimit(1) + .frame(width: 92) + .multilineTextAlignment(.center) + } + } +} diff --git a/Projects/App/Sources/Features/Main/Components/BannerSection.swift b/Projects/App/Sources/Features/Main/Components/BannerSection.swift new file mode 100644 index 0000000..2030195 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/BannerSection.swift @@ -0,0 +1,55 @@ +import SwiftUI + +// MARK: - BannerSection + +struct BannerSection: View { + let banners: [Banner] + let onBannerTapped: (Banner) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(banners) { banner in + BannerCard(banner: banner) + .onTapGesture { onBannerTapped(banner) } + } + } + .padding(.horizontal, 20) + } + } +} + +// MARK: - BannerCard + +struct BannerCard: View { + let banner: Banner + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "141417")) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(banner.subtitle) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Color(hex: "F0F0F0")) + + Text(banner.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.leading, 20) + + Spacer() + + // 배너 이미지 영역 (플레이스홀더) + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.3)) + .frame(width: 100, height: 76) + .padding(.trailing, 12) + } + } + .frame(width: 335, height: 84) + } +} diff --git a/Projects/App/Sources/Features/Main/Components/ChartSection.swift b/Projects/App/Sources/Features/Main/Components/ChartSection.swift new file mode 100644 index 0000000..4cd6e00 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/ChartSection.swift @@ -0,0 +1,89 @@ +import SwiftUI + +// MARK: - ChartSection + +struct ChartSection: View { + let songs: [ChartSong] + let updateTime: String + let onSongTapped: (ChartSong) -> Void + let onSeeAllTapped: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // 헤더 + VStack(alignment: .leading, spacing: 4) { + Text("위플리 TOP 100") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Text(updateTime) + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color(hex: "A3A3A3")) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 노래 목록 + VStack(spacing: 8) { + ForEach(songs) { chartSong in + ChartSongRow(chartSong: chartSong) + .onTapGesture { onSongTapped(chartSong) } + } + } + .padding(.vertical, 12) + } + } +} + +// MARK: - ChartSongRow + +struct ChartSongRow: View { + let chartSong: ChartSong + + var body: some View { + HStack(spacing: 0) { + // 앨범 아트 + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [Color.blue.opacity(0.6), Color.purple.opacity(0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 52, height: 52) + .padding(.leading, 20) + + // 순위 + Text("\(chartSong.rank)") + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color.white.opacity(0.8)) + .frame(width: 26) + + // 노래 정보 + VStack(alignment: .leading, spacing: 4) { + Text(chartSong.song.title) + .font(.system(size: 14, weight: .light)) + .foregroundColor(.white) + .lineLimit(1) + + Text(chartSong.song.artist) + .font(.system(size: 12, weight: .light)) + .foregroundColor(Color.white.opacity(0.7)) + .lineLimit(1) + } + + Spacer() + + // 더보기 버튼 + Button(action: {}) { + Image(systemName: "ellipsis") + .font(.system(size: 16)) + .foregroundColor(.white) + } + .frame(width: 44, height: 44) + .padding(.trailing, 20) + } + .frame(height: 52) + } +} diff --git a/Projects/App/Sources/Features/Main/Components/MainAppBar.swift b/Projects/App/Sources/Features/Main/Components/MainAppBar.swift new file mode 100644 index 0000000..55698f3 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/MainAppBar.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct MainAppBar: View { + let topSafeArea: CGFloat + let onSearchTapped: () -> Void + let onNotificationTapped: () -> Void + + var body: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: topSafeArea) + + HStack { + // 로고 + Text("w.") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Spacer() + + // 검색 버튼 + Button(action: onSearchTapped) { + Image(systemName: "magnifyingglass") + .font(.system(size: 20)) + .foregroundColor(.white) + } + .frame(width: 40, height: 40) + + // 알림 버튼 + Button(action: onNotificationTapped) { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + .font(.system(size: 20)) + .foregroundColor(.white) + + Circle() + .fill(Color.red) + .frame(width: 5, height: 5) + .offset(x: 2, y: -2) + } + } + .frame(width: 40, height: 40) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + + Spacer() + } + } +} diff --git a/Projects/App/Sources/Features/Main/Components/PlaylistSection.swift b/Projects/App/Sources/Features/Main/Components/PlaylistSection.swift new file mode 100644 index 0000000..216a750 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/PlaylistSection.swift @@ -0,0 +1,69 @@ +import SwiftUI + +// MARK: - PlaylistSection + +struct PlaylistSection: View { + let title: String + let playlists: [Playlist] + let onPlaylistTapped: (Playlist) -> Void + let onSeeAllTapped: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 헤더 + Button(action: onSeeAllTapped) { + HStack(spacing: 4) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "D8D8DD")) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 플레이리스트 목록 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(playlists) { playlist in + PlaylistItemView(playlist: playlist) + .onTapGesture { onPlaylistTapped(playlist) } + } + } + .padding(.horizontal, 20) + } + } + } +} + +// MARK: - PlaylistItemView + +struct PlaylistItemView: View { + let playlist: Playlist + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 커버 이미지 (플레이스홀더) + RoundedRectangle(cornerRadius: 12) + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.6), Color.pink.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 136, height: 136) + + // 제목 + Text(playlist.title) + .font(.system(size: 13, weight: .light)) + .foregroundColor(.white) + .lineLimit(2) + .frame(width: 136, alignment: .leading) + .padding(.top, 12) + } + } +} diff --git a/Projects/App/Sources/Features/Main/Components/RelayListSection.swift b/Projects/App/Sources/Features/Main/Components/RelayListSection.swift new file mode 100644 index 0000000..9890f4c --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/RelayListSection.swift @@ -0,0 +1,183 @@ +import SwiftUI + +// MARK: - RelayListSection + +struct RelayListSection: View { + let relayLists: [RelayList] + let currentIndex: Int + let topSafeArea: CGFloat + let onPageChanged: (Int) -> Void + + var body: some View { + VStack(spacing: 16) { + TabView(selection: Binding( + get: { currentIndex }, + set: { onPageChanged($0) } + )) { + ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in + RelayListCard(relayList: relayList, topSafeArea: topSafeArea) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 400 + topSafeArea) + + // 커스텀 인디케이터 + HStack(spacing: 4) { + ForEach(0 ..< relayLists.count, id: \.self) { index in + Capsule() + .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) + .frame(width: index == currentIndex ? 20 : 4, height: 4) + } + } + } + } +} + +// MARK: - RelayListCard + +struct RelayListCard: View { + let relayList: RelayList + var topSafeArea: CGFloat = 0 + + var body: some View { + ZStack(alignment: .bottom) { + // 배경 이미지 (플레이스홀더) + LinearGradient( + colors: [Color.purple.opacity(0.6), Color.blue.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // 하단 그라디언트 + LinearGradient( + colors: [ + Color.black.opacity(0), + Color.black.opacity(0.1), + Color.black.opacity(0.2), + Color.black.opacity(0.6), + Color.black, + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 342) + + // 콘텐츠 + VStack(alignment: .leading, spacing: 0) { + // 상단 safe area + 앱바 높이만큼 여백 + Spacer() + .frame(height: topSafeArea + 60) + + VStack(alignment: .leading, spacing: 4) { + // 제목 + Text(relayList.title) + .font(.system(size: 28, weight: .semibold)) + .foregroundColor(.white) + .lineSpacing(6) + + // 참여자 수 + Text("\(relayList.participantCount.formatted())명 참여") + .font(.system(size: 14, weight: .regular)) + .foregroundColor(Color(hex: "D8D8DD")) + } + + Spacer() + .frame(height: 32) + + // 첫 곡 정보 + if let firstSong = relayList.firstSong { + FirstSongView(song: firstSong) + } + + Spacer() + .frame(height: 16) + + // 타이머 또는 완성 상태 + RelayListTimerView(relayList: relayList) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } +} + +// MARK: - FirstSongView + +struct FirstSongView: View { + let song: Song + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(song.title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + + HStack(spacing: 8) { + if let albumName = song.albumName { + Text(albumName) + .font(.system(size: 14, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + } + + if song.albumName != nil, song.releaseYear != nil { + Circle() + .fill(Color(hex: "B5B5C0")) + .frame(width: 4, height: 4) + } + + if let year = song.releaseYear { + Text(year) + .font(.system(size: 14, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + } + } + } + + Spacer() + + // 앨범 아트 (플레이스홀더) + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [Color.orange, Color.pink], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 60, height: 60) + } + } +} + +// MARK: - RelayListTimerView + +struct RelayListTimerView: View { + let relayList: RelayList + + var body: some View { + HStack { + if relayList.isCompleted { + Text("릴레이리스트가 완성되었어요 🎉") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + } else { + Text("플리 완성까지") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Spacer() + + Text(relayList.remainingTimeText) + .font(.system(size: 14, weight: .light)) + .foregroundColor(Color(hex: "C2C2C2")) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/Projects/App/Sources/Features/Main/Components/YouTubeSection.swift b/Projects/App/Sources/Features/Main/Components/YouTubeSection.swift new file mode 100644 index 0000000..7d37a67 --- /dev/null +++ b/Projects/App/Sources/Features/Main/Components/YouTubeSection.swift @@ -0,0 +1,74 @@ +import SwiftUI + +// MARK: - YouTubeSection + +struct YouTubeSection: View { + let videos: [YouTubeVideo] + let onVideoTapped: (YouTubeVideo) -> Void + let onSeeAllTapped: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 헤더 + Button(action: onSeeAllTapped) { + HStack(spacing: 4) { + // YouTube 아이콘 (플레이스홀더) + Image(systemName: "play.rectangle.fill") + .font(.system(size: 12)) + .foregroundColor(.red) + + Text("Youtube Music") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(hex: "F0F0F0")) + + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "D8D8DD")) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + // 영상 목록 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(videos) { video in + YouTubeVideoItemView(video: video) + .onTapGesture { onVideoTapped(video) } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + } +} + +// MARK: - YouTubeVideoItemView + +struct YouTubeVideoItemView: View { + let video: YouTubeVideo + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 썸네일 (플레이스홀더) + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.3)) + .frame(width: 240, height: 135) + + // 제목 + Text(video.title) + .font(.system(size: 15, weight: .regular)) + .foregroundColor(Color(hex: "F0F0F0")) + .lineLimit(1) + .frame(width: 240, alignment: .leading) + .padding(.top, 11) + + // 채널명 + Text(video.channelName) + .font(.system(size: 13, weight: .light)) + .foregroundColor(Color(hex: "B5B5C0")) + .padding(.top, 4) + } + } +} diff --git a/Projects/App/Sources/Features/Main/MainFeature.swift b/Projects/App/Sources/Features/Main/MainFeature.swift index 514f447..13c1fde 100644 --- a/Projects/App/Sources/Features/Main/MainFeature.swift +++ b/Projects/App/Sources/Features/Main/MainFeature.swift @@ -119,214 +119,3 @@ struct MainFeature { } } } - -// MARK: - Models - -struct RelayList: Equatable, Identifiable { - let id: String - let title: String - let participantCount: Int - let firstSong: Song? - let backgroundImageURL: String? - var remainingSeconds: Int - - var isCompleted: Bool { - remainingSeconds <= 0 - } - - var remainingTimeText: String { - guard remainingSeconds > 0 else { return "" } - let days = remainingSeconds / 86400 - let hours = (remainingSeconds % 86400) / 3600 - let minutes = (remainingSeconds % 3600) / 60 - let seconds = remainingSeconds % 60 - return "\(days)일 \(hours)시간 \(minutes)분 \(seconds)초" - } -} - -struct Song: Equatable, Identifiable { - let id: String - let title: String - let artist: String - let albumName: String? - let releaseYear: String? - let albumArtURL: String? -} - -struct ChartSong: Equatable, Identifiable { - let id: String - let rank: Int - let song: Song -} - -struct Artist: Equatable, Identifiable { - let id: String - let name: String - let profileImageURL: String? -} - -struct Playlist: Equatable, Identifiable { - let id: String - let title: String - let coverImageURL: String? -} - -struct YouTubeVideo: Equatable, Identifiable { - let id: String - let title: String - let channelName: String - let thumbnailURL: String? -} - -struct Banner: Equatable, Identifiable { - let id: String - let title: String - let subtitle: String - let imageURL: String? - let actionURL: String? -} - -struct MainData: Equatable { - let relayLists: [RelayList] - let chartSongs: [ChartSong] - let popularArtists: [Artist] - let recommendedPlaylists: [Playlist] - let themedPlaylists: [Playlist] - let youtubeVideos: [YouTubeVideo] - let banners: [Banner] - let chartUpdateTime: String -} - -// MARK: - MainClient Dependency - -struct MainClient { - var fetchMainData: @Sendable () async -> MainData -} - -extension MainClient: DependencyKey { - static let liveValue = MainClient( - fetchMainData: { - // TODO: Implement actual API call - try? await Task.sleep(nanoseconds: 500000000) - return .mock - } - ) - - static let testValue = MainClient( - fetchMainData: { .mock } - ) - - static let previewValue = MainClient( - fetchMainData: { .mock } - ) -} - -extension DependencyValues { - var mainClient: MainClient { - get { self[MainClient.self] } - set { self[MainClient.self] = newValue } - } -} - -// MARK: - Mock Data - -extension MainData { - static let mock = MainData( - relayLists: [ - RelayList( - id: "1", - title: "손님들이 물어보는\n카페 BGM", - participantCount: 3012, - firstSong: Song( - id: "1", - title: "Radical Optimism", - artist: "Dua Lipa", - albumName: "Album", - releaseYear: "2024", - albumArtURL: nil - ), - backgroundImageURL: nil, - remainingSeconds: 130215 - ), - RelayList( - id: "2", - title: "비 오는 날 듣기 좋은\n감성 플레이리스트", - participantCount: 2456, - firstSong: nil, - backgroundImageURL: nil, - remainingSeconds: 0 - ), - ], - chartSongs: [ - ChartSong( - id: "1", - rank: 1, - song: Song( - id: "s1", - title: "Small girl (feat. 도경수 (D.O)", - artist: "이영지", - albumName: nil, - releaseYear: nil, - albumArtURL: nil - ) - ), - ChartSong( - id: "2", - rank: 2, - song: Song(id: "s2", title: "Supernova", artist: "aespa", albumName: nil, releaseYear: nil, albumArtURL: nil) - ), - ChartSong( - id: "3", - rank: 3, - song: Song(id: "s3", title: "How Sweet", artist: "NewJeans", albumName: nil, releaseYear: nil, albumArtURL: nil) - ), - ChartSong( - id: "4", - rank: 4, - song: Song( - id: "s4", - title: "해야 (HEYA)", - artist: "IVE (아이브)", - albumName: nil, - releaseYear: nil, - albumArtURL: nil - ) - ), - ChartSong( - id: "5", - rank: 5, - song: Song(id: "s5", title: "소나기", artist: "이클립스 (ECLIPSE)", albumName: nil, releaseYear: nil, albumArtURL: nil) - ), - ], - popularArtists: [ - Artist(id: "a1", name: "BIGBANG (빅뱅)", profileImageURL: nil), - Artist(id: "a2", name: "비투비", profileImageURL: nil), - Artist(id: "a3", name: "윤하(Younha/ユンナ)", profileImageURL: nil), - Artist(id: "a4", name: "엔플라잉(N.Flying)", profileImageURL: nil), - ], - recommendedPlaylists: [ - Playlist(id: "p1", title: "끈적달달한 체리위스키를 머금은 힙합 R&B", coverImageURL: nil), - Playlist(id: "p2", title: "노을처럼 번지는 아날로그 무드", coverImageURL: nil), - Playlist(id: "p3", title: "추위를 녹이는\n음색의 보이스", coverImageURL: nil), - ], - themedPlaylists: [ - Playlist(id: "t1", title: "초여름 청량한 케이팝 댄스", coverImageURL: nil), - Playlist(id: "t2", title: "청량함 가득 여름 국힙", coverImageURL: nil), - Playlist(id: "t3", title: "뼛속까지 청량해지는 K-pop", coverImageURL: nil), - ], - youtubeVideos: [ - YouTubeVideo(id: "y1", title: "Falling - 로이킴 [더 시즌즈-최정훈의 밤의공원]", channelName: "KBS Kpop", thumbnailURL: nil), - YouTubeVideo( - id: "y2", - title: "ZICO (지코) 'SPOT! (feat. JENNIE)' Official MV", - channelName: "HYBE LABELS", - thumbnailURL: nil - ), - ], - banners: [ - Banner(id: "b1", title: "실시간 통합 순위를 한 눈에!", subtitle: "오직 위플리에서만", imageURL: nil, actionURL: nil), - Banner(id: "b2", title: "감성 가득한 일상 보러가기", subtitle: "위플리 인스타그램 OPEN!", imageURL: nil, actionURL: nil), - ], - chartUpdateTime: "6월 23일 오전 7시 업데이트" - ) -} diff --git a/Projects/App/Sources/Features/Main/MainView.swift b/Projects/App/Sources/Features/Main/MainView.swift index b7d24e8..f40b12c 100644 --- a/Projects/App/Sources/Features/Main/MainView.swift +++ b/Projects/App/Sources/Features/Main/MainView.swift @@ -12,7 +12,7 @@ struct MainView: View { ZStack(alignment: .top) { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 24) { - // 릴레이리스트 섹션 (상단 safe area까지 확장) + // 릴레이리스트 섹션 RelayListSection( relayLists: store.relayLists, currentIndex: store.currentRelayListIndex, @@ -84,614 +84,6 @@ struct MainView: View { } } -// MARK: - MainAppBar - -private struct MainAppBar: View { - let topSafeArea: CGFloat - let onSearchTapped: () -> Void - let onNotificationTapped: () -> Void - - var body: some View { - VStack(spacing: 0) { - Spacer() - .frame(height: topSafeArea) - - HStack { - // 로고 - Text("w.") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) - - Spacer() - - // 검색 버튼 - Button(action: onSearchTapped) { - Image(systemName: "magnifyingglass") - .font(.system(size: 20)) - .foregroundColor(.white) - } - .frame(width: 40, height: 40) - - // 알림 버튼 - Button(action: onNotificationTapped) { - ZStack(alignment: .topTrailing) { - Image(systemName: "bell") - .font(.system(size: 20)) - .foregroundColor(.white) - - Circle() - .fill(Color.red) - .frame(width: 5, height: 5) - .offset(x: 2, y: -2) - } - } - .frame(width: 40, height: 40) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - - Spacer() - } - } -} - -// MARK: - RelayListSection - -private struct RelayListSection: View { - let relayLists: [RelayList] - let currentIndex: Int - let topSafeArea: CGFloat - let onPageChanged: (Int) -> Void - - var body: some View { - VStack(spacing: 16) { - TabView(selection: Binding( - get: { currentIndex }, - set: { onPageChanged($0) } - )) { - ForEach(Array(relayLists.enumerated()), id: \.element.id) { index, relayList in - RelayListCard(relayList: relayList, topSafeArea: topSafeArea) - .tag(index) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 400 + topSafeArea) - - // 커스텀 인디케이터 - HStack(spacing: 4) { - ForEach(0 ..< relayLists.count, id: \.self) { index in - Capsule() - .fill(Color.white.opacity(index == currentIndex ? 0.7 : 0.3)) - .frame(width: index == currentIndex ? 20 : 4, height: 4) - } - } - } - } -} - -// MARK: - RelayListCard - -private struct RelayListCard: View { - let relayList: RelayList - var topSafeArea: CGFloat = 0 - - var body: some View { - ZStack(alignment: .bottom) { - // 배경 이미지 (플레이스홀더) - LinearGradient( - colors: [Color.purple.opacity(0.6), Color.blue.opacity(0.4)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - - // 하단 그라디언트 - LinearGradient( - colors: [ - Color.black.opacity(0), - Color.black.opacity(0.1), - Color.black.opacity(0.2), - Color.black.opacity(0.6), - Color.black, - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 342) - - // 콘텐츠 - VStack(alignment: .leading, spacing: 0) { - // 상단 safe area + 앱바 높이만큼 여백 - Spacer() - .frame(height: topSafeArea + 60) - - VStack(alignment: .leading, spacing: 4) { - // 제목 - Text(relayList.title) - .font(.system(size: 28, weight: .semibold)) - .foregroundColor(.white) - .lineSpacing(6) - - // 참여자 수 - Text("\(relayList.participantCount.formatted())명 참여") - .font(.system(size: 14, weight: .regular)) - .foregroundColor(Color(hex: "D8D8DD")) - } - - Spacer() - .frame(height: 32) - - // 첫 곡 정보 - if let firstSong = relayList.firstSong { - FirstSongView(song: firstSong) - } - - Spacer() - .frame(height: 16) - - // 타이머 또는 완성 상태 - TimerView(relayList: relayList) - } - .padding(.horizontal, 20) - .padding(.bottom, 20) - } - } -} - -// MARK: - FirstSongView - -private struct FirstSongView: View { - let song: Song - - var body: some View { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text(song.title) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.white) - .lineLimit(1) - - HStack(spacing: 8) { - if let albumName = song.albumName { - Text(albumName) - .font(.system(size: 14, weight: .light)) - .foregroundColor(Color(hex: "B5B5C0")) - } - - if song.albumName != nil, song.releaseYear != nil { - Circle() - .fill(Color(hex: "B5B5C0")) - .frame(width: 4, height: 4) - } - - if let year = song.releaseYear { - Text(year) - .font(.system(size: 14, weight: .light)) - .foregroundColor(Color(hex: "B5B5C0")) - } - } - } - - Spacer() - - // 앨범 아트 (플레이스홀더) - RoundedRectangle(cornerRadius: 8) - .fill( - LinearGradient( - colors: [Color.orange, Color.pink], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 60, height: 60) - } - } -} - -// MARK: - TimerView - -private struct TimerView: View { - let relayList: RelayList - - var body: some View { - HStack { - if relayList.isCompleted { - Text("릴레이리스트가 완성되었어요 🎉") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(Color(hex: "F0F0F0")) - } else { - Text("플리 완성까지") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(Color(hex: "F0F0F0")) - - Spacer() - - Text(relayList.remainingTimeText) - .font(.system(size: 14, weight: .light)) - .foregroundColor(Color(hex: "C2C2C2")) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} - -// MARK: - ChartSection - -private struct ChartSection: View { - let songs: [ChartSong] - let updateTime: String - let onSongTapped: (ChartSong) -> Void - let onSeeAllTapped: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - // 헤더 - VStack(alignment: .leading, spacing: 4) { - Text("위플리 TOP 100") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(Color(hex: "F0F0F0")) - - Text(updateTime) - .font(.system(size: 12, weight: .light)) - .foregroundColor(Color(hex: "A3A3A3")) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - - // 노래 목록 - VStack(spacing: 8) { - ForEach(songs) { chartSong in - ChartSongRow(chartSong: chartSong) - .onTapGesture { onSongTapped(chartSong) } - } - } - .padding(.vertical, 12) - } - } -} - -// MARK: - ChartSongRow - -private struct ChartSongRow: View { - let chartSong: ChartSong - - var body: some View { - HStack(spacing: 0) { - // 앨범 아트 - RoundedRectangle(cornerRadius: 4) - .fill( - LinearGradient( - colors: [Color.blue.opacity(0.6), Color.purple.opacity(0.6)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 52, height: 52) - .padding(.leading, 20) - - // 순위 - Text("\(chartSong.rank)") - .font(.system(size: 12, weight: .light)) - .foregroundColor(Color.white.opacity(0.8)) - .frame(width: 26) - - // 노래 정보 - VStack(alignment: .leading, spacing: 4) { - Text(chartSong.song.title) - .font(.system(size: 14, weight: .light)) - .foregroundColor(.white) - .lineLimit(1) - - Text(chartSong.song.artist) - .font(.system(size: 12, weight: .light)) - .foregroundColor(Color.white.opacity(0.7)) - .lineLimit(1) - } - - Spacer() - - // 더보기 버튼 - Button(action: {}) { - Image(systemName: "ellipsis") - .font(.system(size: 16)) - .foregroundColor(.white) - } - .frame(width: 44, height: 44) - .padding(.trailing, 20) - } - .frame(height: 52) - } -} - -// MARK: - BannerSection - -private struct BannerSection: View { - let banners: [Banner] - let onBannerTapped: (Banner) -> Void - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(banners) { banner in - BannerCard(banner: banner) - .onTapGesture { onBannerTapped(banner) } - } - } - .padding(.horizontal, 20) - } - } -} - -// MARK: - BannerCard - -private struct BannerCard: View { - let banner: Banner - - var body: some View { - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 8) - .fill(Color(hex: "141417")) - - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(banner.subtitle) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(Color(hex: "F0F0F0")) - - Text(banner.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - } - .padding(.leading, 20) - - Spacer() - - // 배너 이미지 영역 (플레이스홀더) - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.3)) - .frame(width: 100, height: 76) - .padding(.trailing, 12) - } - } - .frame(width: 335, height: 84) - } -} - -// MARK: - ArtistRankingSection - -private struct ArtistRankingSection: View { - let artists: [Artist] - let onArtistTapped: (Artist) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - // 헤더 - VStack(alignment: .leading, spacing: 4) { - Text("위플리 인기 랭킹") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(Color(hex: "F0F0F0")) - - Text("위플리 차트에서 인기가 많은 가수들을 모아봤어요") - .font(.system(size: 12, weight: .light)) - .foregroundColor(Color(hex: "A3A3A3")) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - - // 아티스트 목록 - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(artists) { artist in - ArtistProfileView(artist: artist) - .onTapGesture { onArtistTapped(artist) } - } - } - .padding(.horizontal, 20) - } - } - } -} - -// MARK: - ArtistProfileView - -private struct ArtistProfileView: View { - let artist: Artist - - var body: some View { - VStack(spacing: 4) { - // 프로필 이미지 (원형 마스크) - Circle() - .fill( - LinearGradient( - colors: [Color.gray.opacity(0.5), Color.gray.opacity(0.3)], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: 80, height: 80) - - // 아티스트 이름 - Text(artist.name) - .font(.system(size: 12, weight: .light)) - .foregroundColor(Color(hex: "B5B5C0")) - .lineLimit(1) - .frame(width: 92) - .multilineTextAlignment(.center) - } - } -} - -// MARK: - PlaylistSection - -private struct PlaylistSection: View { - let title: String - let playlists: [Playlist] - let onPlaylistTapped: (Playlist) -> Void - let onSeeAllTapped: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // 헤더 - Button(action: onSeeAllTapped) { - HStack(spacing: 4) { - Text(title) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(Color(hex: "F0F0F0")) - - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(Color(hex: "D8D8DD")) - } - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - - // 플레이리스트 목록 - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(playlists) { playlist in - PlaylistItemView(playlist: playlist) - .onTapGesture { onPlaylistTapped(playlist) } - } - } - .padding(.horizontal, 20) - } - } - } -} - -// MARK: - PlaylistItemView - -private struct PlaylistItemView: View { - let playlist: Playlist - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // 커버 이미지 (플레이스홀더) - RoundedRectangle(cornerRadius: 12) - .fill( - LinearGradient( - colors: [Color.purple.opacity(0.6), Color.pink.opacity(0.4)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 136, height: 136) - - // 제목 - Text(playlist.title) - .font(.system(size: 13, weight: .light)) - .foregroundColor(.white) - .lineLimit(2) - .frame(width: 136, alignment: .leading) - .padding(.top, 12) - } - } -} - -// MARK: - YouTubeSection - -private struct YouTubeSection: View { - let videos: [YouTubeVideo] - let onVideoTapped: (YouTubeVideo) -> Void - let onSeeAllTapped: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // 헤더 - Button(action: onSeeAllTapped) { - HStack(spacing: 4) { - // YouTube 아이콘 (플레이스홀더) - Image(systemName: "play.rectangle.fill") - .font(.system(size: 12)) - .foregroundColor(.red) - - Text("Youtube Music") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(Color(hex: "F0F0F0")) - - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(Color(hex: "D8D8DD")) - } - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - - // 영상 목록 - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(videos) { video in - YouTubeVideoItemView(video: video) - .onTapGesture { onVideoTapped(video) } - } - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - } - } - } -} - -// MARK: - YouTubeVideoItemView - -private struct YouTubeVideoItemView: View { - let video: YouTubeVideo - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // 썸네일 (플레이스홀더) - RoundedRectangle(cornerRadius: 12) - .fill(Color.gray.opacity(0.3)) - .frame(width: 240, height: 135) - - // 제목 - Text(video.title) - .font(.system(size: 15, weight: .regular)) - .foregroundColor(Color(hex: "F0F0F0")) - .lineLimit(1) - .frame(width: 240, alignment: .leading) - .padding(.top, 11) - - // 채널명 - Text(video.channelName) - .font(.system(size: 13, weight: .light)) - .foregroundColor(Color(hex: "B5B5C0")) - .padding(.top, 4) - } - } -} - -// MARK: - Color Extension - -extension Color { - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (255, 0, 0, 0) - } - self.init( - .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 - ) - } -} - // MARK: - Preview #Preview { diff --git a/Projects/App/Sources/Features/Main/Models/MainModels.swift b/Projects/App/Sources/Features/Main/Models/MainModels.swift new file mode 100644 index 0000000..fda9e6a --- /dev/null +++ b/Projects/App/Sources/Features/Main/Models/MainModels.swift @@ -0,0 +1,92 @@ +import Foundation + +// MARK: - RelayList + +struct RelayList: Equatable, Identifiable { + let id: String + let title: String + let participantCount: Int + let firstSong: Song? + let backgroundImageURL: String? + var remainingSeconds: Int + + var isCompleted: Bool { + remainingSeconds <= 0 + } + + var remainingTimeText: String { + guard remainingSeconds > 0 else { return "" } + let days = remainingSeconds / 86400 + let hours = (remainingSeconds % 86400) / 3600 + let minutes = (remainingSeconds % 3600) / 60 + let seconds = remainingSeconds % 60 + return "\(days)일 \(hours)시간 \(minutes)분 \(seconds)초" + } +} + +// MARK: - Song + +struct Song: Equatable, Identifiable { + let id: String + let title: String + let artist: String + let albumName: String? + let releaseYear: String? + let albumArtURL: String? +} + +// MARK: - ChartSong + +struct ChartSong: Equatable, Identifiable { + let id: String + let rank: Int + let song: Song +} + +// MARK: - Artist + +struct Artist: Equatable, Identifiable { + let id: String + let name: String + let profileImageURL: String? +} + +// MARK: - Playlist + +struct Playlist: Equatable, Identifiable { + let id: String + let title: String + let coverImageURL: String? +} + +// MARK: - YouTubeVideo + +struct YouTubeVideo: Equatable, Identifiable { + let id: String + let title: String + let channelName: String + let thumbnailURL: String? +} + +// MARK: - Banner + +struct Banner: Equatable, Identifiable { + let id: String + let title: String + let subtitle: String + let imageURL: String? + let actionURL: String? +} + +// MARK: - MainData + +struct MainData: Equatable { + let relayLists: [RelayList] + let chartSongs: [ChartSong] + let popularArtists: [Artist] + let recommendedPlaylists: [Playlist] + let themedPlaylists: [Playlist] + let youtubeVideos: [YouTubeVideo] + let banners: [Banner] + let chartUpdateTime: String +}