diff --git a/.swiftlint.yml b/.swiftlint.yml index cae063a..3f8e0d8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,11 +1 @@ -disabled_rules: - - line_length - - trailing_whitespace -included: - - today-s-sound - - Tests -excluded: - - Pods - - Carthage - - DerivedData -reporter: "xcode" +데 \ No newline at end of file diff --git a/Makefile b/Makefile index 844a0d8..4e1d635 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # ===== Config ===== SCHEME ?= today-s-sound PROJECT ?= today-s-sound.xcodeproj -DEST ?= platform=iOS Simulator,name=iPhone 16 Pro +DEST ?= platform=iOS Simulator,name=iPhone 16 Pro,OS=18.6 CONFIG ?= Debug SDK ?= iphonesimulator diff --git a/today-s-sound.xcodeproj/project.pbxproj b/today-s-sound.xcodeproj/project.pbxproj index 8c883e2..6664c45 100644 --- a/today-s-sound.xcodeproj/project.pbxproj +++ b/today-s-sound.xcodeproj/project.pbxproj @@ -6,6 +6,13 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 9ADF457E2E950B6100E8B5A2 /* CombineMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF457D2E950B6100E8B5A2 /* CombineMoya */; }; + 9ADF45802E950B6100E8B5A2 /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF457F2E950B6100E8B5A2 /* Moya */; }; + 9ADF45822E950B6100E8B5A2 /* ReactiveMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */; }; + 9ADF45842E950B6100E8B5A2 /* RxMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADF45832E950B6100E8B5A2 /* RxMoya */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 259FC9672E890D7F001152B9 /* today-s-sound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "today-s-sound.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -36,6 +43,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9ADF45802E950B6100E8B5A2 /* Moya in Frameworks */, + 9ADF457E2E950B6100E8B5A2 /* CombineMoya in Frameworks */, + 9ADF45842E950B6100E8B5A2 /* RxMoya in Frameworks */, + 9ADF45822E950B6100E8B5A2 /* ReactiveMoya in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -78,6 +89,10 @@ ); name = "today-s-sound"; packageProductDependencies = ( + 9ADF457D2E950B6100E8B5A2 /* CombineMoya */, + 9ADF457F2E950B6100E8B5A2 /* Moya */, + 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */, + 9ADF45832E950B6100E8B5A2 /* RxMoya */, ); productName = "today-s-sound"; productReference = 259FC9672E890D7F001152B9 /* today-s-sound.app */; @@ -107,6 +122,9 @@ ); mainGroup = 259FC95E2E890D7E001152B9; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 259FC9682E890D7F001152B9 /* Products */; projectDirPath = ""; @@ -265,7 +283,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "today-s-sound/Info.plist"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -293,7 +311,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "today-s-sound/Info.plist"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -335,6 +353,40 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Moya/Moya"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 15.0.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9ADF457D2E950B6100E8B5A2 /* CombineMoya */ = { + isa = XCSwiftPackageProductDependency; + package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; + productName = CombineMoya; + }; + 9ADF457F2E950B6100E8B5A2 /* Moya */ = { + isa = XCSwiftPackageProductDependency; + package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; + productName = Moya; + }; + 9ADF45812E950B6100E8B5A2 /* ReactiveMoya */ = { + isa = XCSwiftPackageProductDependency; + package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; + productName = ReactiveMoya; + }; + 9ADF45832E950B6100E8B5A2 /* RxMoya */ = { + isa = XCSwiftPackageProductDependency; + package = 9ADF457C2E950ADB00E8B5A2 /* XCRemoteSwiftPackageReference "Moya" */; + productName = RxMoya; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 259FC95F2E890D7E001152B9 /* Project object */; } diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index e3f6917..127aaf5 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct TodaySSoundApp: App { var body: some Scene { WindowGroup { - HomeView() + MainView() } } } diff --git a/today-s-sound/Core/Auth/UserSession.swift b/today-s-sound/Core/Auth/UserSession.swift new file mode 100644 index 0000000..f00458c --- /dev/null +++ b/today-s-sound/Core/Auth/UserSession.swift @@ -0,0 +1,12 @@ +import Foundation + +final class UserSession: ObservableObject { + @Published var accessToken: String = "" + @Published var refreshToken: String = "" + @Published var autoLogin: Bool = true + + func clear() { + accessToken = "" + refreshToken = "" + } +} diff --git a/today-s-sound/Core/Network/APITargetType.swift b/today-s-sound/Core/Network/APITargetType.swift new file mode 100644 index 0000000..f5f1f64 --- /dev/null +++ b/today-s-sound/Core/Network/APITargetType.swift @@ -0,0 +1,13 @@ +import Foundation +import Moya + +protocol APITargetType: TargetType {} + +extension APITargetType { + var baseURL: URL { + guard let url = URL(string: Config.baseURL) else { + fatalError("Invalid Base URL") + } + return url + } +} diff --git a/today-s-sound/Core/Network/Config.swift b/today-s-sound/Core/Network/Config.swift new file mode 100644 index 0000000..0ef89da --- /dev/null +++ b/today-s-sound/Core/Network/Config.swift @@ -0,0 +1,11 @@ +import Foundation + +enum Config { + static var baseURL: String { + #if DEBUG + return "https://dev-your-api-url.com" + #else + return "https://your-api-url.com" + #endif + } +} diff --git a/today-s-sound/Core/Network/NetworkError.swift b/today-s-sound/Core/Network/NetworkError.swift new file mode 100644 index 0000000..7090e7b --- /dev/null +++ b/today-s-sound/Core/Network/NetworkError.swift @@ -0,0 +1,9 @@ +import Foundation + +enum NetworkError: Error { + case invalidURL + case requestFailed(Error) + case decodingFailed(Error) + case serverError(statusCode: Int) + case unknown +} diff --git a/today-s-sound/Core/Network/Provider/AuthInterceptor.swift b/today-s-sound/Core/Network/Provider/AuthInterceptor.swift new file mode 100644 index 0000000..4048a34 --- /dev/null +++ b/today-s-sound/Core/Network/Provider/AuthInterceptor.swift @@ -0,0 +1,108 @@ +import Alamofire +import Foundation +import Moya + +final class AuthInterceptor: RequestInterceptor { + private let userSession: UserSession + + private lazy var refreshProvider: MoyaProvider = { + let session = Session(configuration: .default) // no interceptor + return MoyaProvider(session: session) + }() + + private var isRefreshing = false + private var waiting: [(RetryResult) -> Void] = [] + private let lock = NSLock() + + init(userSession: UserSession) { self.userSession = userSession } + + func adapt(_ urlRequest: URLRequest, + for session: Session, + completion: @escaping (Result) -> Void) + { + var req = urlRequest + let path = req.url?.path ?? "" + + let bypass: Set = [ + "/api/auth/login", + "/api/auth/refresh" + ] + + if bypass.contains(path) { + if req.value(forHTTPHeaderField: "Authorization") != nil { + req.setValue(nil, forHTTPHeaderField: "Authorization") + } + return completion(.success(req)) + } + + if !userSession.accessToken.isEmpty { + req.setValue("Bearer \(userSession.accessToken)", forHTTPHeaderField: "Authorization") + } + completion(.success(req)) + } + + func retry(_ request: Request, + for session: Session, + dueTo error: Error, + completion: @escaping (RetryResult) -> Void) + { + let path = request.request?.url?.path ?? "nil" + let status = request.response?.statusCode ?? -1 + + if path == "/api/auth/refresh" { + completion(.doNotRetry); return + } + + guard status == 401 || status == 403 || status == 419 else { + completion(.doNotRetry); return + } + + guard userSession.autoLogin, !userSession.refreshToken.isEmpty else { + DispatchQueue.main.async { self.userSession.clear() } + completion(.doNotRetry) + return + } + + lock.lock() + if isRefreshing { + waiting.append(completion) + lock.unlock() + return + } + isRefreshing = true + waiting.append(completion) + lock.unlock() + + refreshProvider.request(.refresh(refreshToken: userSession.refreshToken)) { [weak self] result in + guard let self else { return } + var queuedResult: RetryResult = .doNotRetry + + switch result { + case let .success(res): + if (200 ..< 300).contains(res.statusCode), + let dto = try? JSONDecoder().decode(RefreshResponseDTO.self, from: res.data), + dto.isSuccess, let data = dto.data + { + DispatchQueue.main.async { + self.userSession.accessToken = data.accessToken + self.userSession.refreshToken = data.refreshToken + } + queuedResult = .retry + } else { + DispatchQueue.main.async { self.userSession.clear() } + } + + case .failure: + DispatchQueue.main.async { self.userSession.clear() } + } + + lock.lock() + let queued = waiting + waiting.removeAll() + isRefreshing = false + lock.unlock() + + queued.forEach { $0(queuedResult) } + } + } +} diff --git a/today-s-sound/Core/Network/Provider/NetworkKit.swift b/today-s-sound/Core/Network/Provider/NetworkKit.swift new file mode 100644 index 0000000..d1fbe67 --- /dev/null +++ b/today-s-sound/Core/Network/Provider/NetworkKit.swift @@ -0,0 +1,13 @@ +import Alamofire +import Foundation +import Moya + +enum NetworkKit { + static func provider(userSession: UserSession, + plugins: [PluginType] = []) -> MoyaProvider + { + let interceptor = AuthInterceptor(userSession: userSession) + let session = Session(interceptor: interceptor) + return MoyaProvider(session: session, plugins: plugins) + } +} diff --git a/today-s-sound/Core/Network/Service/APIService.swift b/today-s-sound/Core/Network/Service/APIService.swift new file mode 100644 index 0000000..40ae6c7 --- /dev/null +++ b/today-s-sound/Core/Network/Service/APIService.swift @@ -0,0 +1,42 @@ +import Combine +import CombineMoya +import Foundation +import Moya + +protocol APIServiceType { + func request(_ target: some TargetType) -> AnyPublisher + func createAnonymous(deviceSecret: String) -> AnyPublisher +} + +class APIService: APIServiceType { + private let anonymousProvider: MoyaProvider + + init(userSession: UserSession = UserSession()) { + anonymousProvider = NetworkKit.provider(userSession: userSession) + } + + func request(_ target: some TargetType) -> AnyPublisher { + Fail(error: .requestFailed(NSError(domain: "NotImplemented", code: -1))).eraseToAnyPublisher() + } + + func createAnonymous(deviceSecret: String) -> AnyPublisher { + anonymousProvider.requestPublisher(.createAnonymous(deviceSecret: deviceSecret)) + .tryMap { response -> Data in + guard (200 ... 299).contains(response.statusCode) else { + throw NetworkError.serverError(statusCode: response.statusCode) + } + return response.data + } + .decode(type: AnonymousUserResponse.self, decoder: JSONDecoder()) + .mapError { error -> NetworkError in + 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/AnonymousAPI.swift b/today-s-sound/Core/Network/Targets/AnonymousAPI.swift new file mode 100644 index 0000000..89ffc07 --- /dev/null +++ b/today-s-sound/Core/Network/Targets/AnonymousAPI.swift @@ -0,0 +1,26 @@ +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/AuthAPITarget.swift b/today-s-sound/Core/Network/Targets/AuthAPITarget.swift new file mode 100644 index 0000000..9140e7c --- /dev/null +++ b/today-s-sound/Core/Network/Targets/AuthAPITarget.swift @@ -0,0 +1,36 @@ +import Foundation +import Moya + +enum AuthAPITarget { + case refresh(refreshToken: String) +} + +extension AuthAPITarget: APITargetType { + var path: String { + switch self { + case .refresh: + "/api/auth/refresh" + } + } + + var method: Moya.Method { .post } + + var task: Task { + switch self { + case let .refresh(refreshToken: refreshToken): + .requestParameters(parameters: ["refreshToken": refreshToken], encoding: JSONEncoding.default) + } + } + + var headers: [String: String]? { ["Content-Type": "application/json"] } +} + +struct RefreshResponseDTO: Codable { + let isSuccess: Bool + let data: RefreshTokensDTO? +} + +struct RefreshTokensDTO: Codable { + let accessToken: String + let refreshToken: String +} diff --git a/today-s-sound/Core/SpeechService.swift b/today-s-sound/Core/SpeechService.swift new file mode 100644 index 0000000..92a6c1c --- /dev/null +++ b/today-s-sound/Core/SpeechService.swift @@ -0,0 +1,25 @@ +import AVFoundation +import Foundation + +class SpeechService { + static let shared = SpeechService() + private let synthesizer = AVSpeechSynthesizer() + + private init() {} + + func speak(text: String, language: String = "ko-KR") { + let utterance = AVSpeechUtterance(string: text) + utterance.voice = AVSpeechSynthesisVoice(language: language) + + // Stop any speaking in progress before starting a new one + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + + synthesizer.speak(utterance) + } + + func stop() { + synthesizer.stopSpeaking(at: .immediate) + } +} diff --git a/today-s-sound/Data/Models/Alert.swift b/today-s-sound/Data/Models/Alert.swift new file mode 100644 index 0000000..ef17b00 --- /dev/null +++ b/today-s-sound/Data/Models/Alert.swift @@ -0,0 +1,9 @@ +import Foundation + +struct Alert: Codable, Identifiable { + let id: UUID + let title: String + let content: String + let date: Date + let isUrgent: Bool +} diff --git a/today-s-sound/Data/Models/AnonymousUser.swift b/today-s-sound/Data/Models/AnonymousUser.swift new file mode 100644 index 0000000..a55ed57 --- /dev/null +++ b/today-s-sound/Data/Models/AnonymousUser.swift @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..647634a --- /dev/null +++ b/today-s-sound/Data/Models/Subscription.swift @@ -0,0 +1,7 @@ +import Foundation + +struct Subscription: Codable, Identifiable { + let id: UUID + let name: String + let url: String +} diff --git a/today-s-sound/Features/Home/HomeView.swift b/today-s-sound/Features/Home/HomeView.swift deleted file mode 100644 index 25a2b98..0000000 --- a/today-s-sound/Features/Home/HomeView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// HomeView.swift -// today-s-sound -// -// Created by 하승연 on 9/28/25. -// - -import SwiftUI - -struct HomeView: View { - var body: some View { - VStack { - Group { - Text("오늘의 소리") - .font(.KoddiBold56) - .shadow(color: .black.opacity(0.25), radius: 2, x: 0, y: 4) - } - .foregroundStyle(Color.black) - } - } -} - -#Preview { - HomeView() -} diff --git a/today-s-sound/Info.plist b/today-s-sound/Info.plist index 8748d7a..2155055 100644 --- a/today-s-sound/Info.plist +++ b/today-s-sound/Info.plist @@ -4,9 +4,9 @@ UIAppFonts - KoddiUDOnGothic-ExtraBold - KoddiUDOnGothic-Regular - KoddiUDOnGothic-Bold + KoddiUDOnGothic-ExtraBold.otf + KoddiUDOnGothic-Regular.otf + KoddiUDOnGothic-Bold.otf diff --git a/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift new file mode 100644 index 0000000..fd1699c --- /dev/null +++ b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift @@ -0,0 +1,33 @@ +// +// ScreenMainTitle.swift +// today-s-sound +// +// Created by 박지현 on 10/8/25. +// + +import SwiftUI + +/// 공통 타이틀 컴포넌트. 다양한 화면에서 재사용 가능 +struct ScreenMainTitle: View { + let text: String + let colorScheme: ColorScheme + + var body: some View { + Text(text) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(Color.text(colorScheme)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.bottom, 16) + } +} + +struct ScreenSectionTitle_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + ScreenMainTitle(text: "최근 알림", colorScheme: .light) + ScreenMainTitle(text: "구독 설정", colorScheme: .light) + } + .padding() + } +} diff --git a/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift new file mode 100644 index 0000000..5065b2d --- /dev/null +++ b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift @@ -0,0 +1,31 @@ +// +// ScreenSubTitle.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct ScreenSubTitle: View { + let text: String + let colorScheme: ColorScheme + + var body: some View { + Text(text) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(colorScheme == .dark ? .white : .primaryGreen) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.bottom, 16) + } +} + +struct ScreenTitle_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 0) { + ScreenSubTitle(text: "새 웹페이지 추가", colorScheme: .light) + ScreenSubTitle(text: "새 웹페이지 추가", colorScheme: .dark) + } + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift new file mode 100644 index 0000000..3afee2d --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct AddSubscriptionView: View { + @StateObject private var viewModel = AddSubscriptionViewModel() + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + Color.background(colorScheme) + .ignoresSafeArea() + + VStack(spacing: 0) { + HeaderBar(colorScheme: colorScheme, onClose: { dismiss() }) + + ScreenSubTitle(text: "새 웹페이지 추가", colorScheme: colorScheme) + + ScrollView { + VStack(spacing: 24) { + InputFieldSection( + title: "웹사이트 URL", + placeholder: "https://www.example.com", + description: "모니터링 할 웹페이지 URL을 입력하세요.", + text: $viewModel.urlText, + colorScheme: colorScheme + ) + + InputFieldSection( + title: "웹페이지 별명", + placeholder: "동국대학교 공지사항", + description: "웹 페이지를 식별할 명칭을 입력하세요.", + text: $viewModel.nameText, + colorScheme: colorScheme + ) + + InputFieldSection( + title: "키워드 필터", + placeholder: "장학금, 교직, 학생회", + description: "관심 키워드가 포함된 내용을 걸러낼 필요가 있으면 입력하세요.", + text: $viewModel.keywordsText, + colorScheme: colorScheme, + additionalContent: { + AnyView( + HStack(spacing: 8) { + KeywordBadge(text: "장학금", colorScheme: colorScheme) + KeywordBadge(text: "교직부공지사항", colorScheme: colorScheme) + } + ) + } + ) + + UrgentToggleRow(isOn: $viewModel.isUrgent, colorScheme: colorScheme) + + // 하단 버튼 + Button(action: { + dismiss() + }, label: { + Text("등록 승인 요청") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.primaryGreen90) + ) + }) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 16) + } + } + } + } +} + +struct AddSubscriptionView_Previews: PreviewProvider { + static var previews: some View { + AddSubscriptionView() + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift new file mode 100644 index 0000000..f5f1a77 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -0,0 +1,9 @@ +import Combine +import Foundation + +class AddSubscriptionViewModel: ObservableObject { + @Published var urlText: String = "" + @Published var nameText: String = "" + @Published var keywordsText: String = "" + @Published var isUrgent: Bool = false +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift new file mode 100644 index 0000000..f9664a6 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift @@ -0,0 +1,35 @@ +// +// HeaderBar.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct HeaderBar: View { + let colorScheme: ColorScheme + let onClose: () -> Void + + var body: some View { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.title2) + .foregroundColor(Color.text(colorScheme)) + } + Spacer() + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } +} + +struct HeaderBar_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 0) { + HeaderBar(colorScheme: .light, onClose: {}) + HeaderBar(colorScheme: .dark, onClose: {}) + } + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift new file mode 100644 index 0000000..446872b --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift @@ -0,0 +1,93 @@ +// +// InputFieldSection.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct InputFieldSection: View { + let title: String + let placeholder: String + let description: String + @Binding var text: String + let colorScheme: ColorScheme + let additionalContent: (() -> AnyView)? + + init( + title: String, + placeholder: String, + description: String, + text: Binding, + colorScheme: ColorScheme, + additionalContent: (() -> AnyView)? = nil + ) { + self.title = title + self.placeholder = placeholder + self.description = description + _text = text + self.colorScheme = colorScheme + self.additionalContent = additionalContent + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.text(colorScheme)) +// .background( +// RoundedRectangle(cornerRadius: 12) +// .fill(Color.secondaryBackground(colorScheme)) +// ) + + TextField(placeholder, text: $text) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondaryBackground(colorScheme)) + .stroke(Color.border(colorScheme), lineWidth: 1) + ) + .foregroundColor(Color.text(colorScheme)) + + if let additionalContent { + additionalContent() + } + + Text(description) + .font(.system(size: 13)) + .foregroundColor(Color.secondaryText(colorScheme)) + } + } +} + +struct InputFieldSection_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 24) { + InputFieldSection( + title: "웹사이트 URL", + placeholder: "https://www.example.com", + description: "모니터링 할 웹페이지 URL을 입력하세요.", + text: .constant(""), + colorScheme: .light + ) + + InputFieldSection( + title: "키워드 필터", + placeholder: "장학금, 교직, 학생회", + description: "관심 키워드가 포함된 내용을 걸러낼 필요가 있으면 입력하세요.", + text: .constant(""), + colorScheme: .dark, + additionalContent: { + AnyView( + HStack(spacing: 8) { + KeywordBadge(text: "장학금", colorScheme: .dark) + KeywordBadge(text: "교직부공지사항", colorScheme: .dark) + } + ) + } + ) + } + .padding() + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift new file mode 100644 index 0000000..3aebc41 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift @@ -0,0 +1,35 @@ +// +// KeywordBadge.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct KeywordBadge: View { + let text: String + let colorScheme: ColorScheme + + var body: some View { + Text(text) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.text(colorScheme)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primaryGreen20) + ) + } +} + +struct KeywordBadge_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + KeywordBadge(text: "장학금", colorScheme: .light) + KeywordBadge(text: "교직부공지사항", colorScheme: .dark) + } + .padding() + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift new file mode 100644 index 0000000..4b1a99e --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift @@ -0,0 +1,35 @@ +// +// UrgentToggleRow.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct UrgentToggleRow: View { + @Binding var isOn: Bool + let colorScheme: ColorScheme + + var body: some View { + HStack { + Text("긴급 알림으로 설정") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.text(colorScheme)) + Spacer() + Toggle("", isOn: $isOn) + .labelsHidden() + } + .padding() + } +} + +struct UrgentToggleRow_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + UrgentToggleRow(isOn: .constant(true), colorScheme: .light) + UrgentToggleRow(isOn: .constant(false), colorScheme: .dark) + } + .padding() + } +} diff --git a/today-s-sound/Features/Home/HomeModel.swift b/today-s-sound/Presentation/Features/Main/Home/HomeModel.swift similarity index 100% rename from today-s-sound/Features/Home/HomeModel.swift rename to today-s-sound/Presentation/Features/Main/Home/HomeModel.swift diff --git a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift new file mode 100644 index 0000000..01954a3 --- /dev/null +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -0,0 +1,101 @@ +// +// HomeView.swift +// today-s-sound +// +// Created by 하승연 on 9/28/25. +// + +import SwiftUI + +struct HomeView: View { + @StateObject private var viewModel = MainViewModel() + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + // 다크모드에 따라 배경색 변경 + Color.background(colorScheme) + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + // 오늘의 소리 타이틀 + Text("오늘의 소리") + .font(.KoddiBold56) + .foregroundStyle(Color.text(colorScheme)) + .shadow(color: .black25, radius: 2, x: 0, y: 4) + .padding(.bottom, 60) + + Button( + action: { + if let first = viewModel.recentAlerts.first { + viewModel.playAlert(first) + } + }, + label: { + Image(systemName: "play.fill") + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + .foregroundColor(Color.primaryGreen90) + .padding(40) + } + ) + .padding(.bottom, 60) + + // 속도 조절 + HStack(spacing: 48) { + Button( + action: { viewModel.decreaseRate() }, + label: { + Image(systemName: "minus") + .font(.system(size: 35, weight: .medium)) + .foregroundColor(colorScheme == .dark ? .white : Color.primaryGreen90) + } + ) + + Text(String(format: "%.1f x", viewModel.playbackRate)) + .font(.system(size: 48, weight: .bold)) + .foregroundColor(Color.text(colorScheme)) + .monospacedDigit() + .frame(minWidth: 100) + + Button( + action: { viewModel.increaseRate() }, + label: { + Image(systemName: "plus") + .font(.system(size: 35, weight: .medium)) + .foregroundColor(colorScheme == .dark ? .white : Color.primaryGreen90) + } + ) + } + + Spacer() + + VStack(spacing: 16) { + Text("현재 카테고리") + .font(.system(size: 28)) + .foregroundColor(Color.secondaryText(colorScheme)) + + Text(viewModel.currentCategoryName) + .font(.system(size: 32, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .frame(width: 340, height: 85) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.primaryGreen90) + ) + .foregroundColor(.white) + } + .padding(.bottom, 32) + } + } + } +} + +#Preview { + HomeView() +} diff --git a/today-s-sound/Features/Home/HomeViewModel.swift b/today-s-sound/Presentation/Features/Main/Home/HomeViewModel.swift similarity index 100% rename from today-s-sound/Features/Home/HomeViewModel.swift rename to today-s-sound/Presentation/Features/Main/Home/HomeViewModel.swift diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift new file mode 100644 index 0000000..ea5e415 --- /dev/null +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct MainView: View { + @StateObject private var viewModel = MainViewModel() + + var body: some View { + TabView { + HomeView() + .tabItem { + Image(systemName: "house.fill") + Text("메인") + } + + NotificationListView() + .tabItem { + Image(systemName: "bell.fill") + Text("알림") + } + + SubscriptionListView() + .tabItem { + Image(systemName: "bookmark.fill") + Text("구독") + } + } + } +} + +struct MainView_Previews: PreviewProvider { + static var previews: some View { + MainView() + } +} diff --git a/today-s-sound/Presentation/Features/Main/MainViewModel.swift b/today-s-sound/Presentation/Features/Main/MainViewModel.swift new file mode 100644 index 0000000..68c87e4 --- /dev/null +++ b/today-s-sound/Presentation/Features/Main/MainViewModel.swift @@ -0,0 +1,33 @@ +import Combine +import Foundation + +class MainViewModel: ObservableObject { + @Published var playbackRate: Double = 1.0 + @Published var currentCategoryName: String = "동국대학교 공지사항" + @Published var recentAlerts: [Alert] = [] + + private var cancellables = Set() + + init() { + loadMockAlerts() + } + + func increaseRate() { + playbackRate = min(2.0, (playbackRate * 10 + 1).rounded() / 10) + } + + func decreaseRate() { + playbackRate = max(0.5, (playbackRate * 10 - 1).rounded() / 10) + } + + func playAlert(_ alert: Alert) { + SpeechService.shared.speak(text: alert.title) + } + + private func loadMockAlerts() { + recentAlerts = [ + Alert(id: UUID(), title: "일이삼사오육칠팔", content: "공지 내용 예시", date: Date().addingTimeInterval(-7200), isUrgent: true), + Alert(id: UUID(), title: "잡코리아 채용 공고", content: "채용 소식", date: Date().addingTimeInterval(-10800), isUrgent: false) + ] + } +} diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift new file mode 100644 index 0000000..3831d06 --- /dev/null +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -0,0 +1,101 @@ +// +// AlertCardView.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct AlertCardView: View { + let alert: Alert + let colorScheme: ColorScheme + + private var cardColor: Color { + alert.isUrgent ? .urgentPink : .primaryGreen + } + + private var buttonBackgroundColor: Color { + Color.buttonBackground(colorScheme) + } + + var body: some View { + VStack(spacing: 20) { + // 상단: 타이틀과 아이콘 + HStack(alignment: .top, spacing: 12) { + Image(systemName: alert.isUrgent ? "bell.fill" : "doc.fill") + .font(.system(size: 24)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 8) { + Text(alert.title) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Text("2시간 전") + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.9)) + } + + Spacer() + } + + // 하단: 음성으로 듣기 버튼 + Button(action: { + SpeechService.shared.speak(text: alert.title) + }, label: { + HStack(spacing: 8) { + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 18)) + .foregroundStyle(Color.text(colorScheme)) + Text("음성으로 듣기") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color.text(colorScheme)) + } + .foregroundColor(Color.buttonBackground(colorScheme)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(buttonBackgroundColor) + ) + }) + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(cardColor) + .shadow(color: .black15, radius: 8, x: 0, y: 4) + ) + } +} + +struct AlertCardView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + AlertCardView( + alert: Alert( + id: UUID(), + title: "일이삼사오육칠팔", + content: "공지 내용 예시", + date: Date().addingTimeInterval(-7200), + isUrgent: true + ), + colorScheme: .light + ) + + AlertCardView( + alert: Alert( + id: UUID(), + title: "잡코리아 채용 공고", + content: "채용 소식", + date: Date().addingTimeInterval(-10800), + isUrgent: false + ), + colorScheme: .dark + ) + } + .padding() + } +} diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift new file mode 100644 index 0000000..ad8ae0a --- /dev/null +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct NotificationListView: View { + @StateObject private var viewModel = NotificationListViewModel() + @Environment(\.colorScheme) var colorScheme + + var body: some View { + NavigationView { + ZStack { + Color.background(colorScheme) + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) + + ScrollView { + VStack(spacing: 16) { + ForEach(viewModel.alerts) { alert in + AlertCardView(alert: alert, colorScheme: colorScheme) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } + .navigationBarHidden(true) + } + } +} + +struct NotificationListView_Previews: PreviewProvider { + static var previews: some View { + NotificationListView() + } +} diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift new file mode 100644 index 0000000..19c4a46 --- /dev/null +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift @@ -0,0 +1,36 @@ +import Combine +import Foundation + +class NotificationListViewModel: ObservableObject { + @Published var alerts: [Alert] = [] + + init() { + loadMock() + } + + 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 + ) + ] + } +} diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift new file mode 100644 index 0000000..2ab3db1 --- /dev/null +++ b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift @@ -0,0 +1,68 @@ +import Combine +import SwiftUI + +final class AnonymousTestViewModel: ObservableObject { + @Published var deviceSecret: String = UUID().uuidString + @Published var userId: String = "" + @Published var log: String = "" + + private let api = APIService() + private var cancellables: Set = [] + + func createAnonymous() { + log = "요청 중..." + api.createAnonymous(deviceSecret: deviceSecret) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: + break + case let .failure(error): + self?.log = "실패: \(error)" + } + } receiveValue: { [weak self] response in + self?.userId = response.result.userId + self?.log = "성공: user_id=\(response.result.userId)" + } + .store(in: &cancellables) + } +} + +struct AnonymousTestView: View { + @StateObject private var viewModel = AnonymousTestViewModel() + + var body: some View { + Form { + Section(header: Text("디바이스 시크릿")) { + TextField("deviceSecret", text: $viewModel.deviceSecret) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Section(header: Text("동작")) { + Button("익명 사용자 생성") { + viewModel.createAnonymous() + } + } + if !viewModel.userId.isEmpty { + Section(header: Text("결과 user_id")) { + Text(viewModel.userId) + .font(.system(.body, design: .monospaced)) + } + } + Section(header: Text("로그")) { + Text(viewModel.log) + .font(.footnote) + .foregroundColor(.gray) + } + } + .navigationTitle("익명 생성 테스트") + } +} + +#if DEBUG + struct AnonymousTestView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { AnonymousTestView() } + } + } +#endif diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift new file mode 100644 index 0000000..ca739ce --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift @@ -0,0 +1,35 @@ +// +// AddSubscriptionButton.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct AddSubscriptionButton: View { + let colorScheme: ColorScheme + let onTap: () -> Void + + var body: some View { + VStack(spacing: 12) { + Button(action: onTap) { + HStack { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + Text("새로운 웹페이지 추가") + .font(.system(size: 24, weight: .semibold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.primaryGreen90) + ) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift new file mode 100644 index 0000000..1004f8e --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift @@ -0,0 +1,24 @@ +// +// EmptyStateView.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct EmptyStateView: View { + let message: String + let colorScheme: ColorScheme + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 40, weight: .regular)) + .foregroundColor(Color.secondaryText(colorScheme)) + Text(message) + .foregroundColor(Color.secondaryText(colorScheme)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift new file mode 100644 index 0000000..2c05c4b --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift @@ -0,0 +1,35 @@ +// +// StatusBadge.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct StatusBadge: View { + let text: String + let colorScheme: ColorScheme + + var body: some View { + Text(text) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(colorScheme == .dark ? .white : .primaryGreen) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color.badgeGreen) + ) + } +} + +struct StatusBadge_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + StatusBadge(text: "등록중", colorScheme: .light) + StatusBadge(text: "일이삼사", colorScheme: .dark) + } + .padding() + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift new file mode 100644 index 0000000..8dc1de5 --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -0,0 +1,47 @@ +// +// SubscriptionCardView.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct SubscriptionCardView: View { + let subscription: Subscription + let colorScheme: ColorScheme + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(subscription.name) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(Color.text(colorScheme)) + + Text(subscription.url) + .font(.system(size: 13)) + .foregroundColor(Color.secondaryText(colorScheme)) + .lineLimit(1) + + HStack(spacing: 8) { + StatusBadge(text: "등록중", colorScheme: colorScheme) + StatusBadge(text: "일이삼사", colorScheme: colorScheme) + } + } + + Spacer() + + Button(action: {}, label: { + Image(systemName: "bell") + .font(.system(size: 40)) + .foregroundColor(.green) + }) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondaryBackground(colorScheme)) + .shadow(color: .black5, radius: 4, x: 0, y: 2) + ) + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift new file mode 100644 index 0000000..34846ac --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift @@ -0,0 +1,29 @@ +// +// SubscriptionsListSection.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +struct SubscriptionsListSection: View { + let subscriptions: [Subscription] + let colorScheme: ColorScheme + + var body: some View { + if subscriptions.isEmpty { + EmptyStateView(message: "구독 중인 페이지가 없어요.", colorScheme: colorScheme) + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(subscriptions) { subscription in + SubscriptionCardView(subscription: subscription, colorScheme: colorScheme) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift new file mode 100644 index 0000000..a10f7fb --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct SubscriptionListView: View { + @StateObject private var viewModel = SubscriptionListViewModel() + @Environment(\.colorScheme) var colorScheme + @State private var showAddSubscription = false + + var body: some View { + NavigationView { + ZStack { + Color.background(colorScheme) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 12) { + Spacer() + ScreenMainTitle(text: "구독 설정", colorScheme: colorScheme) + ScreenSubTitle(text: "구독 중인 페이지", colorScheme: colorScheme) + + SubscriptionsListSection( + subscriptions: viewModel.subscriptions, + colorScheme: colorScheme + ) + + AddSubscriptionButton(colorScheme: colorScheme) { + showAddSubscription = true + } + } + } + .navigationBarHidden(true) + } + .sheet(isPresented: $showAddSubscription) { + AddSubscriptionView() + } + } +} + +struct SubscriptionListView_Previews: PreviewProvider { + static var previews: some View { + SubscriptionListView() + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift new file mode 100644 index 0000000..c3cb4ac --- /dev/null +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -0,0 +1,17 @@ +import Combine +import Foundation + +class SubscriptionListViewModel: ObservableObject { + @Published var subscriptions: [Subscription] = [] + + init() { + loadMock() + } + + private func loadMock() { + subscriptions = [ + Subscription(id: UUID(), name: "동국대학교 공지사항", url: "https://www.dongguk.edu"), + Subscription(id: UUID(), name: "네이버 연합뉴스 속보", url: "https://news.naver.com") + ] + } +} diff --git a/today-s-sound/Resources/Colors.swift b/today-s-sound/Resources/Colors.swift new file mode 100644 index 0000000..dc07531 --- /dev/null +++ b/today-s-sound/Resources/Colors.swift @@ -0,0 +1,77 @@ +// +// Colors.swift +// today-s-sound +// +// Created by Assistant on 12/19/24. +// + +import SwiftUI + +// MARK: - Brand Colors + +extension Color { + /// 메인 브랜드 그린 색상 (Primary Green) + static let primaryGreen = Color(red: 0 / 255, green: 223 / 255, blue: 119 / 255) + + /// 긴급 알림 핑크 색상 (Urgent Pink) + static let urgentPink = Color(red: 1.0, green: 0.298, blue: 0.729, opacity: 1.0) + + /// 배지 배경 그린 색상 (Badge Background Green) + static let badgeGreen = Color(red: 52 / 255, green: 199 / 255, blue: 89 / 255, opacity: 0.16) + + /// 카드 그레이 색상 (Card Grey) + static let cardGrey = Color(red: 245 / 255, green: 245 / 255, blue: 245 / 255) +} + +// MARK: - Semantic Colors + +extension Color { + /// 배경색 (다크모드 대응) + static func background(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? .black : .white + } + + /// 보조 배경색 (다크모드 대응) + static func secondaryBackground(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95) + } + + /// 텍스트 색상 (다크모드 대응) + static func text(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? .white : .black + } + + /// 보조 텍스트 색상 (다크모드 대응) + static func secondaryText(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? .white.opacity(0.6) : .black.opacity(0.6) + } + + /// 테두리 색상 (다크모드 대응) + static func border(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? .white.opacity(0.2) : .gray.opacity(0.3) + } + + /// 버튼 배경색 (다크모드 대응) + static func buttonBackground(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? .black : .white + } +} + +// MARK: - Opacity Variants + +extension Color { + /// Primary Green with 90% opacity + static let primaryGreen90 = Color.primaryGreen.opacity(0.9) + + /// Primary Green with 20% opacity + static let primaryGreen20 = Color.primaryGreen.opacity(0.2) + + /// Black with 15% opacity + static let black15 = Color.black.opacity(0.15) + + /// Black with 25% opacity + static let black25 = Color.black.opacity(0.25) + + /// Black with 5% opacity + static let black5 = Color.black.opacity(0.05) +}