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/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/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() + } + ) +} 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 new file mode 100644 index 0000000..13c1fde --- /dev/null +++ b/Projects/App/Sources/Features/Main/MainFeature.swift @@ -0,0 +1,121 @@ +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 + } + } + } +} diff --git a/Projects/App/Sources/Features/Main/MainView.swift b/Projects/App/Sources/Features/Main/MainView.swift new file mode 100644 index 0000000..f40b12c --- /dev/null +++ b/Projects/App/Sources/Features/Main/MainView.swift @@ -0,0 +1,95 @@ +import ComposableArchitecture +import SwiftUI + +struct MainView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + GeometryReader { geometry in + let topSafeArea = geometry.safeAreaInsets.top + + ZStack(alignment: .top) { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 24) { + // 릴레이리스트 섹션 + 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) + } + .onAppear { + store.send(.onAppear) + } + } + } +} + +// MARK: - Preview + +#Preview { + MainView( + store: Store(initialState: MainFeature.State()) { + MainFeature() + } + ) +} 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 +}