diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index faca817..7813501 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -25,6 +25,7 @@ protocol APIServiceType { userId: String, deviceSecret: String, page: Int, size: Int ) -> AnyPublisher func getKeywords() -> AnyPublisher + func getURLs() -> AnyPublisher func getHomeFeed( userId: String, deviceSecret: String ) -> AnyPublisher @@ -39,6 +40,7 @@ class APIService: APIServiceType { private let subscriptionProvider: MoyaProvider private let alarmProvider: MoyaProvider private let keywordProvider: MoyaProvider + private let urlProvider: MoyaProvider private let feedProvider: MoyaProvider init(userSession: UserSession = UserSession()) { @@ -52,6 +54,7 @@ class APIService: APIServiceType { subscriptionProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) alarmProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) keywordProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) + urlProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) feedProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) #else userProvider = NetworkKit.provider(userSession: userSession) @@ -59,6 +62,7 @@ class APIService: APIServiceType { subscriptionProvider = NetworkKit.provider(userSession: userSession) alarmProvider = NetworkKit.provider(userSession: userSession) keywordProvider = NetworkKit.provider(userSession: userSession) + urlProvider = NetworkKit.provider(userSession: userSession) feedProvider = NetworkKit.provider(userSession: userSession) #endif } @@ -331,6 +335,23 @@ class APIService: APIServiceType { .eraseToAnyPublisher() } + // MARK: - URL API + + func getURLs() -> AnyPublisher { + urlProvider.requestPublisher(.getURLs) + .mapError { moyaError -> NetworkError in + .requestFailed(moyaError) + } + .flatMap { [weak self] response -> AnyPublisher in + guard let self else { + return Fail(error: NetworkError.unknown) + .eraseToAnyPublisher() + } + return handleResponse(response, decodeTo: URLsResponse.self, debugLabel: "URL 목록 응답") + } + .eraseToAnyPublisher() + } + // MARK: - Feed API func getHomeFeed( diff --git a/today-s-sound/Core/Network/Targets/URLAPI.swift b/today-s-sound/Core/Network/Targets/URLAPI.swift new file mode 100644 index 0000000..cc75c56 --- /dev/null +++ b/today-s-sound/Core/Network/Targets/URLAPI.swift @@ -0,0 +1,43 @@ +// +// URLAPI.swift +// today-s-sound +// +// Created by Assistant +// + +import Foundation +import Moya + +enum URLAPI { + case getURLs +} + +extension URLAPI: APITargetType { + var path: String { + switch self { + case .getURLs: + "/api/urls" + } + } + + var method: Moya.Method { + switch self { + case .getURLs: + .get + } + } + + var task: Task { + switch self { + case .getURLs: + .requestPlain + } + } + + var headers: [String: String]? { + [ + "Content-Type": "application/json", + "Accept": "application/json" + ] + } +} diff --git a/today-s-sound/Data/Models/Subscription.swift b/today-s-sound/Data/Models/Subscription.swift index f13a968..b143620 100644 --- a/today-s-sound/Data/Models/Subscription.swift +++ b/today-s-sound/Data/Models/Subscription.swift @@ -4,8 +4,8 @@ import Foundation /// 구독 생성 요청 struct CreateSubscriptionRequest: Codable { - let url: String - let keywords: [String] + let urlId: Int64 + let keywordIds: [Int64] let alias: String? let isUrgent: Bool } diff --git a/today-s-sound/Data/Models/URL.swift b/today-s-sound/Data/Models/URL.swift new file mode 100644 index 0000000..9baad74 --- /dev/null +++ b/today-s-sound/Data/Models/URL.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - URL Response Models + +/// URL 목록 응답 (배열 직접 반환 또는 APIResponse 형태) +/// Swagger 문서에 따르면 배열을 직접 반환하지만, 프로젝트 일관성을 위해 APIResponse로 처리 +typealias URLsResponse = APIResponse<[URLItem]> + +extension URLsResponse { + // 편의 속성: result를 urls로 접근 + var urls: [URLItem] { + result + } +} + +/// 개별 URL 아이템 +struct URLItem: Codable, Identifiable { + let id: Int64 + let link: String + let title: String +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index 9b498f4..daf3a5f 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -35,14 +35,82 @@ struct AddSubscriptionView: View { // 스크롤 되는 영역 (입력 필드, 키워드, 토글 등) ScrollView { VStack(spacing: 24) { - // 1) 웹사이트 URL (필수) - InputFieldSection( - title: "웹사이트 URL", - description: "모니터링할 웹페이지의 정확한 URL을 입력하세요.", - isRequired: true, - text: $viewModel.urlText, - theme: appTheme.theme - ) + // 1) 웹사이트 URL 선택 (필수) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Text("웹사이트 URL") + .font(.KoddiBold20) + .foregroundColor(Color.text(appTheme.theme)) + .accessibilityLabel("웹사이트 URL 필수 선택") + + Text("*") + .font(.KoddiBold20) + .foregroundColor(.red) + .accessibilityHidden(true) + } + + Button(action: { + viewModel.showURLSelector = true + }) { + HStack { + Text(viewModel.selectedURL?.title ?? "URL 선택...") + .font(.KoddiRegular16) + .foregroundColor( + viewModel.selectedURL == nil + ? Color.secondaryText(appTheme.theme) + : Color.text(appTheme.theme) + ) + Spacer() + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondaryBackground(appTheme.theme)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.border(appTheme.theme), lineWidth: 1) + ) + } + .accessibilityLabel(viewModel.selectedURL == nil ? "URL 선택 버튼" : "URL 수정 버튼") + .accessibilityHint("탭하여 URL을 선택합니다") + .accessibilityValue(viewModel.selectedURL?.title ?? "") + + Text("모니터링할 웹페이지를 선택하세요.") + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(appTheme.theme)) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel("모니터링할 웹페이지를 선택하세요") + } + + // 선택된 URL 표시 + if let selectedURL = viewModel.selectedURL { + HStack { + Text(selectedURL.link) + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(appTheme.theme)) + .lineLimit(1) + Spacer() + Button(action: { + viewModel.clearURL() + }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(Color.secondaryText(appTheme.theme)) + } + .accessibilityLabel("선택된 URL 삭제") + .accessibilityHint("탭하여 선택된 URL을 제거합니다") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondaryBackground(appTheme.theme).opacity(0.5)) + ) + } + } // 2) 웹페이지 별명 (선택) InputFieldSection( @@ -64,7 +132,7 @@ struct AddSubscriptionView: View { viewModel.showKeywordSelector = true }) { HStack { - Text(viewModel.selectedKeywords.isEmpty ? "키워드 추가..." : "키워드 수정...") + Text(viewModel.selectedKeywordNames.isEmpty ? "키워드 추가..." : "키워드 수정...") .font(.KoddiRegular16) .foregroundColor(Color.secondaryText(appTheme.theme)) Spacer() @@ -80,7 +148,7 @@ struct AddSubscriptionView: View { .stroke(Color.border(appTheme.theme), lineWidth: 1) ) } - .accessibilityLabel(viewModel.selectedKeywords.isEmpty ? "키워드 추가 버튼" : "키워드 수정 버튼") + .accessibilityLabel(viewModel.selectedKeywordNames.isEmpty ? "키워드 추가 버튼" : "키워드 수정 버튼") .accessibilityHint("탭하여 키워드를 선택합니다") Text("관심 키워드가 포함된 글을 알림으로 받아보세요.") @@ -91,14 +159,17 @@ struct AddSubscriptionView: View { } // 선택된 키워드 배지들 - if !viewModel.selectedKeywords.isEmpty { + if !viewModel.selectedKeywordNames.isEmpty { FlowLayout(spacing: 8) { - ForEach(viewModel.selectedKeywords, id: \.self) { keyword in + ForEach(viewModel.selectedKeywordNames, id: \.self) { keywordName in KeywordBadgeWithDelete( - text: keyword, + text: keywordName, theme: appTheme.theme ) { - viewModel.removeKeyword(keyword) + // 키워드 이름으로 ID 찾기 + if let keyword = viewModel.availableKeywords.first(where: { $0.name == keywordName }) { + viewModel.removeKeyword(keyword.id) + } } } } @@ -146,7 +217,7 @@ struct AddSubscriptionView: View { ? "구독을 등록하는 중입니다" : viewModel.isSubmitEnabled ? "이 웹사이트 등록 승인을 요청합니다." - : "웹사이트 URL을 입력해야 활성화됩니다." + : "웹사이트 URL을 선택해야 활성화됩니다." ) .padding(.horizontal, 16) .padding(.vertical, 16) @@ -165,6 +236,16 @@ struct AddSubscriptionView: View { } } .ignoresSafeArea(.keyboard, edges: .bottom) + // URL 선택 시트 + .sheet(isPresented: $viewModel.showURLSelector) { + URLSelectorSheet(viewModel: viewModel, theme: appTheme.theme) + .onAppear { + // URL 설정 시트가 열릴 때 URL 목록 로드 + if viewModel.availableURLs.isEmpty { + viewModel.loadURLs() + } + } + } // 키워드 설정 시트 .sheet(isPresented: $viewModel.showKeywordSelector) { KeywordSelectorSheet(viewModel: viewModel, theme: appTheme.theme) @@ -194,6 +275,153 @@ struct AddSubscriptionView: View { } } +// MARK: - URL 선택 시트 + +struct URLSelectorSheet: View { + @ObservedObject var viewModel: AddSubscriptionViewModel + let theme: AppTheme + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + Color.background(theme) + .ignoresSafeArea() + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + VStack(spacing: 0) { + SheetHandleBar(theme: theme) + .padding(.top, 20) + .accessibilityElement() + .accessibilityLabel("URL 설정 창 닫기") + .accessibilityHint("이 영역을 두 번 탭하거나 아래로 스와이프하면 창이 닫힙니다.") + .onTapGesture { + dismiss() + } + + // URL 설정 화면 제목 + ScreenSubTitle(text: "URL 선택", theme: theme) + + VStack(spacing: 0) { + // 스크롤 가능한 URL 목록 + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if viewModel.isLoadingURLs { + VStack(spacing: 16) { + ProgressView("불러오는 중...") + .progressViewStyle(CircularProgressViewStyle()) + .accessibilityLabel("URL 목록을 불러오는 중입니다") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if let errorMessage = viewModel.urlErrorMessage { + VStack(spacing: 16) { + Text(errorMessage) + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(theme)) + .accessibilityLabel("오류: \(errorMessage)") + + Button("다시 시도") { + viewModel.loadURLs() + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .font(.KoddiBold20) + .foregroundColor(.white) + .background(Color.primaryGreen) + .cornerRadius(8) + .accessibilityLabel("다시 시도 버튼") + .accessibilityHint("탭하여 URL 목록을 다시 불러옵니다") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if viewModel.availableURLs.isEmpty { + VStack(spacing: 16) { + Text("등록된 URL이 없습니다.") + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(theme)) + .accessibilityLabel("등록된 URL이 없습니다") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + VStack(spacing: 0) { + ForEach(Array(viewModel.availableURLs.enumerated()), id: \.element.id) { index, url in + URLRow( + url: url, + isSelected: viewModel.selectedURL?.id == url.id, + theme: theme + ) { + viewModel.selectURL(url) + dismiss() + } + + if index < viewModel.availableURLs.count - 1 { + Divider() + .background(Color.border(theme)) + .padding(.horizontal, 20) + } + } + } + } + } + .padding(.top, 8) + .padding(.bottom, 8) + } + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +// MARK: - URL 행 컴포넌트 + +struct URLRow: View { + let url: URLItem + let isSelected: Bool + let theme: AppTheme + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(url.title) + .font(.KoddiBold20) + .foregroundColor(Color.text(theme)) + + Text(url.link) + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(theme)) + .lineLimit(1) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(Color.primaryGreen) + } else { + Image(systemName: "circle") + .font(.system(size: 24)) + .foregroundColor(Color.border(theme)) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("URL \(url.title)") + .accessibilityValue(isSelected ? "선택됨" : "선택 안 됨") + .accessibilityHint("탭하여 이 URL을 선택합니다") + } +} + // MARK: - 키워드 선택 시트 struct KeywordSelectorSheet: View { @@ -266,13 +494,13 @@ struct KeywordSelectorSheet: View { .padding(.vertical, 40) } else { VStack(spacing: 0) { - ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.offset) { index, keyword in + ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.element.id) { index, keyword in KeywordCheckboxRow( - keyword: keyword, - isSelected: viewModel.selectedKeywords.contains(keyword), + keyword: keyword.name, + isSelected: viewModel.selectedKeywordIds.contains(keyword.id), theme: theme ) { - viewModel.toggleKeyword(keyword) + viewModel.toggleKeyword(keyword.id) } if index < viewModel.availableKeywords.count - 1 { diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index 50d5697..06fde88 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -4,21 +4,29 @@ import Foundation final class AddSubscriptionViewModel: ObservableObject { // 입력값 @Published var urlText: String = "" + @Published var selectedURL: URLItem? = nil @Published var nameText: String = "" @Published var isUrgent: Bool = false + // URL 선택 관련 + @Published var showURLSelector: Bool = false + // 키워드 선택 관련 - @Published var selectedKeywords: [String] = [] + @Published var selectedKeywordIds: [Int64] = [] @Published var showKeywordSelector: Bool = false // API 상태 @Published var isLoading: Bool = false @Published var errorMessage: String? + @Published var isLoadingURLs: Bool = false + @Published var urlErrorMessage: String? @Published var isLoadingKeywords: Bool = false @Published var keywordErrorMessage: String? - // 키워드 목록 (API에서 가져옴) - @Published var availableKeywords: [String] = [] + // URL 목록 (API에서 가져옴) + @Published var availableURLs: [URLItem] = [] + // 키워드 목록 (API에서 가져옴) - ID와 name을 함께 저장 + @Published var availableKeywords: [KeywordItem] = [] private let apiService: APIService private var cancellables = Set() @@ -27,16 +35,17 @@ final class AddSubscriptionViewModel: ObservableObject { self.apiService = apiService } - /// URL이 비어있지 않을 때만 전송 가능 + /// URL이 선택되었을 때만 전송 가능 var isSubmitEnabled: Bool { - !urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + selectedURL != nil } /// 현재 입력 상태를 기반으로 Request payload 생성 - func makeRequestPayload() -> CreateSubscriptionRequest { - CreateSubscriptionRequest( - url: urlText.trimmingCharacters(in: .whitespacesAndNewlines), - keywords: selectedKeywords, + func makeRequestPayload() -> CreateSubscriptionRequest? { + guard let selectedURL else { return nil } + return CreateSubscriptionRequest( + urlId: selectedURL.id, + keywordIds: selectedKeywordIds, alias: nameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nameText.trimmingCharacters(in: .whitespacesAndNewlines), @@ -59,7 +68,12 @@ final class AddSubscriptionViewModel: ObservableObject { isLoading = true errorMessage = nil - let request = makeRequestPayload() + guard let request = makeRequestPayload() else { + errorMessage = "URL을 선택해주세요" + isLoading = false + completion(false) + return + } print("📤 구독 생성 요청:", request) @@ -109,27 +123,98 @@ final class AddSubscriptionViewModel: ObservableObject { .store(in: &cancellables) } + // MARK: - URL 선택 로직 + + func selectURL(_ url: URLItem) { + selectedURL = url + urlText = url.link + } + + func clearURL() { + selectedURL = nil + urlText = "" + } + // MARK: - 키워드 선택 로직 - func addKeyword(_ keyword: String) { - let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty, !selectedKeywords.contains(trimmed) { - selectedKeywords.append(trimmed) + func addKeyword(_ keywordId: Int64) { + if !selectedKeywordIds.contains(keywordId) { + selectedKeywordIds.append(keywordId) } } - func removeKeyword(_ keyword: String) { - selectedKeywords.removeAll { $0 == keyword } + func removeKeyword(_ keywordId: Int64) { + selectedKeywordIds.removeAll { $0 == keywordId } } - func toggleKeyword(_ keyword: String) { - if selectedKeywords.contains(keyword) { - removeKeyword(keyword) + func toggleKeyword(_ keywordId: Int64) { + if selectedKeywordIds.contains(keywordId) { + removeKeyword(keywordId) } else { - addKeyword(keyword) + addKeyword(keywordId) } } + /// 선택된 키워드의 이름 목록 (UI 표시용) + var selectedKeywordNames: [String] { + availableKeywords + .filter { selectedKeywordIds.contains($0.id) } + .map(\.name) + } + + // MARK: - URL 목록 로드 + + /// URL 목록을 API에서 가져오기 + func loadURLs() { + guard !isLoadingURLs else { return } + + isLoadingURLs = true + urlErrorMessage = nil + + print("📡 URL 목록 요청") + + apiService.getURLs() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + isLoadingURLs = false + + switch completion { + case .finished: + break + + case let .failure(error): + switch error { + case let .serverError(statusCode): + urlErrorMessage = "서버 오류 (상태: \(statusCode))" + + case .decodingFailed: + urlErrorMessage = "응답 처리 실패" + + case let .requestFailed(requestError): + urlErrorMessage = "요청 실패: \(requestError.localizedDescription)" + + case .invalidURL: + urlErrorMessage = "잘못된 URL" + + case .unknown: + urlErrorMessage = "알 수 없는 오류" + } + + print("❌ URL 목록 조회 실패: \(urlErrorMessage ?? "")") + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + availableURLs = response.urls + + print("✅ URL 목록 조회 성공: \(response.urls.count)개") + } + ) + .store(in: &cancellables) + } + // MARK: - 키워드 목록 로드 /// 키워드 목록을 API에서 가져오기 @@ -175,11 +260,10 @@ final class AddSubscriptionViewModel: ObservableObject { }, receiveValue: { [weak self] response in guard let self else { return } - // KeywordItem 배열에서 name만 추출 - let keywords = response.keywords.map(\.name) - availableKeywords = keywords + // KeywordItem 전체를 저장 (ID와 name 모두 필요) + availableKeywords = response.keywords - print("✅ 키워드 목록 조회 성공: \(keywords.count)개") + print("✅ 키워드 목록 조회 성공: \(response.keywords.count)개") } ) .store(in: &cancellables)