diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index bb5020b..ce79c19 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -9,18 +9,18 @@ import SwiftUI @main struct TodaySSoundApp: App { - @StateObject private var session = SessionStore() - - var body: some Scene { - WindowGroup { - Group { - if session.isRegistered { - MainView() - } else { - OnBoardingView() - } - } - .environmentObject(session) + @StateObject private var session = SessionStore() + + var body: some Scene { + WindowGroup { + Group { + if session.isRegistered { + MainView() + } else { + OnBoardingView() } + } + .environmentObject(session) } + } } diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift new file mode 100644 index 0000000..05827b4 --- /dev/null +++ b/today-s-sound/Core/AppState/SessionStore.swift @@ -0,0 +1,128 @@ +// +// SessionStore.swift +// today-s-sound +// +// Refactored by Assistant - Moya 기반으로 리팩토링 +// + +import Combine +import Foundation +import SwiftUI + +@MainActor +final class SessionStore: ObservableObject { + @Published private(set) var userId: String? + @Published private(set) var isRegistered: Bool = false + @Published var lastError: String? + + private let apiService: APIService + private var cancellables = Set() + + init(apiService: APIService = APIService()) { + self.apiService = apiService + + // 앱 시작 시, 키체인에 저장되어 있으면 로드 + #if DEBUG + print("━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("🔐 키체인 확인 (SessionStore.init)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━") + if let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) { + print("✅ deviceSecret: \(deviceSecret)") + } else { + print("❌ deviceSecret: (없음)") + } + if let savedId = Keychain.getString(for: KeychainKey.userId) { + print("✅ userId: \(savedId)") + } else { + print("❌ userId: (없음)") + } + print("━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + #else + print("⚠️ RELEASE 모드로 실행 중 - DEBUG 로그 비활성화") + #endif + + if let savedId = Keychain.getString(for: KeychainKey.userId) { + userId = savedId + isRegistered = true + } else { + isRegistered = false + } + } + + /// 처음 실행 시 한 번 호출: deviceSecret 생성/보관 → 서버 등록 + func registerIfNeeded() async { + guard !isRegistered else { return } + + // 1) deviceSecret을 키체인에서 찾고, 없으면 생성하여 저장 + let secret: String = { + if let savedSecret = Keychain.getString(for: KeychainKey.deviceSecret) { + return savedSecret + } + let generated = DeviceSecretGenerator.generate() + Keychain.setString(generated, for: KeychainKey.deviceSecret) + return generated + }() + + // 2) Combine을 사용한 비동기 API 호출 + await withCheckedContinuation { continuation in + apiService.registerAnonymous(deviceSecret: secret) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + + switch completion { + case .finished: + break + + case let .failure(error): + // 에러 처리 + switch error { + case let .serverError(statusCode): + lastError = "서버 오류 (상태: \(statusCode))" + + case let .decodingFailed(decodeError): + lastError = "응답 처리 실패: \(decodeError.localizedDescription)" + + case let .requestFailed(requestError): + lastError = "요청 실패: \(requestError.localizedDescription)" + + case .invalidURL: + lastError = "잘못된 URL" + + case .unknown: + lastError = "알 수 없는 오류" + } + + print("❌ 익명 사용자 등록 실패: \(lastError ?? "")") + } + + continuation.resume() + }, + receiveValue: { [weak self] response in + guard let self else { return } + + // 3) userId 저장 + let userId = response.result.userId + Keychain.setString(userId, for: KeychainKey.userId) + + self.userId = userId + isRegistered = true + lastError = nil + + print("✅ 익명 사용자 등록 성공: \(userId)") + } + ) + .store(in: &self.cancellables) + } + } + + /// 로그아웃 (키체인 초기화) + func logout() { + Keychain.delete(for: KeychainKey.userId) + Keychain.delete(for: KeychainKey.deviceSecret) + + userId = nil + isRegistered = false + lastError = nil + } +} diff --git a/today-s-sound/Core/Network/Config.swift b/today-s-sound/Core/Network/Config.swift index 0ef89da..03cfb00 100644 --- a/today-s-sound/Core/Network/Config.swift +++ b/today-s-sound/Core/Network/Config.swift @@ -3,9 +3,9 @@ import Foundation enum Config { static var baseURL: String { #if DEBUG - return "https://dev-your-api-url.com" + return "http://localhost:8080" // 개발 서버 #else - return "https://your-api-url.com" + return "https://api.todays-sound.com" // 운영 서버 #endif } } diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift index 40ae6c7..e4d594f 100644 --- a/today-s-sound/Core/Network/Service/APIService.swift +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -5,30 +5,93 @@ import Moya protocol APIServiceType { func request(_ target: some TargetType) -> AnyPublisher - func createAnonymous(deviceSecret: String) -> AnyPublisher + func registerAnonymous(deviceSecret: String) -> AnyPublisher + func getSubscriptions( + userId: String, deviceSecret: String, page: Int, size: Int + ) -> AnyPublisher + func getAlarms( + userId: String, deviceSecret: String, page: Int, size: Int + ) -> AnyPublisher } class APIService: APIServiceType { - private let anonymousProvider: MoyaProvider + private let userProvider: MoyaProvider + private let authProvider: MoyaProvider + private let subscriptionProvider: MoyaProvider + private let alarmProvider: MoyaProvider init(userSession: UserSession = UserSession()) { - anonymousProvider = NetworkKit.provider(userSession: userSession) + #if DEBUG + // 개발 모드에서는 로깅 플러그인 추가 + let logger = NetworkLoggerPlugin(configuration: .init( + logOptions: .verbose + )) + userProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) + authProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) + subscriptionProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) + alarmProvider = NetworkKit.provider(userSession: userSession, plugins: [logger]) + #else + userProvider = NetworkKit.provider(userSession: userSession) + authProvider = NetworkKit.provider(userSession: userSession) + subscriptionProvider = NetworkKit.provider(userSession: userSession) + alarmProvider = NetworkKit.provider(userSession: userSession) + #endif } + // MARK: - Generic Request + func request(_ target: some TargetType) -> AnyPublisher { - Fail(error: .requestFailed(NSError(domain: "NotImplemented", code: -1))).eraseToAnyPublisher() + Fail(error: .requestFailed(NSError(domain: "NotImplemented", code: -1))) + .eraseToAnyPublisher() } - func createAnonymous(deviceSecret: String) -> AnyPublisher { - anonymousProvider.requestPublisher(.createAnonymous(deviceSecret: deviceSecret)) + // MARK: - User API + + func registerAnonymous(deviceSecret: String) -> AnyPublisher { + userProvider.requestPublisher(.registerAnonymous(deviceSecret: deviceSecret)) .tryMap { response -> Data in + // 상태 코드 체크 guard (200 ... 299).contains(response.statusCode) else { throw NetworkError.serverError(statusCode: response.statusCode) } + + // 🐛 디버깅: 서버 응답 출력 + #if DEBUG + if let jsonString = String(data: response.data, encoding: .utf8) { + print("📥 서버 응답 (Raw JSON):") + print(jsonString) + } + #endif + return response.data } - .decode(type: AnonymousUserResponse.self, decoder: JSONDecoder()) + // JSON 데이터를 RegisterAnonymousResponse 구조체로 자동 변환 + .decode(type: RegisterAnonymousResponse.self, decoder: JSONDecoder()) .mapError { error -> NetworkError in + // 🐛 디버깅: 디코딩 에러 상세 출력 + #if DEBUG + if let decodingError = error as? DecodingError { + print("❌ 디코딩 에러 발생:") + switch decodingError { + case let .keyNotFound(key, context): + print(" - 키를 찾을 수 없음: \(key.stringValue)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + print(" - 설명: \(context.debugDescription)") + case let .typeMismatch(type, context): + print(" - 타입 불일치: \(type)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + case let .valueNotFound(type, context): + print(" - 값을 찾을 수 없음: \(type)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + case let .dataCorrupted(context): + print(" - 데이터 손상") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + @unknown default: + print(" - 알 수 없는 디코딩 에러") + } + } + #endif + if let networkError = error as? NetworkError { return networkError } else if error is DecodingError { @@ -39,4 +102,122 @@ class APIService: APIServiceType { } .eraseToAnyPublisher() } + + // MARK: - Subscription API + + func getSubscriptions( + userId: String, deviceSecret: String, page: Int = 0, size: Int = 10 + ) -> AnyPublisher { + subscriptionProvider.requestPublisher(.getSubscriptions( + userId: userId, + deviceSecret: deviceSecret, + page: page, + size: size + )) + .tryMap { response -> Data in + // 상태 코드 체크 + guard (200 ... 299).contains(response.statusCode) else { + throw NetworkError.serverError(statusCode: response.statusCode) + } + + // 🐛 디버깅: 서버 응답 출력 + #if DEBUG + if let jsonString = String(data: response.data, encoding: .utf8) { + print("📥 구독 목록 응답 (Raw JSON):") + print(jsonString) + } + #endif + + return response.data + } + .decode(type: SubscriptionListResponse.self, decoder: JSONDecoder()) + .mapError { error -> NetworkError in + // 🐛 디버깅: 디코딩 에러 상세 출력 + #if DEBUG + if let decodingError = error as? DecodingError { + print("❌ 디코딩 에러 발생:") + switch decodingError { + case let .keyNotFound(key, context): + print(" - 키를 찾을 수 없음: \(key.stringValue)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + case let .typeMismatch(type, context): + print(" - 타입 불일치: \(type)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + @unknown default: + print(" - 알 수 없는 디코딩 에러") + } + } + #endif + + if let networkError = error as? NetworkError { + return networkError + } else if error is DecodingError { + return .decodingFailed(error) + } else { + return .requestFailed(error) + } + } + .eraseToAnyPublisher() + } + + // MARK: - Alarm API + + func getAlarms( + userId: String, deviceSecret: String, page: Int = 0, size: Int = 10 + ) -> AnyPublisher { + alarmProvider.requestPublisher(.getAlarms( + userId: userId, + deviceSecret: deviceSecret, + page: page, + size: size + )) + .tryMap { response -> Data in + // 상태 코드 체크 + guard (200 ... 299).contains(response.statusCode) else { + throw NetworkError.serverError(statusCode: response.statusCode) + } + + // 🐛 디버깅: 서버 응답 출력 + #if DEBUG + if let jsonString = String(data: response.data, encoding: .utf8) { + print("📥 알림 목록 응답 (Raw JSON):") + print(jsonString) + } + #endif + + return response.data + } + .decode(type: AlarmListResponse.self, decoder: JSONDecoder()) + .mapError { error -> NetworkError in + // 🐛 디버깅: 디코딩 에러 상세 출력 + #if DEBUG + if let decodingError = error as? DecodingError { + print("❌ 디코딩 에러 발생:") + switch decodingError { + case let .keyNotFound(key, context): + print(" - 키를 찾을 수 없음: \(key.stringValue)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + case let .typeMismatch(type, context): + print(" - 타입 불일치: \(type)") + print(" - 경로: \(context.codingPath.map(\.stringValue).joined(separator: " -> "))") + case let .valueNotFound(type, context): + print(" - 값을 찾을 수 없음: \(type)") + case let .dataCorrupted(context): + print(" - 데이터 손상") + @unknown default: + print(" - 알 수 없는 디코딩 에러") + } + } + #endif + + if let networkError = error as? NetworkError { + return networkError + } else if error is DecodingError { + return .decodingFailed(error) + } else { + return .requestFailed(error) + } + } + .eraseToAnyPublisher() + } } diff --git a/today-s-sound/Core/Network/Targets/AlarmAPI.swift b/today-s-sound/Core/Network/Targets/AlarmAPI.swift new file mode 100644 index 0000000..7e2b33e --- /dev/null +++ b/today-s-sound/Core/Network/Targets/AlarmAPI.swift @@ -0,0 +1,54 @@ +// +// AlarmAPI.swift +// today-s-sound +// +// Created by Assistant +// + +import Foundation +import Moya + +enum AlarmAPI { + case getAlarms(userId: String, deviceSecret: String, page: Int, size: Int) +} + +extension AlarmAPI: APITargetType { + var path: String { + switch self { + case .getAlarms: + "/api/alarms" + } + } + + var method: Moya.Method { + switch self { + case .getAlarms: + .get + } + } + + var task: Task { + switch self { + case let .getAlarms(_, _, page, size): + .requestParameters( + parameters: [ + "page": page, + "size": size + ], + encoding: URLEncoding.queryString + ) + } + } + + var headers: [String: String]? { + switch self { + case let .getAlarms(userId, deviceSecret, _, _): + [ + "Content-Type": "application/json", + "Accept": "application/json", + "X-User-ID": userId, + "X-Device-Secret": deviceSecret + ] + } + } +} diff --git a/today-s-sound/Core/Network/Targets/AnonymousAPI.swift b/today-s-sound/Core/Network/Targets/AnonymousAPI.swift deleted file mode 100644 index 89ffc07..0000000 --- a/today-s-sound/Core/Network/Targets/AnonymousAPI.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import Moya - -enum AnonymousAPI { - case createAnonymous(deviceSecret: String) -} - -extension AnonymousAPI: APITargetType { - var path: String { - switch self { - case .createAnonymous: - "/api/users/anonymous" - } - } - - var method: Moya.Method { .post } - - var task: Task { - switch self { - case let .createAnonymous(deviceSecret): - .requestParameters(parameters: ["deviceSecret": deviceSecret], encoding: JSONEncoding.default) - } - } - - var headers: [String: String]? { ["Content-Type": "application/json"] } -} diff --git a/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift b/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift new file mode 100644 index 0000000..a24108f --- /dev/null +++ b/today-s-sound/Core/Network/Targets/SubscriptionAPI.swift @@ -0,0 +1,54 @@ +// +// SubscriptionAPI.swift +// today-s-sound +// +// Created by Assistant +// + +import Foundation +import Moya + +enum SubscriptionAPI { + case getSubscriptions(userId: String, deviceSecret: String, page: Int, size: Int) +} + +extension SubscriptionAPI: APITargetType { + var path: String { + switch self { + case .getSubscriptions: + "/api/subscriptions" + } + } + + var method: Moya.Method { + switch self { + case .getSubscriptions: + .get + } + } + + var task: Task { + switch self { + case let .getSubscriptions(_, _, page, size): + .requestParameters( + parameters: [ + "page": page, + "size": size + ], + encoding: URLEncoding.queryString + ) + } + } + + var headers: [String: String]? { + switch self { + case let .getSubscriptions(userId, deviceSecret, _, _): + [ + "Content-Type": "application/json", + "Accept": "application/json", + "X-User-ID": userId, + "X-Device-Secret": deviceSecret + ] + } + } +} diff --git a/today-s-sound/Core/Network/Targets/UserAPI.swift b/today-s-sound/Core/Network/Targets/UserAPI.swift new file mode 100644 index 0000000..f162c05 --- /dev/null +++ b/today-s-sound/Core/Network/Targets/UserAPI.swift @@ -0,0 +1,49 @@ +// +// UserAPI.swift +// today-s-sound +// +// Refactored by Assistant +// + +import Foundation +import Moya + +enum UserAPI { + case registerAnonymous(deviceSecret: String) + // 향후 추가 가능: + // case getUserProfile(userId: String) + // case updateProfile(userId: String, name: String) +} + +extension UserAPI: APITargetType { + var path: String { + switch self { + case .registerAnonymous: + "/api/users/anonymous" + } + } + + var method: Moya.Method { + switch self { + case .registerAnonymous: + .post + } + } + + var task: Task { + switch self { + case let .registerAnonymous(deviceSecret): + .requestParameters( + parameters: ["deviceSecret": deviceSecret], + encoding: JSONEncoding.default + ) + } + } + + var headers: [String: String]? { + [ + "Content-Type": "application/json", + "Accept": "application/json" + ] + } +} diff --git a/today-s-sound/Core/Security/Keychain.swift b/today-s-sound/Core/Security/Keychain.swift new file mode 100644 index 0000000..eb4786a --- /dev/null +++ b/today-s-sound/Core/Security/Keychain.swift @@ -0,0 +1,64 @@ +// +// Keychain.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import Security + +enum KeychainKey { + static let deviceSecret = "device_secret" + static let userId = "user_id" + static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) +} + +enum Keychain { + @discardableResult + static func set(_ value: Data, for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: value, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 앱 재부팅 후에도 접근 + ] + SecItemDelete(query as CFDictionary) // 기존 값 제거 + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + static func get(for key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { return nil } + return (item as? Data) + } + + @discardableResult + static func delete(for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // 문자열 편의 메서드 + @discardableResult + static func setString(_ value: String, for key: String) -> Bool { + set(Data(value.utf8), for: key) + } + + static func getString(for key: String) -> String? { + guard let data = get(for: key) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/today-s-sound/Data/Models/Alarm.swift b/today-s-sound/Data/Models/Alarm.swift new file mode 100644 index 0000000..47a7efe --- /dev/null +++ b/today-s-sound/Data/Models/Alarm.swift @@ -0,0 +1,79 @@ +// +// Alarm.swift +// today-s-sound +// +// Created by Assistant +// + +import Foundation + +// MARK: - Alarm Response Models + +/// 알림 목록 응답 (서버 Envelope 구조) +struct AlarmListResponse: Codable { + let errorCode: Int? + let message: String + let result: [AlarmItem] + + // 편의 속성: result를 alarms로 접근 + var alarms: [AlarmItem] { + result + } +} + +/// 개별 알림 아이템 +struct AlarmItem: Codable, Identifiable { + let alias: String + let timeAgo: String + let summaries: [SummaryItem] + let isUrgent: Bool? + + // Identifiable을 위한 id (alias를 고유 식별자로 사용) + var id: String { alias } + + enum CodingKeys: String, CodingKey { + case alias + case timeAgo + case summaries + case isUrgent + } + + // 디코딩 시 isUrgent가 없으면 nil로 설정 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + alias = try container.decode(String.self, forKey: .alias) + timeAgo = try container.decode(String.self, forKey: .timeAgo) + summaries = try container.decode([SummaryItem].self, forKey: .summaries) + isUrgent = try container.decodeIfPresent(Bool.self, forKey: .isUrgent) + } + + // 인코딩 + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(alias, forKey: .alias) + try container.encode(timeAgo, forKey: .timeAgo) + try container.encode(summaries, forKey: .summaries) + try container.encodeIfPresent(isUrgent, forKey: .isUrgent) + } + + // 수동 초기화 (Preview 등에서 사용) + init(alias: String, timeAgo: String, summaries: [SummaryItem], isUrgent: Bool? = nil) { + self.alias = alias + self.timeAgo = timeAgo + self.summaries = summaries + self.isUrgent = isUrgent + } +} + +/// 요약 아이템 +struct SummaryItem: Codable, Identifiable { + let id: Int64 + let summary: String + let updatedAt: String // ISO8601 문자열 + + // Date로 변환하는 편의 속성 + var updatedDate: Date? { + let formatter = ISO8601DateFormatter() + return formatter.date(from: updatedAt) + } +} diff --git a/today-s-sound/Data/Models/AnonymousUser.swift b/today-s-sound/Data/Models/AnonymousUser.swift deleted file mode 100644 index a55ed57..0000000 --- a/today-s-sound/Data/Models/AnonymousUser.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct AnonymousUserResponse: Codable { - let errorCode: Int? - let message: String - let result: AnonymousUserResult -} - -struct AnonymousUserResult: Codable { - let userId: String - - enum CodingKeys: String, CodingKey { - case userId = "user_id" - } -} diff --git a/today-s-sound/Data/Models/Subscription.swift b/today-s-sound/Data/Models/Subscription.swift index 647634a..e957dbd 100644 --- a/today-s-sound/Data/Models/Subscription.swift +++ b/today-s-sound/Data/Models/Subscription.swift @@ -1,7 +1,38 @@ import Foundation -struct Subscription: Codable, Identifiable { - let id: UUID - let name: String +// MARK: - Subscription Response Models + +/// 구독 목록 응답 (서버 Envelope 구조) +struct SubscriptionListResponse: Codable { + let errorCode: Int? + let message: String + let result: [SubscriptionItem] + + // 편의 속성: result를 subscriptions로 접근 + var subscriptions: [SubscriptionItem] { + result + } +} + +/// 개별 구독 아이템 +struct SubscriptionItem: Codable, Identifiable { + let id: Int64 let url: String + let alias: String + let isUrgent: Bool + let keywords: [KeywordItem] + + enum CodingKeys: String, CodingKey { + case id + case url + case alias + case isUrgent + case keywords + } +} + +/// 키워드 아이템 +struct KeywordItem: Codable, Identifiable { + let id: Int64 + let name: String } diff --git a/today-s-sound/Data/Models/User.swift b/today-s-sound/Data/Models/User.swift new file mode 100644 index 0000000..0580af0 --- /dev/null +++ b/today-s-sound/Data/Models/User.swift @@ -0,0 +1,47 @@ +// +// User.swift +// today-s-sound +// +// Refactored by Assistant +// + +import Foundation + +// MARK: - 익명 사용자 등록 + +struct RegisterAnonymousRequest: Codable { + let deviceSecret: String +} + +struct RegisterAnonymousResponse: Codable { + let errorCode: String? + let message: String + let result: AnonymousUserResult +} + +struct AnonymousUserResult: Codable { + let userId: String +} + +// MARK: - 에러 응답 + +struct APIErrorResponse: Codable, Error { + let status: Int + let code: String + let message: String +} + +// MARK: - Device Secret 생성 유틸 + +enum DeviceSecretGenerator { + static func generate() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + // URL-safe base64 + let data = Data(bytes) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift index ea5e415..7fa25ad 100644 --- a/today-s-sound/Presentation/Features/Main/MainView.swift +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -2,6 +2,7 @@ import SwiftUI struct MainView: View { @StateObject private var viewModel = MainViewModel() + @State private var showingDebugSettings = false var body: some View { TabView { @@ -22,6 +23,52 @@ struct MainView: View { Image(systemName: "bookmark.fill") Text("구독") } + + #if DEBUG + // 디버그 모드에서만 표시 + NavigationView { + VStack(spacing: 20) { + Image(systemName: "hammer.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("개발자 도구") + .font(.title) + .fontWeight(.bold) + + List { + Section("테스트") { + NavigationLink("익명 사용자 등록 테스트") { + AnonymousTestView() + } + } + + Section("설정") { + Button(action: { + showingDebugSettings = true + }) { + HStack { + Image(systemName: "key.fill") + Text("키체인 관리") + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + .navigationTitle("디버그") + } + .tabItem { + Image(systemName: "hammer.fill") + Text("디버그") + } + .sheet(isPresented: $showingDebugSettings) { + DebugSettingsView() + } + #endif } } } diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift index 3831d06..8eb2723 100644 --- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -5,14 +5,54 @@ // Created by Assistant on 12/19/24. // +import Combine import SwiftUI struct AlertCardView: View { - let alert: Alert + let alert: Alert? + let alarm: AlarmItem? let colorScheme: ColorScheme + @State private var isPlaying: Bool = false + @State private var currentSummaryIndex: Int = 0 + @State private var cancellables = Set() + + // Alert 또는 AlarmItem 중 하나만 있어야 함 + init(alert: Alert? = nil, alarm: AlarmItem? = nil, colorScheme: ColorScheme) { + self.alert = alert + self.alarm = alarm + self.colorScheme = colorScheme + } + private var cardColor: Color { - alert.isUrgent ? .urgentPink : .primaryGreen + if let alert { + return alert.isUrgent ? .urgentPink : .primaryGreen + } else if let alarm { + // AlarmItem의 isUrgent 필드로 긴급 여부 판단 + return (alarm.isUrgent ?? false) ? .urgentPink : .primaryGreen + } + return .primaryGreen + } + + private var title: String { + alert?.title ?? alarm?.alias ?? "" + } + + private var timeText: String { + alert.map { _ in "2시간 전" } ?? alarm?.timeAgo ?? "" + } + + private var summaries: [String] { + alarm?.summaries.map(\.summary) ?? [] + } + + private var isUrgent: Bool { + if let alert { + return alert.isUrgent + } else if let alarm { + return alarm.isUrgent ?? false + } + return false } private var buttonBackgroundColor: Color { @@ -23,19 +63,23 @@ struct AlertCardView: View { VStack(spacing: 20) { // 상단: 타이틀과 아이콘 HStack(alignment: .top, spacing: 12) { - Image(systemName: alert.isUrgent ? "bell.fill" : "doc.fill") + Image(systemName: isUrgent ? "bell.fill" : "doc.fill") .font(.system(size: 24)) .foregroundColor(.white) + .accessibilityHidden(true) // 아이콘은 시각적 장식이므로 숨김 VStack(alignment: .leading, spacing: 8) { - Text(alert.title) + Text(title) .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) .multilineTextAlignment(.leading) + .accessibilityAddTraits(.isHeader) // 헤더로 인식 + .accessibilityLabel(title) - Text("2시간 전") + Text(timeText) .font(.system(size: 16)) .foregroundColor(.white.opacity(0.9)) + .accessibilityLabel("\(timeText)에 받은 알림") } Spacer() @@ -43,13 +87,35 @@ struct AlertCardView: View { // 하단: 음성으로 듣기 버튼 Button(action: { - SpeechService.shared.speak(text: alert.title) + if isPlaying { + // 재생 중단 + SpeechService.shared.stop() + isPlaying = false + currentSummaryIndex = 0 + cancellables.removeAll() + + // VoiceOver 알림 + UIAccessibility.post(notification: .announcement, argument: "재생이 중단되었습니다") + } else { + // 재생 시작 + playAllSummaries() + + // 재생 시작 VoiceOver 알림 + let summaryCount = summaries.count + if summaryCount > 0 { + UIAccessibility.post(notification: .announcement, argument: "\(summaryCount)개의 내용을 재생합니다") + } else { + UIAccessibility.post(notification: .announcement, argument: "알림 내용을 재생합니다") + } + } }, label: { HStack(spacing: 8) { - Image(systemName: "speaker.wave.2.fill") + Image(systemName: isPlaying ? "stop.circle.fill" : "speaker.wave.2.fill") .font(.system(size: 18)) - .foregroundStyle(Color.text(colorScheme)) - Text("음성으로 듣기") + .foregroundStyle(isPlaying ? .red : Color.text(colorScheme)) + .accessibilityHidden(true) // 아이콘은 숨김, 텍스트로 전달 + + Text(isPlaying ? "재생 중단" : "음성으로 듣기") .font(.system(size: 18, weight: .semibold)) .foregroundColor(Color.text(colorScheme)) } @@ -61,6 +127,10 @@ struct AlertCardView: View { .fill(buttonBackgroundColor) ) }) + .accessibilityLabel(accessibilityButtonLabel) + .accessibilityHint(accessibilityButtonHint) + .accessibilityValue(accessibilityButtonValue) + .accessibilityAddTraits(isPlaying ? .isSelected : []) } .padding(24) .background( @@ -68,6 +138,164 @@ struct AlertCardView: View { .fill(cardColor) .shadow(color: .black15, radius: 8, x: 0, y: 4) ) + .accessibilityElement(children: .combine) // 카드를 하나의 요소로 그룹화 + .accessibilityLabel(accessibilityCardLabel) + } + + // MARK: - 접근성 속성 + + private var accessibilityCardLabel: String { + let typeText = isUrgent ? "긴급 알림" : "알림" + return "\(typeText), \(title), \(timeText)" + } + + private var accessibilityButtonLabel: String { + isPlaying ? "재생 중단 버튼" : "음성으로 듣기 버튼" + } + + private var accessibilityButtonHint: String { + if isPlaying { + return "이중탭하여 재생을 중단합니다" + } else { + let count = summaries.count + if count > 0 { + return "이중탭하여 \(count)개의 알림 내용을 음성으로 들을 수 있습니다" + } else { + return "이중탭하여 알림 내용을 음성으로 들을 수 있습니다" + } + } + } + + private var accessibilityButtonValue: String { + if isPlaying { + let total = summaries.count + if total > 0 { + return "재생 중, \(currentSummaryIndex + 1)번째 내용 재생 중, 전체 \(total)개" + } else { + return "재생 중" + } + } else { + let count = summaries.count + if count > 0 { + return "대기 중, \(count)개의 내용이 있습니다" + } else { + return "대기 중" + } + } + } + + // MARK: - 음성 재생 함수 + + private func playAllSummaries() { + guard let alarm, !alarm.summaries.isEmpty else { + // AlarmItem이 없으면 Alert의 title만 재생 + if let alert { + SpeechService.shared.speak(text: alert.title) + isPlaying = true + + // 재생 완료 감지 + SpeechService.shared.didFinishSpeaking + .sink { [self] _ in + isPlaying = false + } + .store(in: &cancellables) + } + return + } + + // 첫 번째 summary 재생 + currentSummaryIndex = 0 + isPlaying = true + playSummary(at: 0) + } + + private func playSummary(at index: Int) { + guard let alarm, + index < alarm.summaries.count + else { + // 모든 summary 재생 완료 + isPlaying = false + currentSummaryIndex = 0 + cancellables.removeAll() + + // VoiceOver 알림: 재생 완료 + UIAccessibility.post(notification: .announcement, argument: "모든 내용 재생이 완료되었습니다") + return + } + + // 중복 재생 방지: 이미 다른 summary를 재생 중이면 리턴 + guard currentSummaryIndex == index || !SpeechService.shared.isSpeaking else { + print("⚠️ 이미 재생 중입니다. 중복 재생 방지: 현재 index=\(currentSummaryIndex), 요청된 index=\(index)") + return + } + + // 이전 cancellable 정리 + cancellables.removeAll() + + let summary = alarm.summaries[index] + currentSummaryIndex = index + + // 순서 안내 음성 재생 (예: "첫 번째 내용", "두 번째 내용") + let orderText = getOrderText(index: index, total: alarm.summaries.count) + let fullText = "\(orderText). \(summary.summary)" + + // 재생 시작 전에 중복 체크 + guard !SpeechService.shared.isSpeaking else { + print("⚠️ SpeechService가 이미 재생 중입니다. 중복 재생 방지") + return + } + + // 재생 시작 + SpeechService.shared.speak(text: fullText) + + // VoiceOver 알림: 현재 재생 중인 내용 + let total = alarm.summaries.count + UIAccessibility.post(notification: .announcement, argument: "\(index + 1)번째 내용 재생 중, 전체 \(total)개 중") + + // 재생 완료 감지 (index 검증으로 중복 방지) + let cancellable = SpeechService.shared.didFinishSpeaking + .sink(receiveValue: { [self] _ in + // 현재 재생 중인 index가 변경되었으면 리턴 (중복 방지) + guard currentSummaryIndex == index else { + print("⚠️ 재생 중 index 변경됨. 무시: 예상=\(index), 현재=\(currentSummaryIndex)") + return + } + + // 다음 summary 재생 + let nextIndex = index + 1 + if nextIndex < alarm.summaries.count { + // 약간의 딜레이 후 다음 재생 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // 딜레이 후에도 여전히 같은 index인지 확인 + if currentSummaryIndex == index { + playSummary(at: nextIndex) + } + } + } else { + // 모두 재생 완료 + isPlaying = false + currentSummaryIndex = 0 + cancellables.removeAll() + + // VoiceOver 알림: 재생 완료 + UIAccessibility.post(notification: .announcement, argument: "모든 내용 재생이 완료되었습니다") + } + }) + + cancellable.store(in: &cancellables) + } + + // MARK: - 순서 텍스트 생성 + + private func getOrderText(index: Int, total: Int) -> String { + let numbers = ["첫", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉", "열"] + + if index < numbers.count { + return "\(numbers[index]) 번째 내용" + } else { + // 10개 이상일 경우 숫자로 표기 + return "\(index + 1)번째 내용" + } } } diff --git a/today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift b/today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift new file mode 100644 index 0000000..b739905 --- /dev/null +++ b/today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift @@ -0,0 +1,136 @@ +// +// AlarmGroupView.swift +// today-s-sound +// +// 알림 그룹을 표시하는 카드 컴포넌트 +// + +import Combine +import SwiftUI + +struct AlarmGroupView: View { + let alarm: AlarmItem + let colorScheme: ColorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // 헤더: 구독 이름 + 시간 + HStack { + Text(alarm.alias) + .font(.custom("KoddiUD OnGothic Bold", size: 18)) + .foregroundColor(Color.text(colorScheme)) + + Spacer() + + Text(alarm.timeAgo) + .font(.system(size: 13)) + .foregroundColor(Color.secondaryText(colorScheme)) + } + + // 구분선 + Divider() + .background(Color.border(colorScheme)) + + // 요약 목록 + VStack(alignment: .leading, spacing: 8) { + ForEach(alarm.summaries) { summary in + SummaryRowView(summary: summary, colorScheme: colorScheme) + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondaryBackground(colorScheme)) + .shadow(color: .black5, radius: 4, x: 0, y: 2) + ) + } +} + +struct SummaryRowView: View { + let summary: SummaryItem + let colorScheme: ColorScheme + + @State private var isPlaying: Bool = false + @State private var cancellable: AnyCancellable? + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // 불릿 포인트 + Circle() + .fill(Color.primaryGreen) + .frame(width: 6, height: 6) + .padding(.top, 6) + + // 요약 텍스트 + Text(summary.summary) + .font(.system(size: 15)) + .foregroundColor(Color.text(colorScheme)) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + // 재생 버튼 + Button(action: { + if isPlaying { + // 재생 중단 + SpeechService.shared.stop() + isPlaying = false + cancellable?.cancel() + } else { + // 재생 시작 + SpeechService.shared.speak(text: summary.summary) + isPlaying = true + + // 재생 완료 알림 구독 + cancellable = SpeechService.shared.didFinishSpeaking + .sink { _ in + isPlaying = false + } + } + }) { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 24)) + .foregroundColor(isPlaying ? .red : Color.primaryGreen) + } + .buttonStyle(.plain) + .padding(.top, 2) + } + } +} + +// MARK: - Preview + +#if DEBUG + struct AlarmGroupView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + AlarmGroupView( + alarm: AlarmItem( + alias: "동국대학교 장애학생지원센터", + timeAgo: "2시간 전", + summaries: [ + SummaryItem(id: 1, summary: "2025년 1학기 학습지원 도우미 모집 안내", updatedAt: "2025-11-01T10:00:00Z"), + SummaryItem(id: 2, summary: "장애학생 학습 보조기기 대여 신청 접수 중", updatedAt: "2025-11-01T11:00:00Z") + ], + isUrgent: false + ), + colorScheme: .light + ) + + AlarmGroupView( + alarm: AlarmItem( + alias: "서울시 긴급재난문자", + timeAgo: "5분 전", + summaries: [ + SummaryItem(id: 3, summary: "강남구 일대 호우 특보 발령", updatedAt: "2025-11-01T11:50:00Z") + ], + isUrgent: true + ), + colorScheme: .dark + ) + } + .padding() + } + } +#endif diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index ad8ae0a..811c9e5 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -14,18 +14,104 @@ struct NotificationListView: View { Spacer() ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) - ScrollView { + // 로딩 상태 + if viewModel.isLoading, viewModel.alarms.isEmpty { + Spacer() + ProgressView("불러오는 중...") + .progressViewStyle(CircularProgressViewStyle()) + .accessibilityLabel("알림 목록을 불러오는 중입니다") + .accessibilityHint("잠시만 기다려주세요") + Spacer() + } + // 에러 메시지 + else if let errorMessage = viewModel.errorMessage { + Spacer() + VStack(spacing: 16) { + Text("⚠️") + .font(.system(size: 48)) + .accessibilityHidden(true) // 이모지는 숨김, 텍스트로 전달 + + Text(errorMessage) + .font(.system(size: 16)) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("오류: \(errorMessage)") + + Button("다시 시도") { + viewModel.refresh() + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.primaryGreen) + .foregroundColor(.white) + .cornerRadius(8) + .accessibilityLabel("다시 시도 버튼") + .accessibilityHint("이중탭하여 알림 목록을 다시 불러옵니다") + } + Spacer() + } + // 빈 상태 + else if viewModel.alarms.isEmpty { + Spacer() VStack(spacing: 16) { - ForEach(viewModel.alerts) { alert in - AlertCardView(alert: alert, colorScheme: colorScheme) + Text("📭") + .font(.system(size: 48)) + .accessibilityHidden(true) // 이모지는 숨김 + + Text("최근 알림이 없습니다") + .font(.system(size: 16)) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("최근 알림이 없습니다") + } + Spacer() + } + // 알림 목록 + else { + ScrollView { + VStack(spacing: 16) { + ForEach(Array(viewModel.alarms.enumerated()), id: \.element.id) { index, alarm in + AlertCardView(alarm: alarm, colorScheme: colorScheme) + .accessibilityElement(children: .ignore) // 개별 카드 내부 접근성은 카드에서 처리 + .accessibilityLabel("알림 \(index + 1), \(viewModel.alarms.count)개 중") + .onAppear { + // 마지막에서 3번째 아이템이 보일 때만 트리거 + if let lastIndex = viewModel.alarms.indices.last, + let currentIndex = viewModel.alarms.firstIndex(where: { $0.id == alarm.id }), + currentIndex >= lastIndex - 2 + { + viewModel.loadMoreIfNeeded(currentItem: alarm) + } + } + } + + // 더 불러오는 중 인디케이터 + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding() + .accessibilityLabel("추가 알림을 불러오는 중입니다") + Spacer() + } + } } + .padding(.horizontal, 16) + .padding(.top, 8) } - .padding(.horizontal, 16) - .padding(.top, 8) + .refreshable { + viewModel.refresh() + } + .accessibilityLabel("알림 목록") + .accessibilityHint("총 \(viewModel.alarms.count)개의 알림이 있습니다. 아래로 당겨서 새로고침할 수 있습니다") } } } .navigationBarHidden(true) + .onAppear { + // 처음 로드 + if viewModel.alarms.isEmpty { + viewModel.loadAlarms() + } + } } } } diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift index 19c4a46..9cf6a65 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift @@ -1,36 +1,145 @@ +// +// NotificationListViewModel.swift +// today-s-sound +// +// Updated to use real API with infinite scroll +// + import Combine -import Foundation +import SwiftUI class NotificationListViewModel: ObservableObject { - @Published var alerts: [Alert] = [] + @Published var alarms: [AlarmItem] = [] + @Published var isLoading: Bool = false + @Published var isLoadingMore: Bool = false + @Published var errorMessage: String? + + private let apiService: APIService + private var cancellables = Set() + + // 오프셋 기반 페이지네이션 + private var currentPage: Int = 0 + private let pageSize: Int = 10 + private var hasMoreData: Bool = true + + init(apiService: APIService = APIService()) { + self.apiService = apiService + } + + /// 알림 목록 불러오기 + func loadAlarms() { + print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("📞 loadAlarms() 호출됨!") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━") + + // 이미 로딩 중이거나 더 이상 데이터가 없으면 리턴 + guard !isLoading, !isLoadingMore, hasMoreData else { + print("⏸️ 알림 로딩 중단: isLoading=\(isLoading), isLoadingMore=\(isLoadingMore), hasMoreData=\(hasMoreData)") + return + } + print("✅ 로딩 상태 체크 통과") + + guard let userId = Keychain.getString(for: KeychainKey.userId), + let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) + else { + print("❌ 키체인에서 userId 또는 deviceSecret을 찾을 수 없음!") + errorMessage = "사용자 정보가 없습니다" + return + } + print("✅ 키체인 정보 획득 성공") + print(" userId: \(userId)") + print(" deviceSecret: \(deviceSecret.prefix(20))...") + + // 첫 로딩인지 더 불러오기인지 구분 + if currentPage == 0 { + isLoading = true + print("📂 첫 로딩 시작") + } else { + isLoadingMore = true + print("📂 추가 로딩 시작") + } + errorMessage = nil + + print("📡 알림 목록 API 요청 준비:") + print(" URL: http://localhost:8080/api/alarms") + print(" page: \(currentPage)") + print(" size: \(pageSize)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + apiService.getAlarms( + userId: userId, + deviceSecret: deviceSecret, + page: currentPage, + size: pageSize + ) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + isLoading = false + isLoadingMore = false + + switch completion { + case .finished: + break + + case let .failure(error): + switch error { + case let .serverError(statusCode): + errorMessage = "서버 오류 (상태: \(statusCode))" + + case .decodingFailed: + errorMessage = "응답 처리 실패" + + case let .requestFailed(requestError): + errorMessage = "요청 실패: \(requestError.localizedDescription)" + + case .invalidURL: + errorMessage = "잘못된 URL" + + case .unknown: + errorMessage = "알 수 없는 오류" + } + + print("❌ 알림 목록 조회 실패: \(errorMessage ?? "")") + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + + let newItems = response.alarms + + // 기존 목록에 추가 (서버에서 이미 정렬됨!) + alarms.append(contentsOf: newItems) + + // 다음 페이지로 이동 + currentPage += 1 + + // 받은 개수가 pageSize보다 적으면 더 이상 데이터 없음 + if newItems.count < pageSize { + hasMoreData = false + print("🏁 마지막 페이지 도달: 받은 개수(\(newItems.count)) < 예상(\(pageSize))") + } + + print("✅ 알림 목록 조회 성공: \(newItems.count)개 추가 (전체: \(alarms.count)개)") + } + ) + .store(in: &cancellables) + } - init() { - loadMock() + /// 새로고침 (처음부터 다시 로드) + func refresh() { + print("🔄 알림 새로고침") + alarms = [] + currentPage = 0 + hasMoreData = true + errorMessage = nil + loadAlarms() } - private func loadMock() { - alerts = [ - Alert( - id: UUID(), - title: "일이삼사오육칠팔", - content: "본문 예시", - date: Date().addingTimeInterval(-7200), - isUrgent: true - ), - Alert( - id: UUID(), - title: "잡코리아 채용 공고", - content: "본문 예시 2", - date: Date().addingTimeInterval(-10800), - isUrgent: false - ), - Alert( - id: UUID(), - title: "동국대 도서관 휴관 안내", - content: "본문 예시 3", - date: Date().addingTimeInterval(-14400), - isUrgent: true - ) - ] + /// 특정 아이템이 보일 때 호출 (무한 스크롤 트리거) + func loadMoreIfNeeded(currentItem item: AlarmItem) { + // View에서 이미 threshold 체크했으므로 바로 로드 + loadAlarms() } } diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index 6bcd6b6..b2e39af 100644 --- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -8,50 +8,50 @@ import SwiftUI struct OnBoardingView: View { - @EnvironmentObject var session: SessionStore - @State private var isLoading = false + @EnvironmentObject var session: SessionStore + @State private var isLoading = false - var body: some View { - VStack(spacing: 20) { - Text("환영합니다 👋") - .font(.largeTitle).bold() - Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) + var body: some View { + VStack(spacing: 20) { + Text("환영합니다 👋") + .font(.largeTitle).bold() + Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) - if isLoading { - ProgressView("등록 중…") - .padding(.top, 8) - } else { - Button { - Task { - isLoading = true - defer { isLoading = false } - await session.registerIfNeeded() - } - } label: { - Text("시작하기") - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .padding(.top, 12) - } + if isLoading { + ProgressView("등록 중…") + .padding(.top, 8) + } else { + Button { + Task { + isLoading = true + defer { isLoading = false } + await session.registerIfNeeded() + } + } label: { + Text("시작하기") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.top, 12) + } - if let err = session.lastError { - Text(err) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.top, 8) - } + if let err = session.lastError { + Text(err) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.top, 8) + } - // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) - // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { - // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) - // } - } - .padding(24) + // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) + // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { + // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) + // } } + .padding(24) + } } diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift index 2ab3db1..d932ceb 100644 --- a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift +++ b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift @@ -1,29 +1,117 @@ import Combine import SwiftUI +@MainActor final class AnonymousTestViewModel: ObservableObject { - @Published var deviceSecret: String = UUID().uuidString + @Published var deviceSecret: String = DeviceSecretGenerator.generate() @Published var userId: String = "" @Published var log: String = "" + @Published var isLoading: Bool = false + @Published var keychainData: [String: String] = [:] - private let api = APIService() + private let apiService = APIService() private var cancellables: Set = [] - func createAnonymous() { - log = "요청 중..." - api.createAnonymous(deviceSecret: deviceSecret) + // 키체인 데이터 로드 + func loadKeychainData() { + keychainData.removeAll() + + if let secret = Keychain.getString(for: KeychainKey.deviceSecret) { + keychainData["deviceSecret"] = secret + } else { + keychainData["deviceSecret"] = "(없음)" + } + + if let uid = Keychain.getString(for: KeychainKey.userId) { + keychainData["userId"] = uid + } else { + keychainData["userId"] = "(없음)" + } + + if let apiKey = Keychain.getString(for: KeychainKey.apiKey) { + keychainData["apiKey"] = apiKey + } else { + keychainData["apiKey"] = "(없음)" + } + + log = "🔑 키체인 데이터 로드 완료" + } + + // 키체인 초기화 + func clearKeychain() { + Keychain.delete(for: KeychainKey.deviceSecret) + Keychain.delete(for: KeychainKey.userId) + Keychain.delete(for: KeychainKey.apiKey) + + loadKeychainData() + log = "🗑️ 키체인 데이터 삭제 완료" + } + + // 디바이스 시크릿 재생성 + func regenerateDeviceSecret() { + deviceSecret = DeviceSecretGenerator.generate() + log = "새로운 deviceSecret 생성됨" + } + + // 익명 사용자 등록 + func registerAnonymous() { + isLoading = true + userId = "" + log = "📤 익명 사용자 등록 요청 중...\ndeviceSecret: \(deviceSecret.prefix(20))..." + + apiService.registerAnonymous(deviceSecret: deviceSecret) .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - switch completion { - case .finished: - break - case let .failure(error): - self?.log = "실패: \(error)" + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + isLoading = false + + switch completion { + case .finished: + break + + case let .failure(error): + // 상세한 에러 메시지 + var errorLog = "❌ 등록 실패\n" + switch error { + case let .serverError(statusCode): + errorLog += "서버 오류 (상태: \(statusCode))" + + case let .decodingFailed(decodeError): + errorLog += "응답 처리 실패\n\(decodeError.localizedDescription)" + + case let .requestFailed(requestError): + errorLog += "요청 실패\n\(requestError.localizedDescription)" + + case .invalidURL: + errorLog += "잘못된 URL" + + case .unknown: + errorLog += "알 수 없는 오류" + } + + log = errorLog + print("❌ \(errorLog)") + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + + userId = response.result.userId + + var successLog = "✅ 등록 성공!\n" + successLog += "━━━━━━━━━━━━━━━━━━\n" + successLog += "User ID: \(response.result.userId)\n" + successLog += "Message: \(response.message)\n" + if let errorCode = response.errorCode { + successLog += "Error Code: \(errorCode)\n" + } + successLog += "━━━━━━━━━━━━━━━━━━" + + log = successLog + print("✅ 익명 사용자 등록 성공: \(response.result.userId)") } - } receiveValue: { [weak self] response in - self?.userId = response.result.userId - self?.log = "성공: user_id=\(response.result.userId)" - } + ) .store(in: &cancellables) } } @@ -33,29 +121,170 @@ struct AnonymousTestView: View { var body: some View { Form { - Section(header: Text("디바이스 시크릿")) { - TextField("deviceSecret", text: $viewModel.deviceSecret) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + // MARK: - Device Secret 섹션 + + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Device Secret") + .font(.caption) + .foregroundColor(.secondary) + + Text(viewModel.deviceSecret) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.primary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + + Button(action: viewModel.regenerateDeviceSecret) { + HStack { + Image(systemName: "arrow.clockwise") + Text("새로 생성") + } + .font(.caption) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + } header: { + Label("디바이스 시크릿", systemImage: "key.fill") } - Section(header: Text("동작")) { - Button("익명 사용자 생성") { - viewModel.createAnonymous() + + // MARK: - 동작 섹션 + + Section { + Button(action: viewModel.registerAnonymous) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(.circular) + } else { + Image(systemName: "person.badge.plus") + } + Text(viewModel.isLoading ? "등록 중..." : "익명 사용자 등록") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) } + .disabled(viewModel.isLoading || viewModel.deviceSecret.isEmpty) + } header: { + Label("동작", systemImage: "bolt.fill") + } footer: { + Text("서버에 익명 사용자를 등록합니다.") + .font(.caption) } + + // MARK: - 결과 섹션 + if !viewModel.userId.isEmpty { - Section(header: Text("결과 user_id")) { - Text(viewModel.userId) - .font(.system(.body, design: .monospaced)) + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("User ID") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Button(action: { + UIPasteboard.general.string = viewModel.userId + }) { + Label("복사", systemImage: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.borderless) + } + + Text(viewModel.userId) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.green) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + } + .padding(.vertical, 4) + } header: { + Label("결과", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) } } - Section(header: Text("로그")) { - Text(viewModel.log) - .font(.footnote) - .foregroundColor(.gray) + + // MARK: - 키체인 확인 섹션 + + Section { + Button(action: viewModel.loadKeychainData) { + HStack { + Image(systemName: "key.fill") + Text("키체인 데이터 확인") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + + if !viewModel.keychainData.isEmpty { + VStack(alignment: .leading, spacing: 12) { + ForEach(viewModel.keychainData.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in + VStack(alignment: .leading, spacing: 4) { + Text(key) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(value == "(없음)" ? .red : .primary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(value == "(없음)" ? Color.red.opacity(0.1) : Color.secondary.opacity(0.1)) + .cornerRadius(8) + } + } + } + .padding(.vertical, 8) + + Button(action: viewModel.clearKeychain) { + HStack { + Image(systemName: "trash.fill") + Text("키체인 초기화") + } + .font(.caption) + .foregroundColor(.red) + } + .buttonStyle(.borderless) + } + } header: { + Label("키체인 확인 (디버그)", systemImage: "externaldrive.fill") + } footer: { + Text("시뮬레이터에 저장된 Keychain 데이터를 확인합니다.") + .font(.caption) } + + // MARK: - 로그 섹션 + + if !viewModel.log.isEmpty { + Section { + ScrollView { + Text(viewModel.log) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(viewModel.log.contains("❌") ? .red : + viewModel.log.contains("✅") ? .green : .secondary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 100) + } header: { + Label("로그", systemImage: "text.alignleft") + } + } + } + .navigationTitle("익명 사용자 등록 테스트") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.loadKeychainData() } - .navigationTitle("익명 생성 테스트") } } diff --git a/today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift b/today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift new file mode 100644 index 0000000..f249107 --- /dev/null +++ b/today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift @@ -0,0 +1,101 @@ +// +// DebugSettingsView.swift +// today-s-sound +// +// 디버그용 설정 화면 +// + +import SwiftUI + +struct DebugSettingsView: View { + @EnvironmentObject var sessionStore: SessionStore + @Environment(\.dismiss) var dismiss + @State private var showingAlert = false + + var body: some View { + NavigationView { + Form { + // MARK: - 키체인 정보 + + Section { + if let userId = sessionStore.userId { + VStack(alignment: .leading, spacing: 8) { + Text("User ID") + .font(.caption) + .foregroundColor(.secondary) + + Text(userId) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } else { + Text("User ID: (없음)") + .foregroundColor(.secondary) + } + + if let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) { + VStack(alignment: .leading, spacing: 8) { + Text("Device Secret") + .font(.caption) + .foregroundColor(.secondary) + + Text(deviceSecret.prefix(40) + "...") + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } else { + Text("Device Secret: (없음)") + .foregroundColor(.secondary) + } + } header: { + Label("키체인 정보", systemImage: "key.fill") + } + + // MARK: - 위험 구역 + + Section { + Button(role: .destructive, action: { + showingAlert = true + }) { + HStack { + Image(systemName: "trash.fill") + Text("키체인 초기화 (로그아웃)") + } + } + } header: { + Label("위험 구역", systemImage: "exclamationmark.triangle.fill") + } footer: { + Text("⚠️ 키체인을 초기화하면 다시 온보딩 화면으로 돌아갑니다.") + .font(.caption) + } + } + .navigationTitle("디버그 설정") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("닫기") { + dismiss() + } + } + } + .alert("키체인 초기화", isPresented: $showingAlert) { + Button("취소", role: .cancel) {} + Button("초기화", role: .destructive) { + sessionStore.logout() + dismiss() + } + } message: { + Text("모든 키체인 데이터를 삭제하고 처음부터 시작합니다.") + } + } + } +} + +#if DEBUG + struct DebugSettingsView_Previews: PreviewProvider { + static var previews: some View { + DebugSettingsView() + .environmentObject(SessionStore()) + } + } +#endif diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift index 8dc1de5..d8e9100 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -8,33 +8,48 @@ import SwiftUI struct SubscriptionCardView: View { - let subscription: Subscription + let subscription: SubscriptionItem let colorScheme: ColorScheme var body: some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) { - Text(subscription.name) + // 구독 이름 (alias) + Text(subscription.alias) .font(.system(size: 20, weight: .semibold)) .foregroundColor(Color.text(colorScheme)) + // URL Text(subscription.url) .font(.system(size: 13)) .foregroundColor(Color.secondaryText(colorScheme)) .lineLimit(1) - HStack(spacing: 8) { - StatusBadge(text: "등록중", colorScheme: colorScheme) - StatusBadge(text: "일이삼사", colorScheme: colorScheme) + // 키워드 배지들 + if !subscription.keywords.isEmpty { + HStack(spacing: 8) { + ForEach(subscription.keywords.prefix(3)) { keyword in + StatusBadge(text: keyword.name, colorScheme: colorScheme) + } + + // 더 많은 키워드가 있으면 "+" 표시 + if subscription.keywords.count > 3 { + StatusBadge( + text: "+\(subscription.keywords.count - 3)", + colorScheme: colorScheme + ) + } + } } } Spacer() + // 긴급 알림 아이콘 Button(action: {}, label: { - Image(systemName: "bell") + Image(systemName: subscription.isUrgent ? "bell.fill" : "bell") .font(.system(size: 40)) - .foregroundColor(.green) + .foregroundColor(subscription.isUrgent ? .red : .green) }) } .padding(16) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift index 34846ac..a822752 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift @@ -8,8 +8,10 @@ import SwiftUI struct SubscriptionsListSection: View { - let subscriptions: [Subscription] + let subscriptions: [SubscriptionItem] let colorScheme: ColorScheme + let onLoadMore: (SubscriptionItem) -> Void + let isLoadingMore: Bool var body: some View { if subscriptions.isEmpty { @@ -19,6 +21,25 @@ struct SubscriptionsListSection: View { VStack(spacing: 12) { ForEach(subscriptions) { subscription in SubscriptionCardView(subscription: subscription, colorScheme: colorScheme) + .onAppear { + // 마지막에서 5번째 아이템이 보일 때만 트리거 + if let lastIndex = subscriptions.indices.last, + let currentIndex = subscriptions.firstIndex(where: { $0.id == subscription.id }), + currentIndex >= lastIndex - 4 + { // 마지막에서 5번째부터 + onLoadMore(subscription) + } + } + } + + // 더 불러오는 중 인디케이터 + if isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() + } } } .padding(.horizontal, 16) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index a10f7fb..c962048 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -16,10 +16,44 @@ struct SubscriptionListView: View { ScreenMainTitle(text: "구독 설정", colorScheme: colorScheme) ScreenSubTitle(text: "구독 중인 페이지", colorScheme: colorScheme) - SubscriptionsListSection( - subscriptions: viewModel.subscriptions, - colorScheme: colorScheme - ) + // 로딩 상태 + if viewModel.isLoading, viewModel.subscriptions.isEmpty { + Spacer() + ProgressView("불러오는 중...") + .progressViewStyle(CircularProgressViewStyle()) + Spacer() + } + // 에러 메시지 + else if let errorMessage = viewModel.errorMessage { + Spacer() + VStack(spacing: 16) { + Text("⚠️") + .font(.system(size: 48)) + Text(errorMessage) + .font(.system(size: 16)) + .foregroundColor(Color.secondaryText(colorScheme)) + Button("다시 시도") { + viewModel.refresh() + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.primaryGreen) + .foregroundColor(.white) + .cornerRadius(8) + } + Spacer() + } + // 구독 목록 + else { + SubscriptionsListSection( + subscriptions: viewModel.subscriptions, + colorScheme: colorScheme, + onLoadMore: { item in + viewModel.loadMoreIfNeeded(currentItem: item) + }, + isLoadingMore: viewModel.isLoadingMore + ) + } AddSubscriptionButton(colorScheme: colorScheme) { showAddSubscription = true @@ -27,6 +61,16 @@ struct SubscriptionListView: View { } } .navigationBarHidden(true) + .onAppear { + // 처음 로드 + if viewModel.subscriptions.isEmpty { + viewModel.loadSubscriptions() + } + } + .refreshable { + // Pull to refresh + viewModel.refresh() + } } .sheet(isPresented: $showAddSubscription) { AddSubscriptionView() diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift index c3cb4ac..e1d3f54 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -1,17 +1,130 @@ +// +// SubscriptionListViewModel.swift +// today-s-sound +// +// Updated to use offset-based pagination +// + import Combine -import Foundation +import SwiftUI class SubscriptionListViewModel: ObservableObject { - @Published var subscriptions: [Subscription] = [] + @Published var subscriptions: [SubscriptionItem] = [] + @Published var isLoading: Bool = false + @Published var isLoadingMore: Bool = false + @Published var errorMessage: String? + + private let apiService: APIService + private var cancellables = Set() + + // 오프셋 기반 페이지네이션 + private var currentPage: Int = 0 + private let pageSize: Int = 5 + private var hasMoreData: Bool = true + + init(apiService: APIService = APIService()) { + self.apiService = apiService + } + + /// 구독 목록 불러오기 + func loadSubscriptions() { + // 이미 로딩 중이거나 더 이상 데이터가 없으면 리턴 + guard !isLoading, !isLoadingMore, hasMoreData else { + print("⏸️ 로딩 중단: isLoading=\(isLoading), isLoadingMore=\(isLoadingMore), hasMoreData=\(hasMoreData)") + return + } + + guard let userId = Keychain.getString(for: KeychainKey.userId), + let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) + else { + errorMessage = "사용자 정보가 없습니다" + return + } + + // 첫 로딩인지 더 불러오기인지 구분 + if currentPage == 0 { + isLoading = true + } else { + isLoadingMore = true + } + errorMessage = nil + + print("📡 구독 목록 요청: page=\(currentPage), size=\(pageSize)") + + apiService.getSubscriptions( + userId: userId, + deviceSecret: deviceSecret, + page: currentPage, + size: pageSize + ) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard let self else { return } + isLoading = false + isLoadingMore = false + + switch completion { + case .finished: + break + + case let .failure(error): + switch error { + case let .serverError(statusCode): + errorMessage = "서버 오류 (상태: \(statusCode))" + + case .decodingFailed: + errorMessage = "응답 처리 실패" + + case let .requestFailed(requestError): + errorMessage = "요청 실패: \(requestError.localizedDescription)" + + case .invalidURL: + errorMessage = "잘못된 URL" + + case .unknown: + errorMessage = "알 수 없는 오류" + } + + print("❌ 구독 목록 조회 실패: \(errorMessage ?? "")") + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + + let newItems = response.subscriptions + + // 기존 목록에 추가 (서버에서 이미 정렬됨!) + subscriptions.append(contentsOf: newItems) + + // 다음 페이지로 이동 + currentPage += 1 + + // 받은 개수가 pageSize보다 적으면 더 이상 데이터 없음 + if newItems.count < pageSize { + hasMoreData = false + print("🏁 마지막 페이지 도달: 받은 개수(\(newItems.count)) < 예상(\(pageSize))") + } + + print("✅ 구독 목록 조회 성공: \(newItems.count)개 추가 (전체: \(subscriptions.count)개)") + } + ) + .store(in: &cancellables) + } - init() { - loadMock() + /// 새로고침 (처음부터 다시 로드) + func refresh() { + print("🔄 새로고침") + subscriptions = [] + currentPage = 0 + hasMoreData = true + errorMessage = nil + loadSubscriptions() } - private func loadMock() { - subscriptions = [ - Subscription(id: UUID(), name: "동국대학교 공지사항", url: "https://www.dongguk.edu"), - Subscription(id: UUID(), name: "네이버 연합뉴스 속보", url: "https://news.naver.com") - ] + /// 특정 아이템이 보일 때 호출 (무한 스크롤 트리거) + func loadMoreIfNeeded(currentItem item: SubscriptionItem) { + // View에서 이미 threshold 체크했으므로 바로 로드 + loadSubscriptions() } } diff --git a/today-s-sound/Services/AppState/SessionStore.swift b/today-s-sound/Services/AppState/SessionStore.swift deleted file mode 100644 index 7ffe585..0000000 --- a/today-s-sound/Services/AppState/SessionStore.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// SessionStore.swift -// today-s-sound -// -// Created by 하승연 on 10/30/25. -// - -import Foundation -import SwiftUI - -@MainActor -final class SessionStore: ObservableObject { - @Published private(set) var userId: String? - @Published private(set) var isRegistered: Bool = false - @Published var lastError: String? - - private let api = APIClient() - - init() { - // 앱 시작 시, 키체인에 저장되어 있으면 로드 - if let savedId = Keychain.getString(for: KeychainKey.userId) { - self.userId = savedId - self.isRegistered = true - } else { - self.isRegistered = false - } - } - - /// 처음 실행 시 한 번 호출: deviceSecret 생성/보관 → 서버 등록 - func registerIfNeeded() async { - guard !isRegistered else { return } - - // 1) deviceSecret을 키체인에서 찾고, 없으면 생성하여 저장 - let secret: String = { - if let s = Keychain.getString(for: KeychainKey.deviceSecret) { return s } - let gen = DeviceSecret.generate() - Keychain.setString(gen, for: KeychainKey.deviceSecret) - return gen - }() - - do { - // 2) 서버 호출 - let body = RegisterAnonymousBody(deviceSecret: secret) - typealias Resp = SuccessEnvelope - let envelope: Resp = try await api.postJSON(path: "/api/users/anonymous", body: body) - - // 3) user_id 보관 - Keychain.setString(envelope.result.userId, for: KeychainKey.userId) - self.userId = envelope.result.userId - self.isRegistered = true - - // (옵션) 서버가 api_key 같은 걸 준다면 저장 - // if let key = envelope.result.api_key { Keychain.setString(key, for: KeychainKey.apiKey) } - - } catch let APIError.http(_, data) { - // 스웨거 에러 포맷 시도 디코딩 - if let data, let err = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { - lastError = "[\(err.code)] \(err.message)" - } else { - lastError = "알 수 없는 서버 오류" - } - } catch { - lastError = error.localizedDescription - } - } -} diff --git a/today-s-sound/Services/Config/Enviroment.swift b/today-s-sound/Services/Config/Enviroment.swift deleted file mode 100644 index 2bd4160..0000000 --- a/today-s-sound/Services/Config/Enviroment.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Enviroment.swift -// today-s-sound -// -// Created by 하승연 on 10/30/25. -// - -import Foundation - -enum AppConfig { - // 배포/개발 분기 필요하면 Scheme/xcconfig로 주입해도 됨 - static let baseURL = URL(string: "http://localhost:8080")! // 예: https://api.example.com -} diff --git a/today-s-sound/Services/Network/APIClient.swift b/today-s-sound/Services/Network/APIClient.swift deleted file mode 100644 index ae8e41f..0000000 --- a/today-s-sound/Services/Network/APIClient.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// APIClient.swift -// today-s-sound -// -// Created by 하승연 on 10/30/25. -// - -import Foundation - -enum APIError: Error, LocalizedError { - case invalidURL - case http(status: Int, data: Data?) - case decoding(Error) - case underlying(Error) - - var errorDescription: String? { - switch self { - case .invalidURL: return "잘못된 URL" - case .http(let s, _): return "서버 오류(\(s))" - case .decoding(let e): return "디코딩 오류: \(e.localizedDescription)" - case .underlying(let e): return e.localizedDescription - } - } -} - -final class APIClient { - private let baseURL: URL - private let session: URLSession - - init(baseURL: URL = AppConfig.baseURL) { - self.baseURL = baseURL - let cfg = URLSessionConfiguration.default - cfg.httpCookieStorage = HTTPCookieStorage.shared - cfg.httpShouldSetCookies = true - cfg.httpAdditionalHeaders = ["Accept": "application/json", - "Content-Type": "application/json"] - self.session = URLSession(configuration: cfg) - } - - func postJSON( - path: String, - body: Request - ) async throws -> Response { - guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL } - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.httpBody = try JSONEncoder().encode(body) - - let (data, resp) = try await session.data(for: req) // 요청 보내는 코드 - print("서버 raw 응답:", String(data: data, encoding: .utf8) ?? "") - guard let http = resp as? HTTPURLResponse else { throw APIError.underlying(URLError(.badServerResponse)) } - guard (200..<300).contains(http.statusCode) else { - throw APIError.http(status: http.statusCode, data: data) - } - do { - return try JSONDecoder().decode(Response.self, from: data) - } catch { - // 성공 스펙이 래핑되어 있지 않거나 빈 바디일 수 있으니 필요시 분기 - throw APIError.decoding(error) - } - } -} diff --git a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift deleted file mode 100644 index bae95da..0000000 --- a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// RegisterAnonymous.swift -// today-s-sound -// -// Created by 하승연 on 10/30/25. -// - -import Foundation -import CryptoKit - -// 1) Request -struct RegisterAnonymousBody: Encodable { - let deviceSecret: String -} - -// 2) 성공 Response 래퍼 -struct SuccessEnvelope: Decodable { - let errorCode: String? - let message: String - let result: AnonymousResult -} - -struct AnonymousResult: Decodable { - let userId: String - // 서버가 추가로 키 같은 걸 준다면 여기에 옵셔널로: - // let api_key: String? -} - -// 3) 에러 Response -struct ErrorEnvelope: Decodable, Error { - let status: Int - let code: String - let message: String -} - -// 4) deviceSecret 생성 유틸 (32바이트 랜덤 → base64URL) -enum DeviceSecret { - static func generate() -> String { - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - // URL-safe base64 - let data = Data(bytes) - return data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/today-s-sound/Services/Security/Keychain.swift b/today-s-sound/Services/Security/Keychain.swift deleted file mode 100644 index 9be506a..0000000 --- a/today-s-sound/Services/Security/Keychain.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Keychain.swift -// today-s-sound -// -// Created by 하승연 on 10/30/25. -// - -import Foundation -import Security - -enum KeychainKey { - static let deviceSecret = "device_secret" - static let userId = "user_id" - static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) -} - -enum Keychain { - @discardableResult - static func set(_ value: Data, for key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: value, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 앱 재부팅 후에도 접근 - ] - SecItemDelete(query as CFDictionary) // 기존 값 제거 - let status = SecItemAdd(query as CFDictionary, nil) - return status == errSecSuccess - } - - static func get(for key: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess else { return nil } - return (item as? Data) - } - - @discardableResult - static func delete(for key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound - } - - // 문자열 편의 - @discardableResult - static func setString(_ value: String, for key: String) -> Bool { - set(Data(value.utf8), for: key) - } - - static func getString(for key: String) -> String? { - guard let data = get(for: key) else { return nil } - return String(data: data, encoding: .utf8) - } -} diff --git a/today-s-sound/Services/SpeechService.swift b/today-s-sound/Services/SpeechService.swift index 92a6c1c..d008250 100644 --- a/today-s-sound/Services/SpeechService.swift +++ b/today-s-sound/Services/SpeechService.swift @@ -1,25 +1,62 @@ import AVFoundation +import Combine import Foundation -class SpeechService { +class SpeechService: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { static let shared = SpeechService() private let synthesizer = AVSpeechSynthesizer() - private init() {} + // 재생 완료 알림을 위한 Publisher + let didFinishSpeaking = PassthroughSubject() + + @Published var isSpeaking: Bool = false + + override private init() { + super.init() + synthesizer.delegate = self + } func speak(text: String, language: String = "ko-KR") { + // 빈 텍스트 체크 + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + print("⚠️ SpeechService: 빈 텍스트는 재생할 수 없습니다") + return + } + let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: language) + utterance.rate = 0.5 // 말하는 속도 (0.0 ~ 1.0) // Stop any speaking in progress before starting a new one if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) + // 중단 이벤트는 didFinishSpeaking으로 전달하지 않음 } + isSpeaking = true synthesizer.speak(utterance) } func stop() { - synthesizer.stopSpeaking(at: .immediate) + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + isSpeaking = false + // stop() 호출 시에는 didFinishSpeaking 이벤트를 보내지 않음 (의도적 중단) + } + + // MARK: - AVSpeechSynthesizerDelegate + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + // isSpeaking이 true일 때만 완료 이벤트 전송 (중복 방지) + if isSpeaking { + isSpeaking = false + didFinishSpeaking.send() + } + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + isSpeaking = false + // 취소 시에는 didFinishSpeaking 이벤트를 보내지 않음 } }