From 0045c8d73971fa27facd78fa637237b679b51d21 Mon Sep 17 00:00:00 2001 From: mark Date: Tue, 17 Feb 2026 13:55:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(Add):=20Spotify=20api=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=84=B8=ED=8C=85=20=EB=B0=8F=20add=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KillingPart/Info.plist | 2 + KillingPart/Models/SpotifyModel.swift | 56 ++++ KillingPart/Services/SpotifyService.swift | 271 ++++++++++++++++++ .../ViewModels/Add/AddTabViewModel.swift | 117 ++++++++ .../Views/Screens/Main/Add/AddTabView.swift | 207 +++++++++++-- 5 files changed, 629 insertions(+), 24 deletions(-) create mode 100644 KillingPart/Models/SpotifyModel.swift create mode 100644 KillingPart/Services/SpotifyService.swift create mode 100644 KillingPart/ViewModels/Add/AddTabViewModel.swift diff --git a/KillingPart/Info.plist b/KillingPart/Info.plist index a57e406..ec8b063 100644 --- a/KillingPart/Info.plist +++ b/KillingPart/Info.plist @@ -2,6 +2,8 @@ + SPOTIFY_BASIC_AUTH + $(SPOTIFY_BASIC_AUTH) BASE_URL $(BASE_URL) KAKAO_NATIVE_APP_KEY diff --git a/KillingPart/Models/SpotifyModel.swift b/KillingPart/Models/SpotifyModel.swift new file mode 100644 index 0000000..f9caf0f --- /dev/null +++ b/KillingPart/Models/SpotifyModel.swift @@ -0,0 +1,56 @@ +import Foundation + +struct SpotifyTokenResponse: Decodable { + let accessToken: String + let tokenType: String + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + } +} + +struct SpotifySearchResponse: Decodable { + let tracks: SpotifyTracks +} + +struct SpotifyTracks: Decodable { + let items: [SpotifyTrackItem] +} + +struct SpotifyTrackItem: Decodable { + let id: String + let name: String + let artists: [SpotifyArtist] + let album: SpotifyAlbum +} + +struct SpotifyArtist: Decodable { + let name: String +} + +struct SpotifyAlbum: Decodable { + let id: String + let images: [SpotifyAlbumImage] +} + +struct SpotifyAlbumImage: Decodable { + let url: String + let width: Int? + let height: Int? +} + +struct SpotifySimpleTrack: Identifiable { + let id: String + let title: String + let artist: String + let albumImageUrl: String? + let albumId: String + + var albumImageURL: URL? { + guard let albumImageUrl else { return nil } + return URL(string: albumImageUrl) + } +} diff --git a/KillingPart/Services/SpotifyService.swift b/KillingPart/Services/SpotifyService.swift new file mode 100644 index 0000000..220b777 --- /dev/null +++ b/KillingPart/Services/SpotifyService.swift @@ -0,0 +1,271 @@ +import Foundation + +protocol SpotifyServicing { + func searchTracks(query: String, limit: Int) async throws -> [SpotifySimpleTrack] +} + +enum SpotifyServiceError: LocalizedError { + case missingBasicAuth + case invalidResponse + case unauthorized + case serverError(statusCode: Int, message: String?) + case decodingFailed + case networkFailure(message: String) + + var errorDescription: String? { + switch self { + case .missingBasicAuth: + return "Spotify 인증 설정이 누락되었어요." + case .invalidResponse: + return "Spotify 응답을 확인할 수 없어요." + case .unauthorized: + return "Spotify 인증에 실패했어요. 잠시 후 다시 시도해 주세요." + case .serverError(_, let message): + return message ?? "Spotify 검색 처리에 실패했어요." + case .decodingFailed: + return "Spotify 응답 파싱에 실패했어요." + case .networkFailure(let message): + return message + } + } +} + +struct SpotifyService: SpotifyServicing { + private static let tokenCache = SpotifyTokenCache() + + private let session: URLSession + private let decoder = JSONDecoder() + + init(session: URLSession = .shared) { + self.session = session + } + + func searchTracks(query: String, limit: Int = 5) async throws -> [SpotifySimpleTrack] { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return [] } + + let token = try await fetchAccessToken(forceRefresh: false) + do { + return try await searchTracksWithBearerToken( + trimmedQuery, + limit: limit, + bearerToken: token + ) + } catch let error as SpotifyServiceError { + guard case .unauthorized = error else { + throw error + } + } catch { + throw mapError(error) + } + + let refreshedToken = try await fetchAccessToken(forceRefresh: true) + return try await searchTracksWithBearerToken( + trimmedQuery, + limit: limit, + bearerToken: refreshedToken + ) + } + + private func searchTracksWithBearerToken( + _ query: String, + limit: Int, + bearerToken: String + ) async throws -> [SpotifySimpleTrack] { + var components = URLComponents(string: "https://api.spotify.com/v1/search") + components?.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "type", value: "track"), + URLQueryItem(name: "market", value: "KR"), + URLQueryItem(name: "limit", value: String(limit)) + ] + + guard let url = components?.url else { + throw SpotifyServiceError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + request.setValue("ko-KR", forHTTPHeaderField: "Accept-Language") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await performDataTask(with: request) + switch response.statusCode { + case 200..<300: + let decoded: SpotifySearchResponse + do { + decoded = try decoder.decode(SpotifySearchResponse.self, from: data) + } catch { + throw SpotifyServiceError.decodingFailed + } + + return decoded.tracks.items.map { item in + let artistNames = item.artists.map(\.name).joined(separator: ", ") + + return SpotifySimpleTrack( + id: item.id, + title: item.name, + artist: artistNames.isEmpty ? "Unknown Artist" : artistNames, + albumImageUrl: item.album.images.sorted { (lhs, rhs) in + (lhs.width ?? 0) > (rhs.width ?? 0) + }.first?.url, + albumId: item.album.id + ) + } + case 401: + await Self.tokenCache.clear() + throw SpotifyServiceError.unauthorized + default: + throw SpotifyServiceError.serverError( + statusCode: response.statusCode, + message: responseMessage(from: data) + ) + } + } + + private func fetchAccessToken(forceRefresh: Bool) async throws -> String { + if !forceRefresh, let cachedToken = await Self.tokenCache.validToken() { + return cachedToken + } + + let basicAuth = try spotifyBasicAuth() + guard let url = URL(string: "https://accounts.spotify.com/api/token") else { + throw SpotifyServiceError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.post.rawValue + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(basicAuth, forHTTPHeaderField: "Authorization") + request.httpBody = "grant_type=client_credentials".data(using: .utf8) + + let (data, response) = try await performDataTask(with: request) + switch response.statusCode { + case 200..<300: + let decoded: SpotifyTokenResponse + do { + decoded = try decoder.decode(SpotifyTokenResponse.self, from: data) + } catch { + throw SpotifyServiceError.decodingFailed + } + await Self.tokenCache.save( + token: decoded.accessToken, + expiresIn: decoded.expiresIn + ) + return decoded.accessToken + case 401: + await Self.tokenCache.clear() + throw SpotifyServiceError.unauthorized + default: + throw SpotifyServiceError.serverError( + statusCode: response.statusCode, + message: responseMessage(from: data) + ) + } + } + + private func spotifyBasicAuth() throws -> String { + let rawValue = (Bundle.main.object(forInfoDictionaryKey: "SPOTIFY_BASIC_AUTH") as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawValue.isEmpty, rawValue != "$(SPOTIFY_BASIC_AUTH)" else { + throw SpotifyServiceError.missingBasicAuth + } + return rawValue + } + + private func performDataTask(with request: URLRequest) async throws -> (Data, HTTPURLResponse) { + do { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw SpotifyServiceError.invalidResponse + } + return (data, httpResponse) + } catch { + throw mapError(error) + } + } + + private func mapError(_ error: Error) -> SpotifyServiceError { + if let spotifyError = error as? SpotifyServiceError { + return spotifyError + } + return .networkFailure(message: "Spotify 요청 중 네트워크 오류가 발생했어요.") + } + + private func responseMessage(from data: Data) -> String? { + guard !data.isEmpty else { return nil } + + if + let decodedError = try? decoder.decode(SpotifyTopLevelErrorResponse.self, from: data), + let message = decodedError.error?.message?.trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty + { + return message + } + + if + let tokenError = try? decoder.decode(SpotifyTokenErrorResponse.self, from: data), + let description = tokenError.errorDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !description.isEmpty + { + return description + } + + if + let raw = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + { + return raw + } + + return nil + } +} + +private struct SpotifyTopLevelErrorResponse: Decodable { + let error: SpotifyMessageError? +} + +private struct SpotifyMessageError: Decodable { + let status: Int? + let message: String? +} + +private struct SpotifyTokenErrorResponse: Decodable { + let error: String? + let errorDescription: String? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } +} + +private actor SpotifyTokenCache { + private var accessToken: String? + private var expirationDate: Date? + + func validToken() -> String? { + guard + let accessToken, + let expirationDate, + Date() < expirationDate + else { + return nil + } + return accessToken + } + + func save(token: String, expiresIn: Int) { + accessToken = token + expirationDate = Date().addingTimeInterval(TimeInterval(max(expiresIn - 60, 0))) + } + + func clear() { + accessToken = nil + expirationDate = nil + } +} diff --git a/KillingPart/ViewModels/Add/AddTabViewModel.swift b/KillingPart/ViewModels/Add/AddTabViewModel.swift new file mode 100644 index 0000000..816c226 --- /dev/null +++ b/KillingPart/ViewModels/Add/AddTabViewModel.swift @@ -0,0 +1,117 @@ +import Foundation + +@MainActor +final class AddTabViewModel: ObservableObject { + @Published var query = "" + @Published private(set) var tracks: [SpotifySimpleTrack] = [] + @Published private(set) var isLoading = false + @Published var errorMessage: String? + + private let spotifyService: SpotifyServicing + private var searchTask: Task? + private var lastSearchedQuery = "" + + init(spotifyService: SpotifyServicing = SpotifyService()) { + self.spotifyService = spotifyService + } + + deinit { + searchTask?.cancel() + } + + var hasQuery: Bool { + !trimmedQuery.isEmpty + } + + var shouldShowEmptyState: Bool { + hasSearchedCurrentQuery && !isLoading && errorMessage == nil && tracks.isEmpty + } + + func handleQueryChanged() { + searchTask?.cancel() + isLoading = false + + guard hasQuery else { + lastSearchedQuery = "" + tracks = [] + errorMessage = nil + return + } + + if !hasSearchedCurrentQuery { + tracks = [] + errorMessage = nil + } + } + + func submitSearch() { + searchTask?.cancel() + + guard hasQuery else { + tracks = [] + errorMessage = nil + isLoading = false + return + } + + let currentQuery = trimmedQuery + lastSearchedQuery = currentQuery + searchTask = Task { [weak self] in + await self?.search(query: currentQuery) + } + } + + func retrySearch() { + submitSearch() + } + + func clearSearch() { + searchTask?.cancel() + query = "" + lastSearchedQuery = "" + tracks = [] + isLoading = false + errorMessage = nil + } + + private var trimmedQuery: String { + query.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var hasSearchedCurrentQuery: Bool { + hasQuery && trimmedQuery == lastSearchedQuery + } + + private func search(query: String) async { + guard !query.isEmpty else { + tracks = [] + errorMessage = nil + isLoading = false + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + tracks = try await spotifyService.searchTracks(query: query, limit: 10) + } catch { + if Task.isCancelled { return } + tracks = [] + errorMessage = resolveErrorMessage(from: error) + } + } + + private func resolveErrorMessage(from error: Error) -> String { + if let spotifyError = error as? SpotifyServiceError { + return spotifyError.errorDescription ?? "Spotify 검색에 실패했어요." + } + + if let localizedError = error as? LocalizedError { + return localizedError.errorDescription ?? "Spotify 검색에 실패했어요." + } + + return "Spotify 검색에 실패했어요." + } +} diff --git a/KillingPart/Views/Screens/Main/Add/AddTabView.swift b/KillingPart/Views/Screens/Main/Add/AddTabView.swift index 29baa80..94b3b9f 100644 --- a/KillingPart/Views/Screens/Main/Add/AddTabView.swift +++ b/KillingPart/Views/Screens/Main/Add/AddTabView.swift @@ -1,40 +1,199 @@ import SwiftUI struct AddTabView: View { + @StateObject private var viewModel = AddTabViewModel() + var body: some View { NavigationStack { ZStack { Color.black.ignoresSafeArea() - VStack(alignment: .leading, spacing: AppSpacing.l) { - Text("Add") - .font(AppFont.paperlogy7Bold(size: 28)) - .foregroundStyle(.white) + VStack(alignment: .leading, spacing: AppSpacing.m) { + + searchField + + searchContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, 20) + .padding(.horizontal, AppSpacing.l) + .padding(.bottom, AppSpacing.l) + } + .toolbar(.hidden, for: .navigationBar) + } + } - Text("추가 탭입니다. 생성 플로우를 여기에 연결하세요.") - .font(AppFont.paperlogy4Regular(size: 16)) + private var searchField: some View { + HStack(spacing: AppSpacing.s) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.white.opacity(0.7)) + + TextField("곡 또는 아티스트 검색", text: $viewModel.query) + .font(AppFont.paperlogy5Medium(size: 15)) + .foregroundStyle(.white) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.search) + .onSubmit { + viewModel.submitSearch() + } + .onChange(of: viewModel.query) { _ in + viewModel.handleQueryChanged() + } + + if viewModel.hasQuery { + Button { + viewModel.clearSearch() + } label: { + Image(systemName: "xmark.circle.fill") .foregroundStyle(.white.opacity(0.75)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, AppSpacing.m) + .padding(.vertical, 14) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(AppColors.primary600.opacity(0.45), lineWidth: 1) + } + } + + private var searchContent: some View { + Group { + if viewModel.isLoading { + loadingView + } else if let errorMessage = viewModel.errorMessage { + errorView(message: errorMessage) + } else if viewModel.shouldShowEmptyState { + emptyResultView + } else { + trackListView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var loadingView: some View { + VStack(spacing: AppSpacing.s) { + ProgressView() + .progressViewStyle(.circular) + .tint(AppColors.primary600) + + Text("Spotify 검색 중...") + .font(AppFont.paperlogy5Medium(size: 14)) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView(message: String) -> some View { + VStack(alignment: .leading, spacing: AppSpacing.s) { + Text(message) + .font(AppFont.paperlogy4Regular(size: 14)) + .foregroundStyle(.white.opacity(0.85)) - RoundedRectangle(cornerRadius: 16) - .fill(Color.white.opacity(0.08)) - .frame(height: 160) - .overlay { - Text("Create Content") - .font(AppFont.paperlogy6SemiBold(size: 16)) - .foregroundStyle(.white) - } - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(AppColors.primary600.opacity(0.4), lineWidth: 1) - } - - Spacer() + Button { + viewModel.retrySearch() + } label: { + Text("다시 시도") + .font(AppFont.paperlogy6SemiBold(size: 13)) + .foregroundStyle(.black) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(AppColors.primary600) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + .padding(AppSpacing.m) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + + private var emptyResultView: some View { + VStack(spacing: AppSpacing.xs) { + Image(systemName: "music.note.list") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(AppColors.primary600.opacity(0.9)) + + Text("검색 결과가 없어요.") + .font(AppFont.paperlogy5Medium(size: 15)) + .foregroundStyle(.white.opacity(0.86)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var trackListView: some View { + ScrollView { + LazyVStack(spacing: AppSpacing.s) { + ForEach(viewModel.tracks) { track in + trackRow(track) + } + } + .padding(.top, AppSpacing.xs) + .padding(.bottom, AppSpacing.l) + } + .scrollIndicators(.hidden) + } + + private func trackRow(_ track: SpotifySimpleTrack) -> some View { + HStack(spacing: AppSpacing.s) { + trackArtwork(url: track.albumImageURL) + + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(AppFont.paperlogy6SemiBold(size: 15)) + .foregroundStyle(.white) + .lineLimit(1) + + Text(track.artist) + .font(AppFont.paperlogy4Regular(size: 13)) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(AppSpacing.s) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + } + } + + private func trackArtwork(url: URL?) -> some View { + Group { + if let url { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + case .empty, .failure: + artworkPlaceholder + @unknown default: + artworkPlaceholder + } } - .padding(AppSpacing.l) + } else { + artworkPlaceholder } - .toolbarColorScheme(.dark, for: .navigationBar) - .toolbarBackground(.black, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) } + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private var artworkPlaceholder: some View { + Rectangle() + .fill(Color.white.opacity(0.12)) + .overlay { + Image(systemName: "music.note") + .foregroundStyle(.white.opacity(0.72)) + } } } From d1a037c11e82aaee4cc7a26ad08936aa2c539890 Mon Sep 17 00:00:00 2001 From: mark Date: Tue, 17 Feb 2026 13:59:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor(Add):=20AddTabView=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Screens/Main/Add/AddTabView.swift | 195 ++---------------- .../Add/Components/AddSearchContentView.swift | 84 ++++++++ .../Add/Components/AddSearchFieldView.swift | 47 +++++ .../Add/Components/AddTrackListView.swift | 83 ++++++++ 4 files changed, 230 insertions(+), 179 deletions(-) create mode 100644 KillingPart/Views/Screens/Main/Add/Components/AddSearchContentView.swift create mode 100644 KillingPart/Views/Screens/Main/Add/Components/AddSearchFieldView.swift create mode 100644 KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift diff --git a/KillingPart/Views/Screens/Main/Add/AddTabView.swift b/KillingPart/Views/Screens/Main/Add/AddTabView.swift index 94b3b9f..af78006 100644 --- a/KillingPart/Views/Screens/Main/Add/AddTabView.swift +++ b/KillingPart/Views/Screens/Main/Add/AddTabView.swift @@ -9,191 +9,28 @@ struct AddTabView: View { Color.black.ignoresSafeArea() VStack(alignment: .leading, spacing: AppSpacing.m) { - - searchField - - searchContent + AddSearchFieldView( + query: $viewModel.query, + hasQuery: viewModel.hasQuery, + onSubmit: viewModel.submitSearch, + onQueryChanged: viewModel.handleQueryChanged, + onClear: viewModel.clearSearch + ) + + AddSearchContentView( + isLoading: viewModel.isLoading, + errorMessage: viewModel.errorMessage, + shouldShowEmptyState: viewModel.shouldShowEmptyState, + tracks: viewModel.tracks, + onRetry: viewModel.retrySearch + ) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, 20) + .padding(.top, 20) .padding(.horizontal, AppSpacing.l) .padding(.bottom, AppSpacing.l) } .toolbar(.hidden, for: .navigationBar) } } - - private var searchField: some View { - HStack(spacing: AppSpacing.s) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.white.opacity(0.7)) - - TextField("곡 또는 아티스트 검색", text: $viewModel.query) - .font(AppFont.paperlogy5Medium(size: 15)) - .foregroundStyle(.white) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .submitLabel(.search) - .onSubmit { - viewModel.submitSearch() - } - .onChange(of: viewModel.query) { _ in - viewModel.handleQueryChanged() - } - - if viewModel.hasQuery { - Button { - viewModel.clearSearch() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.white.opacity(0.75)) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, AppSpacing.m) - .padding(.vertical, 14) - .background(Color.white.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(AppColors.primary600.opacity(0.45), lineWidth: 1) - } - } - - private var searchContent: some View { - Group { - if viewModel.isLoading { - loadingView - } else if let errorMessage = viewModel.errorMessage { - errorView(message: errorMessage) - } else if viewModel.shouldShowEmptyState { - emptyResultView - } else { - trackListView - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private var loadingView: some View { - VStack(spacing: AppSpacing.s) { - ProgressView() - .progressViewStyle(.circular) - .tint(AppColors.primary600) - - Text("Spotify 검색 중...") - .font(AppFont.paperlogy5Medium(size: 14)) - .foregroundStyle(.white.opacity(0.75)) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func errorView(message: String) -> some View { - VStack(alignment: .leading, spacing: AppSpacing.s) { - Text(message) - .font(AppFont.paperlogy4Regular(size: 14)) - .foregroundStyle(.white.opacity(0.85)) - - Button { - viewModel.retrySearch() - } label: { - Text("다시 시도") - .font(AppFont.paperlogy6SemiBold(size: 13)) - .foregroundStyle(.black) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(AppColors.primary600) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - } - .padding(AppSpacing.m) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white.opacity(0.06)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - - private var emptyResultView: some View { - VStack(spacing: AppSpacing.xs) { - Image(systemName: "music.note.list") - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(AppColors.primary600.opacity(0.9)) - - Text("검색 결과가 없어요.") - .font(AppFont.paperlogy5Medium(size: 15)) - .foregroundStyle(.white.opacity(0.86)) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var trackListView: some View { - ScrollView { - LazyVStack(spacing: AppSpacing.s) { - ForEach(viewModel.tracks) { track in - trackRow(track) - } - } - .padding(.top, AppSpacing.xs) - .padding(.bottom, AppSpacing.l) - } - .scrollIndicators(.hidden) - } - - private func trackRow(_ track: SpotifySimpleTrack) -> some View { - HStack(spacing: AppSpacing.s) { - trackArtwork(url: track.albumImageURL) - - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(AppFont.paperlogy6SemiBold(size: 15)) - .foregroundStyle(.white) - .lineLimit(1) - - Text(track.artist) - .font(AppFont.paperlogy4Regular(size: 13)) - .foregroundStyle(.white.opacity(0.72)) - .lineLimit(1) - } - - Spacer(minLength: 0) - } - .padding(AppSpacing.s) - .background(Color.white.opacity(0.06)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay { - RoundedRectangle(cornerRadius: 14) - .stroke(Color.white.opacity(0.08), lineWidth: 1) - } - } - - private func trackArtwork(url: URL?) -> some View { - Group { - if let url { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFill() - case .empty, .failure: - artworkPlaceholder - @unknown default: - artworkPlaceholder - } - } - } else { - artworkPlaceholder - } - } - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - - private var artworkPlaceholder: some View { - Rectangle() - .fill(Color.white.opacity(0.12)) - .overlay { - Image(systemName: "music.note") - .foregroundStyle(.white.opacity(0.72)) - } - } } diff --git a/KillingPart/Views/Screens/Main/Add/Components/AddSearchContentView.swift b/KillingPart/Views/Screens/Main/Add/Components/AddSearchContentView.swift new file mode 100644 index 0000000..7e9f5c9 --- /dev/null +++ b/KillingPart/Views/Screens/Main/Add/Components/AddSearchContentView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct AddSearchContentView: View { + let isLoading: Bool + let errorMessage: String? + let shouldShowEmptyState: Bool + let tracks: [SpotifySimpleTrack] + let onRetry: () -> Void + + var body: some View { + Group { + if isLoading { + AddSearchLoadingView() + } else if let errorMessage { + AddSearchErrorView(message: errorMessage, onRetry: onRetry) + } else if shouldShowEmptyState { + AddSearchEmptyResultView() + } else { + AddTrackListView(tracks: tracks) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +private struct AddSearchLoadingView: View { + var body: some View { + VStack(spacing: AppSpacing.s) { + ProgressView() + .progressViewStyle(.circular) + .tint(AppColors.primary600) + + Text("Spotify 검색 중...") + .font(AppFont.paperlogy5Medium(size: 14)) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct AddSearchErrorView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.s) { + Text(message) + .font(AppFont.paperlogy4Regular(size: 14)) + .foregroundStyle(.white.opacity(0.85)) + + Button { + onRetry() + } label: { + Text("다시 시도") + .font(AppFont.paperlogy6SemiBold(size: 13)) + .foregroundStyle(.black) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(AppColors.primary600) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + .padding(AppSpacing.m) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } +} + +private struct AddSearchEmptyResultView: View { + var body: some View { + VStack(spacing: AppSpacing.xs) { + Image(systemName: "music.note.list") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(AppColors.primary600.opacity(0.9)) + + Text("검색 결과가 없어요.") + .font(AppFont.paperlogy5Medium(size: 15)) + .foregroundStyle(.white.opacity(0.86)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/KillingPart/Views/Screens/Main/Add/Components/AddSearchFieldView.swift b/KillingPart/Views/Screens/Main/Add/Components/AddSearchFieldView.swift new file mode 100644 index 0000000..788ff50 --- /dev/null +++ b/KillingPart/Views/Screens/Main/Add/Components/AddSearchFieldView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct AddSearchFieldView: View { + @Binding var query: String + let hasQuery: Bool + let onSubmit: () -> Void + let onQueryChanged: () -> Void + let onClear: () -> Void + + var body: some View { + HStack(spacing: AppSpacing.s) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.white.opacity(0.7)) + + TextField("곡 또는 아티스트 검색", text: $query) + .font(AppFont.paperlogy5Medium(size: 15)) + .foregroundStyle(.white) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.search) + .onSubmit { + onSubmit() + } + .onChange(of: query) { _ in + onQueryChanged() + } + + if hasQuery { + Button { + onClear() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white.opacity(0.75)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, AppSpacing.m) + .padding(.vertical, 14) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(AppColors.primary600.opacity(0.45), lineWidth: 1) + } + } +} diff --git a/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift b/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift new file mode 100644 index 0000000..b059577 --- /dev/null +++ b/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct AddTrackListView: View { + let tracks: [SpotifySimpleTrack] + + var body: some View { + ScrollView { + LazyVStack(spacing: AppSpacing.s) { + ForEach(tracks) { track in + AddTrackRowView(track: track) + } + } + .padding(.top, AppSpacing.xs) + .padding(.bottom, AppSpacing.l) + } + .scrollIndicators(.hidden) + } +} + +private struct AddTrackRowView: View { + let track: SpotifySimpleTrack + + var body: some View { + HStack(spacing: AppSpacing.s) { + AddTrackArtworkView(url: track.albumImageURL) + + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(AppFont.paperlogy6SemiBold(size: 15)) + .foregroundStyle(.white) + .lineLimit(1) + + Text(track.artist) + .font(AppFont.paperlogy4Regular(size: 13)) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(AppSpacing.s) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + } + } +} + +private struct AddTrackArtworkView: View { + let url: URL? + + var body: some View { + Group { + if let url { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + case .empty, .failure: + placeholder + @unknown default: + placeholder + } + } + } else { + placeholder + } + } + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private var placeholder: some View { + Rectangle() + .fill(Color.white.opacity(0.12)) + .overlay { + Image(systemName: "music.note") + .foregroundStyle(.white.opacity(0.72)) + } + } +} From 5d02b8641cfe9a4857968e32e195b921c5dea9f2 Mon Sep 17 00:00:00 2001 From: mark Date: Tue, 17 Feb 2026 14:00:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(1.0.6):=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- KillingPart.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/KillingPart.xcodeproj/project.pbxproj b/KillingPart.xcodeproj/project.pbxproj index bb1f615..505fac0 100644 --- a/KillingPart.xcodeproj/project.pbxproj +++ b/KillingPart.xcodeproj/project.pbxproj @@ -434,7 +434,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = GQ89YG5G9R; ENABLE_APP_SANDBOX = YES; @@ -459,7 +459,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.5; + MARKETING_VERSION = 1.0.6; PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -479,7 +479,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = GQ89YG5G9R; ENABLE_APP_SANDBOX = YES; @@ -504,7 +504,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0.5; + MARKETING_VERSION = 1.0.6; PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES;