From 634d713165d670d1da139c1fc7164f7cef1c2982 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sun, 30 Nov 2025 22:19:29 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Feat/#16]=20UI=20=EA=B0=9C=EC=84=A0=20-=20?= =?UTF-8?q?addSubscriptionList=20=EA=B0=9C=EC=84=A0=20-=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=84=A4=EC=A0=95=20=EB=B7=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20=ED=95=98=EB=8B=A8=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Component/AddSubscriptionButton.swift | 79 ++++ .../AddSubscription/AddSubscriptionView.swift | 397 +++++++++--------- .../AddSubscriptionViewModel.swift | 98 ++--- .../AddSubscription/Component/HeaderBar.swift | 35 -- .../Component/InputFieldSection.swift | 132 ++++-- .../Component/KeywordBadge.swift | 35 -- .../Component/KeywordCheckboxRow.swift | 8 +- .../Component/SheetHandler.swift | 36 ++ .../Component/UrgentToggleRow.swift | 35 -- .../Features/Feed/FeedModel.swift | 75 +++- .../Presentation/Features/Feed/FeedView.swift | 127 +++++- .../Features/Feed/FeedViewModel.swift | 2 + .../Component/AddSubscriptionButton.swift | 42 -- .../Component/StatusBadge.swift | 2 +- .../SubscriptionListView.swift | 14 +- today-s-sound/Resources/Fonts.swift | 4 + 16 files changed, 626 insertions(+), 495 deletions(-) create mode 100644 today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift delete mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift delete mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift create mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift delete mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift delete mode 100644 today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift diff --git a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift new file mode 100644 index 0000000..b2b4383 --- /dev/null +++ b/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift @@ -0,0 +1,79 @@ +// +// AddSubscriptionButton.swift +// today-s-sound +// +// 공통 액션 버튼 컴포넌트 +// + +import SwiftUI + +struct AddSubscriptionButton: View { + /// 버튼에 표시할 텍스트 (예: "등록 승인 요청", "저장하기") + let title: String + + /// 컬러 스킴 (라이트/다크에 따라 글자색만 바뀜) + let colorScheme: ColorScheme + + /// 버튼 활성/비활성 여부 + let isEnabled: Bool + + /// 버튼 탭 액션 + let action: () -> Void + + private var textColor: Color { + // 배경색은 그대로 두고, 글자색만 모드에 따라 변경 + colorScheme == .dark ? .black : .white + } + + var body: some View { + Button(action: { + if isEnabled { + action() + } + }) { + Text(title) + .font(.KoddiExtraBold32) + .foregroundColor(textColor) + .frame(maxWidth: .infinity) + .frame(height: 82) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isEnabled ? Color.primaryGreen : Color.primaryGreen.opacity(0.4)) + ) + } + .disabled(!isEnabled) + } +} + +// MARK: - Preview + +struct AddSubscriptionButton_Previews: PreviewProvider { + static var previews: some View { + Group { + // Light Mode + AddSubscriptionButton( + title: "등록 승인 요청", + colorScheme: .light, + isEnabled: true, + action: {} + ) + .previewDisplayName("Light Mode") + .previewLayout(.sizeThatFits) + .padding() + .background(Color.background(.light)) + + // Dark Mode (배경색은 동일, 글자색만 검정) + AddSubscriptionButton( + title: "등록 승인 요청", + colorScheme: .dark, + isEnabled: true, + action: {} + ) + .previewDisplayName("Dark Mode") + .previewLayout(.sizeThatFits) + .padding() + .background(Color.background(.light)) + .preferredColorScheme(.dark) + } + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index 3476bb8..85afcab 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -9,112 +9,140 @@ struct AddSubscriptionView: View { ZStack { Color.background(colorScheme) .ignoresSafeArea() + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } VStack(spacing: 0) { - HeaderBar(colorScheme: colorScheme, onClose: { dismiss() }) + // 상단 핸들 바 (X 대신) + SheetHandleBar(colorScheme: colorScheme) + // 화면 제목 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 - ) - - VStack(alignment: .leading, spacing: 12) { - // 키워드 필터 섹션 - VStack(alignment: .leading, spacing: 8) { - Text("키워드 필터") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.primaryGreen) - - // 키워드 추가 버튼 - Button(action: { - viewModel.showKeywordSelector = true - }) { - HStack { - Text("키워드 추가...") - .font(.system(size: 16)) - .foregroundColor(Color.secondaryText(colorScheme)) - Spacer() + .padding(.bottom, 8) + .padding(.top, 4) + + // 콘텐츠 + 하단 버튼 영역 + VStack(spacing: 0) { + // 스크롤 되는 영역 (입력 필드, 키워드, 토글 등) + ScrollView { + VStack(spacing: 24) { + + // 1) 웹사이트 URL (필수) + InputFieldSection( + title: "웹사이트 URL", + placeholder: "https://www.example.com", + description: "모니터링할 웹페이지의 정확한 URL을 입력하세요.", + isRequired: true, + text: $viewModel.urlText, + colorScheme: colorScheme + ) + + // 2) 웹페이지 별명 (선택) + InputFieldSection( + title: "웹페이지 별명", + placeholder: "동국대학교 공지사항", + description: "해당 페이지를 식별할 명칭을 입력하세요.", + isRequired: false, + text: $viewModel.nameText, + colorScheme: colorScheme + ) + + // 3) 키워드 필터 + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("키워드 필터") + .font(.KoddiBold20) + .foregroundColor(Color.text(colorScheme)) + + Button(action: { + viewModel.showKeywordSelector = true + }) { + HStack { + Text(viewModel.selectedKeywords.isEmpty ? "키워드 추가..." : "키워드 수정...") + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(colorScheme)) + Spacer() + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondaryBackground(colorScheme)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.border(colorScheme), lineWidth: 1) + ) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.secondaryBackground(colorScheme)) - ) - } - Text("관심 키워드가 포함된 내용을 걸러낼 필요가 있으면 입력하세요.") - .font(.system(size: 12)) - .foregroundColor(Color.secondaryText(colorScheme)) - .fixedSize(horizontal: false, vertical: true) - } + Text("관심 키워드가 포함된 글을 알림으로 받아보세요.") + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(colorScheme)) + .fixedSize(horizontal: false, vertical: true) + } - // 선택된 키워드 배지들 - if !viewModel.selectedKeywords.isEmpty { - FlowLayout(spacing: 8) { - ForEach(viewModel.selectedKeywords, id: \.self) { keyword in - KeywordBadgeWithDelete( - text: keyword, - colorScheme: colorScheme - ) { - viewModel.removeKeyword(keyword) + // 선택된 키워드 배지들 + if !viewModel.selectedKeywords.isEmpty { + FlowLayout(spacing: 8) { + ForEach(viewModel.selectedKeywords, id: \.self) { keyword in + KeywordBadgeWithDelete( + text: keyword, + colorScheme: colorScheme + ) { + viewModel.removeKeyword(keyword) + } } } } } - } - 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.primaryGreen) - ) - }) + // 4) 긴급 알림 토글 + HStack { + Text("긴급 알림으로 설정") + .font(.KoddiBold20) + .foregroundColor(Color.text(colorScheme)) + Spacer() + Toggle("", isOn: $viewModel.isUrgent) + .labelsHidden() + } + .padding(.vertical) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // 하단 고정 "등록 승인 요청" 버튼 + AddSubscriptionButton( + title: "등록 승인 요청", + colorScheme: colorScheme, + isEnabled: viewModel.isSubmitEnabled + ) { + let payload = viewModel.makeRequestPayload() + // TODO: 나중에 여기서 API 서비스에 payload를 넘겨서 서버로 전송 + print("📤 New Subscription Request:", payload) + dismiss() } .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 16) + .padding(.vertical, 16) } + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } + .ignoresSafeArea(.keyboard, edges: .bottom) + + // 키워드 설정 시트 .sheet(isPresented: $viewModel.showKeywordSelector) { KeywordSelectorSheet(viewModel: viewModel, colorScheme: colorScheme) } - .onAppear { - // 화면이 나타날 때 키워드 목록 로드 - if viewModel.availableKeywords.isEmpty { - viewModel.loadKeywords() - } - } } } -// 키워드 선택 시트 +// MARK: - 키워드 선택 시트 + struct KeywordSelectorSheet: View { @ObservedObject var viewModel: AddSubscriptionViewModel let colorScheme: ColorScheme @@ -124,129 +152,75 @@ struct KeywordSelectorSheet: View { ZStack { Color.background(colorScheme) .ignoresSafeArea() - - VStack(spacing: 0) { - // 헤더 - HStack { - Spacer() - Text("구독 설정") - .font(.custom("KoddiUD OnGothic Bold", size: 24)) - .foregroundColor(Color.text(colorScheme)) - Spacer() - Button(action: { - dismiss() - }) { - Image(systemName: "xmark") - .font(.system(size: 20)) - .foregroundColor(Color.text(colorScheme)) - } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 32) - - // 키워드 설정 섹션 - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("키워드 설정") - .font(.custom("KoddiUD OnGothic Bold", size: 20)) - .foregroundColor(Color.primaryGreen) - - Spacer() - } - .padding(.horizontal, 20) - // 키워드 체크박스 리스트 - if viewModel.isLoadingKeywords { - VStack(spacing: 16) { - ProgressView() - .padding(.top, 40) - Text("키워드를 불러오는 중...") - .font(.system(size: 14)) - .foregroundColor(Color.secondaryText(colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if let errorMessage = viewModel.keywordErrorMessage { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 32)) - .foregroundColor(.red) - Text(errorMessage) - .font(.system(size: 14)) - .foregroundColor(Color.secondaryText(colorScheme)) - .multilineTextAlignment(.center) - Button(action: { - viewModel.loadKeywords() - }) { - Text("다시 시도") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.vertical, 8) - .background(Color.primaryGreen) - .cornerRadius(8) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if viewModel.availableKeywords.isEmpty { - VStack(spacing: 16) { - Text("등록된 키워드가 없습니다") - .font(.system(size: 14)) - .foregroundColor(Color.secondaryText(colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else { - VStack(spacing: 0) { - ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.offset) { index, keyword in - KeywordCheckboxRow( - keyword: keyword, - isSelected: viewModel.selectedKeywords.contains(keyword), - colorScheme: colorScheme - ) { - viewModel.toggleKeyword(keyword) + VStack(spacing: 0) { + SheetHandleBar(colorScheme: colorScheme) + .padding(.top, 20) + + // 키워드 설정 화면 제목 + ScreenSubTitle(text: "키워드 설정", colorScheme: colorScheme) + + VStack(spacing: 0) { + // 스크롤 가능한 키워드 목록 + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if viewModel.availableKeywords.isEmpty { + VStack(spacing: 16) { + Text("등록된 키워드가 없습니다.") + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(colorScheme)) } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + VStack(spacing: 0) { + ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.offset) { index, keyword in + KeywordCheckboxRow( + keyword: keyword, + isSelected: viewModel.selectedKeywords.contains(keyword), + colorScheme: colorScheme + ) { + viewModel.toggleKeyword(keyword) + } - if index < viewModel.availableKeywords.count - 1 { - Divider() - .background(Color.border(colorScheme)) - .padding(.horizontal, 20) + if index < viewModel.availableKeywords.count - 1 { + Divider() + .background(Color.border(colorScheme)) + .padding(.horizontal, 20) + } + } } } } + .padding(.top, 8) + .padding(.bottom, 8) } + .scrollDismissesKeyboard(.interactively) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // 하단 고정 "저장하기" 버튼 + AddSubscriptionButton( + title: "저장하기", + colorScheme: colorScheme, + isEnabled: true + ) { + dismiss() + } + .padding(.horizontal, 20) + .padding(.top, 12) } - - Spacer() - - // 저장하기 버튼 - Button(action: { - dismiss() - }) { - Text("저장하기") - .font(.custom("KoddiUD OnGothic Bold", size: 18)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .frame(height: 56) - .background(Color.primaryGreen) - .cornerRadius(12) - } - .padding(.horizontal, 20) - .padding(.bottom, 34) - } - } - .onAppear { - // 시트가 나타날 때 키워드 목록이 비어있으면 로드 - if viewModel.availableKeywords.isEmpty, !viewModel.isLoadingKeywords { - viewModel.loadKeywords() + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } } -// 삭제 가능한 키워드 배지 +// MARK: - 키워드 배지 + FlowLayout (파일 내부용) + +/// 삭제 버튼이 있는 키워드 배지 struct KeywordBadgeWithDelete: View { let text: String let colorScheme: ColorScheme @@ -255,45 +229,48 @@ struct KeywordBadgeWithDelete: View { var body: some View { HStack(spacing: 6) { Text(text) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.white) + .font(.KoddiBold14) + .foregroundColor(.primaryGreen) Button(action: onDelete) { Image(systemName: "xmark") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.white) + .font(.KoddiBold14) + .foregroundColor(.primaryGreen) } } .padding(.horizontal, 12) .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color.primaryGreen) + RoundedRectangle(cornerRadius: 20) + .fill(Color.badgeGreenBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.primaryGreen, lineWidth: 1) ) } } -// FlowLayout for keywords +/// 여러 배지를 자동으로 줄바꿈해 배치해주는 레이아웃 struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let result = FlowResult( - in: proposal.replacingUnspecifiedDimensions().width, - subviews: subviews, - spacing: spacing - ) + let maxWidth = proposal.replacingUnspecifiedDimensions().width + let result = FlowResult(in: maxWidth, subviews: subviews, spacing: spacing) return result.size } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let result = FlowResult( - in: bounds.width, - subviews: subviews, - spacing: spacing - ) + let result = FlowResult(in: bounds.width, subviews: subviews, spacing: spacing) for (index, subview) in subviews.enumerated() { - subview.place(at: CGPoint(x: bounds.minX + result.positions[index].x, y: bounds.minY + result.positions[index].y), proposal: .unspecified) + subview.place( + at: CGPoint( + x: bounds.minX + result.positions[index].x, + y: bounds.minY + result.positions[index].y + ), + proposal: .unspecified + ) } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index 9a58d13..a11c40f 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -1,74 +1,58 @@ -import Combine import Foundation -class AddSubscriptionViewModel: ObservableObject { +final class AddSubscriptionViewModel: ObservableObject { + // 입력값 @Published var urlText: String = "" @Published var nameText: String = "" @Published var isUrgent: Bool = false + + // 키워드 선택 관련 @Published var selectedKeywords: [String] = [] @Published var showKeywordSelector: Bool = false - @Published var availableKeywords: [String] = [] - @Published var isLoadingKeywords: Bool = false - @Published var keywordErrorMessage: String? - - private let apiService: APIService - private var cancellables = Set() - init(apiService: APIService = APIService()) { - self.apiService = apiService + // 키워드 목록 (더미 데이터 10개) + @Published var availableKeywords: [String] = [ + "장학금", + "학사공지", + "수업", + "교직", + "학생회", + "봉사활동", + "대회", + "모집공고", + "실습", + "교환학생" + ] + + /// URL이 비어있지 않을 때만 전송 가능 + var isSubmitEnabled: Bool { + !urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// 서버에서 키워드 목록 불러오기 - func loadKeywords() { - guard !isLoadingKeywords else { return } - - isLoadingKeywords = true - keywordErrorMessage = nil - - apiService.getKeywords() - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - guard let self else { return } - isLoadingKeywords = false - - switch completion { - case .finished: - break - - case let .failure(error): - switch error { - case let .serverError(statusCode): - keywordErrorMessage = "서버 오류 (상태: \(statusCode))" - - case .decodingFailed: - keywordErrorMessage = "응답 처리 실패" - - case let .requestFailed(requestError): - keywordErrorMessage = "요청 실패: \(requestError.localizedDescription)" - - case .invalidURL: - keywordErrorMessage = "잘못된 URL" - - case .unknown: - keywordErrorMessage = "알 수 없는 오류" - } + /// 서버로 보낼 요청 DTO (나중에 API 연동 시 그대로 쓰면 됨) + struct NewSubscriptionRequest: Encodable { + let url: String + let name: String? + let isUrgent: Bool + let keywords: [String] + } - print("❌ 키워드 목록 조회 실패: \(keywordErrorMessage ?? "")") - } - }, - receiveValue: { [weak self] response in - guard let self else { return } - // 서버에서 받은 KeywordItem 배열을 String 배열로 변환 - availableKeywords = response.keywords.map(\.name) - print("✅ 키워드 목록 조회 성공: \(availableKeywords.count)개") - } - ) - .store(in: &cancellables) + /// 현재 입력 상태를 기반으로 Request payload 생성 + func makeRequestPayload() -> NewSubscriptionRequest { + NewSubscriptionRequest( + url: urlText.trimmingCharacters(in: .whitespacesAndNewlines), + name: nameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : nameText.trimmingCharacters(in: .whitespacesAndNewlines), + isUrgent: isUrgent, + keywords: selectedKeywords + ) } + // MARK: - 키워드 선택 로직 + func addKeyword(_ keyword: String) { - let trimmed = keyword.trimmingCharacters(in: .whitespaces) + let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, !selectedKeywords.contains(trimmed) { selectedKeywords.append(trimmed) } diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift deleted file mode 100644 index f9664a6..0000000 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/HeaderBar.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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 index 446872b..ca4e4af 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift @@ -2,8 +2,6 @@ // InputFieldSection.swift // today-s-sound // -// Created by Assistant on 12/19/24. -// import SwiftUI @@ -11,6 +9,7 @@ struct InputFieldSection: View { let title: String let placeholder: String let description: String + let isRequired: Bool @Binding var text: String let colorScheme: ColorScheme let additionalContent: (() -> AnyView)? @@ -19,6 +18,7 @@ struct InputFieldSection: View { title: String, placeholder: String, description: String, + isRequired: Bool = false, text: Binding, colorScheme: ColorScheme, additionalContent: (() -> AnyView)? = nil @@ -26,6 +26,7 @@ struct InputFieldSection: View { self.title = title self.placeholder = placeholder self.description = description + self.isRequired = isRequired _text = text self.colorScheme = colorScheme self.additionalContent = additionalContent @@ -33,61 +34,108 @@ struct InputFieldSection: View { 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)) -// ) + // 타이틀 + 필수(*) 표시 + HStack(spacing: 4) { + Text(title) + .font(.KoddiBold20) + .foregroundColor(Color.text(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 isRequired { + Text("*") + .font(.KoddiBold20) + .foregroundColor(.red) + } + } + + // 커스텀 플레이스홀더가 있는 TextField + ZStack(alignment: .leading) { + if text.isEmpty { + Text(placeholder) + .foregroundColor(Color.secondaryText(colorScheme)) + .padding(.horizontal, 18) + .padding(.vertical, 16) + .font(.KoddiRegular16) + } + TextField("", text: $text) + .padding(.horizontal, 18) + .padding(.vertical, 16) + .foregroundColor(Color.text(colorScheme)) + .font(.KoddiRegular16) + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondaryBackground(colorScheme)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.border(colorScheme), lineWidth: 1) + ) + + // 추가 컨텐츠 (예: 추천 키워드 배지 등) if let additionalContent { additionalContent() } + // 설명 텍스트 Text(description) - .font(.system(size: 13)) + .font(.KoddiRegular16) .foregroundColor(Color.secondaryText(colorScheme)) } } } +// MARK: - Preview + 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 - ) + Group { + VStack(spacing: 24) { + InputFieldSection( + title: "웹사이트 URL", + placeholder: "https://www.example.com", + description: "모니터링할 웹페이지 URL을 입력하세요.", + isRequired: true, + 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) - } - ) - } - ) + InputFieldSection( + title: "웹페이지 별명", + placeholder: "동국대학교 공지사항", + description: "해당 페이지를 식별할 명칭을 입력하세요. (선택 사항)", + isRequired: false, + text: .constant("이미 입력된 값"), + colorScheme: .light + ) + } + .padding() + .background(Color.background(.light)) + .previewDisplayName("Light Mode") + + VStack(spacing: 24) { + InputFieldSection( + title: "웹사이트 URL", + placeholder: "https://www.example.com", + description: "모니터링할 웹페이지 URL을 입력하세요.", + isRequired: true, + text: .constant(""), + colorScheme: .dark + ) + + InputFieldSection( + title: "웹페이지 별명", + placeholder: "동국대학교 공지사항", + description: "해당 페이지를 식별할 명칭을 입력하세요. (선택 사항)", + isRequired: false, + text: .constant("이미 입력된 값"), + colorScheme: .dark + ) + } + .padding() + .background(Color.background(.dark)) + .preferredColorScheme(.dark) + .previewDisplayName("Dark Mode") } - .padding() } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift deleted file mode 100644 index 3aebc41..0000000 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadge.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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/KeywordCheckboxRow.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift index 7d330f5..bbb3846 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift @@ -2,12 +2,10 @@ // KeywordCheckboxRow.swift // today-s-sound // -// Created by Assistant on 12/19/24. -// import SwiftUI -// 키워드 체크박스 Row 컴포넌트 +/// 키워드 한 줄(체크박스 + 텍스트) struct KeywordCheckboxRow: View { let keyword: String let isSelected: Bool @@ -17,7 +15,6 @@ struct KeywordCheckboxRow: View { var body: some View { Button(action: action) { HStack(spacing: 16) { - // 체크박스 ZStack { RoundedRectangle(cornerRadius: 6) .stroke(isSelected ? Color.primaryGreen : Color.border(colorScheme), lineWidth: 2) @@ -34,9 +31,8 @@ struct KeywordCheckboxRow: View { } } - // 키워드 텍스트 Text(keyword) - .font(.custom("KoddiUD OnGothic Regular", size: 18)) + .font(.KoddiBold20) .foregroundColor(Color.text(colorScheme)) Spacer() diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift new file mode 100644 index 0000000..250deab --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift @@ -0,0 +1,36 @@ +// +// HeaderBar.swift +// today-s-sound +// +// Reused as a simple sheet handle bar. +// + +import SwiftUI + +/// 시트 상단에 보이는 작은 핸들 바 +struct SheetHandleBar: View { + let colorScheme: ColorScheme + + var body: some View { + VStack(spacing: 8) { + Capsule() + .fill(Color.secondaryText(colorScheme).opacity(0.3)) + .frame(width: 80, height: 5) + .padding(.top, 8) + + // 핸들 바와 실제 콘텐츠 사이 살짝 여백 + Spacer() + .frame(height: 16) + } + } +} + +struct SheetHandleBar_Previews: PreviewProvider { + static var previews: some View { + VStack { + SheetHandleBar(colorScheme: .light) + SheetHandleBar(colorScheme: .dark) + } + .background(Color.background(.light)) + } +} diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift deleted file mode 100644 index 4b1a99e..0000000 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/UrgentToggleRow.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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/Presentation/Features/Feed/FeedModel.swift b/today-s-sound/Presentation/Features/Feed/FeedModel.swift index de12f9f..49350a3 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedModel.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedModel.swift @@ -7,36 +7,91 @@ struct FeedItem: Identifiable, Hashable { let source: String let publishedAt: Date + /// "1시간 전", "3분 전" 같은 상대 시간 텍스트 var relativeTimeText: String { let formatter = RelativeDateTimeFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.unitsStyle = .full return formatter.localizedString(for: publishedAt, relativeTo: Date()) } + + init( + id: UUID = UUID(), + title: String, + summary: String, + source: String, + publishedAt: Date + ) { + self.id = id + self.title = title + self.summary = summary + self.source = source + self.publishedAt = publishedAt + } } enum FeedSampleData { + /// 스크롤 테스트용 데모 데이터 (여러 사이트 섞어서 충분히 길게) static let items: [FeedItem] = [ FeedItem( - id: UUID(), title: "교육부, 시각장애 학생을 위한 AI 오디오 교재 배포", - summary: "전국 특수학교 대상으로 접근성 강화된 음성 교재를 순차 배포합니다.", + summary: "전국 특수학교를 대상으로 접근성 강화 음성 교재를 순차적으로 배포합니다.", source: "교육부 보도자료", - publishedAt: Date().addingTimeInterval(-3600) + publishedAt: Date().addingTimeInterval(-60 * 20) // 20분 전 ), FeedItem( - id: UUID(), title: "서울시청, 공공 서비스 음성 지원 확대 발표", - summary: "민원 앱 내 보이스오버 전용 모드를 도입해 정보 접근성을 높입니다.", + summary: "서울시는 민원 앱에 보이스오버 전용 모드를 도입해 정보 접근성을 높인다고 밝혔습니다.", + source: "서울시청 뉴스룸", + publishedAt: Date().addingTimeInterval(-60 * 60) // 1시간 전 + ), + FeedItem( + title: "오늘의 소리 베타 사용자 인터뷰", + summary: "베타 사용자들이 직접 전한 알림 읽기 경험과 개선 아이디어를 정리했습니다.", + source: "오늘의 소리 팀", + publishedAt: Date().addingTimeInterval(-60 * 60 * 2) // 2시간 전 + ), + FeedItem( + title: "접근성 블로그: iOS 18 보이스오버 변경점 정리", + summary: "새 버전에서 달라진 제스처와 읽기 옵션을 한 번에 확인해 보세요.", + source: "접근성 블로그", + publishedAt: Date().addingTimeInterval(-60 * 60 * 3) // 3시간 전 + ), + FeedItem( + title: "접근성 블로그: 시각장애인을 위한 키보드 단축키 모음", + summary: "웹 브라우저, 문서 편집기, 메신저 앱에서 유용한 단축키를 정리했습니다.", + source: "접근성 블로그", + publishedAt: Date().addingTimeInterval(-60 * 60 * 5) // 5시간 전 + ), + FeedItem( + title: "서울시청, 버스 정류장 음성 안내 고도화", + summary: "버스 도착 정보에 노선 혼잡도와 환승 정보까지 음성으로 추가 안내합니다.", source: "서울시청 뉴스룸", - publishedAt: Date().addingTimeInterval(-8400) + publishedAt: Date().addingTimeInterval(-60 * 60 * 7) // 7시간 전 + ), + FeedItem( + title: "교육부, 대학 온라인 강의 자막·음성 안내 의무화 추진", + summary: "강의 동영상에 자막과 음성 설명을 의무화하는 지침을 마련 중입니다.", + source: "교육부 보도자료", + publishedAt: Date().addingTimeInterval(-60 * 60 * 9) // 9시간 전 ), FeedItem( - id: UUID(), - title: "오늘의 소리 사용자 인터뷰", - summary: "베타 사용자들이 직접 전해준 알림 읽기 경험과 개선 아이디어를 소개합니다.", + title: "오늘의 소리: 이번 주 서비스 업데이트 안내", + summary: "알림 필터 기능과 음성 재생 속도 조절 기능이 추가되었습니다.", source: "오늘의 소리 팀", - publishedAt: Date().addingTimeInterval(-18000) + publishedAt: Date().addingTimeInterval(-60 * 60 * 12) // 12시간 전 + ), + FeedItem( + title: "오늘의 소리: 새로 구독 가능한 웹사이트 소개", + summary: "시각장애인 관련 단체, 공공기관, 접근성 블로그 등 5개 웹사이트를 새로 추가했습니다.", + source: "오늘의 소리 팀", + publishedAt: Date().addingTimeInterval(-60 * 60 * 24) // 1일 전 + ), + FeedItem( + title: "접근성 블로그: 화면 낭독기와 함께 쓰기 좋은 브라우저 설정", + summary: "페이지 자동 스크롤, 탭 이동, 포커스 표시 옵션을 함께 조정해 보세요.", + source: "접근성 블로그", + publishedAt: Date().addingTimeInterval(-60 * 60 * 30) // 1일 6시간 전 ) ] } diff --git a/today-s-sound/Presentation/Features/Feed/FeedView.swift b/today-s-sound/Presentation/Features/Feed/FeedView.swift index be77784..7f60687 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedView.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedView.swift @@ -4,27 +4,45 @@ struct FeedView: View { @StateObject private var viewModel = FeedViewModel() @Environment(\.colorScheme) private var colorScheme + /// 현재 선택된 필터 (기본값: "전체") + @State private var selectedFilter: String = "전체" + + /// 필터 옵션 목록: ["전체", "교육부 보도자료", "서울시청 뉴스룸", "오늘의 소리 팀", ...] + private var filterOptions: [String] { + let sources = Set(viewModel.items.map { $0.source }) + let sorted = Array(sources).sorted() + return ["전체"] + sorted + } + + /// 선택된 필터에 따라 걸러진 피드 아이템 + private var filteredItems: [FeedItem] { + if selectedFilter == "전체" { + return viewModel.items + } else { + return viewModel.items.filter { $0.source == selectedFilter } + } + } + var body: some View { NavigationView { ZStack { Color.background(colorScheme) .ignoresSafeArea() - VStack(spacing: 0) { - ScreenMainTitle(text: "피드", colorScheme: colorScheme) - content - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + content + .frame(maxWidth: .infinity, maxHeight: .infinity) } .navigationBarHidden(true) } } + // MARK: - 상태별 컨텐츠 + @ViewBuilder private var content: some View { if viewModel.isLoading, viewModel.items.isEmpty { loadingState - } else if let errorMessage = viewModel.errorMessage { + } else if let errorMessage = viewModel.errorMessage, viewModel.items.isEmpty { errorState(message: errorMessage) } else if viewModel.items.isEmpty { emptyState @@ -42,6 +60,7 @@ struct FeedView: View { .accessibilityHint("잠시만 기다려주세요") Spacer() } + .padding(.horizontal, 24) } private func errorState(message: String) -> some View { @@ -50,8 +69,8 @@ struct FeedView: View { Text(message) .font(.KoddiBold20) .foregroundColor(Color.secondaryText(colorScheme)) - .accessibilityLabel("오류: \(message)") - .padding(.bottom, 8) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) Button("다시 시도") { Task { await viewModel.refresh() } @@ -74,36 +93,98 @@ struct FeedView: View { Text("표시할 피드가 없습니다") .font(.KoddiBold20) .foregroundColor(Color.secondaryText(colorScheme)) + .multilineTextAlignment(.center) .accessibilityLabel("표시할 피드가 없습니다") Spacer() } + .padding(.horizontal, 24) } + // MARK: - 실제 피드 리스트 + private var feedList: some View { ScrollView { LazyVStack(spacing: 16) { - ForEach(viewModel.items) { item in + // 상단 필터 바 (타이틀 없이, 항상 맨 위) + filterBar + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 4) + + // 필터된 카드 리스트 + ForEach(filteredItems) { item in FeedCard(item: item, colorScheme: colorScheme) + .padding(.horizontal, 16) } } - .padding(.horizontal, 20) .padding(.bottom, 24) - .padding(.top, 8) } .refreshable { await viewModel.refresh() + // 새로고침 후 필터 옵션이 바뀔 수 있으니 선택값 보정 + if !filterOptions.contains(selectedFilter) { + selectedFilter = "전체" + } + } + } + + /// 상단 필터 버튼 바 (단일 선택) + private var filterBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(filterOptions.enumerated()), id: \.offset) { _, option in + let isSelected = (option == selectedFilter) + + Button { + selectedFilter = option + } label: { + Text(option) + .font(.KoddiBold20) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule() + .fill( + isSelected + ? Color.primaryGreen + : Color.secondaryBackground(colorScheme) + ) + ) + .foregroundColor( + isSelected + ? Color.white + : Color.text(colorScheme) + ) + .overlay( + Capsule() + .stroke( + isSelected + ? Color.primaryGreen + : Color.border(colorScheme), + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(option) 피드 보기") + .accessibilityHint("이 버튼을 선택하면 \(option) 피드만 볼 수 있습니다") + } + } + .padding(.vertical, 4) } } } +// MARK: - 카드 뷰 + private struct FeedCard: View { let item: FeedItem let colorScheme: ColorScheme var body: some View { VStack(alignment: .leading, spacing: 12) { - Text(item.source.uppercased()) - .font(.system(size: 13, weight: .semibold)) + Text(item.source) + .font(.KoddiRegular16) .foregroundColor(Color.secondaryText(colorScheme)) Text(item.title) @@ -112,12 +193,12 @@ private struct FeedCard: View { .multilineTextAlignment(.leading) Text(item.summary) - .font(.KoddiRegular16) - .foregroundColor(Color.secondaryText(colorScheme)) + .font(.KoddiRegular20) + .foregroundColor(Color.text(colorScheme)) .multilineTextAlignment(.leading) Text(item.relativeTimeText) - .font(.system(size: 14, weight: .medium)) + .font(.KoddiRegular16) .foregroundColor(.primaryGreen) } .padding(20) @@ -131,12 +212,22 @@ private struct FeedCard: View { .stroke(Color.border(colorScheme), lineWidth: 1) ) .accessibilityElement(children: .combine) - .accessibilityLabel("\(item.source) 새 글, \(item.title), \(item.summary), \(item.relativeTimeText)") + .accessibilityLabel( + "\(item.source) 새 글, \(item.title), \(item.summary), \(item.relativeTimeText)" + ) } } +// MARK: - 프리뷰 + struct FeedView_Previews: PreviewProvider { static var previews: some View { - FeedView() + Group { + FeedView() + .environment(\.colorScheme, .light) + + FeedView() + .environment(\.colorScheme, .dark) + } } } diff --git a/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift index af726a0..73774d0 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift @@ -6,11 +6,13 @@ final class FeedViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var errorMessage: String? + /// 새로고침 (데모에서는 단순 셔플) func refresh() async { isLoading = true errorMessage = nil do { + // 실제 API 연동 전까지는 약간의 딜레이를 주고 셔플만 함 try await Task.sleep(nanoseconds: 800_000_000) items = FeedSampleData.items.shuffled() isLoading = false diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift deleted file mode 100644 index 87f9324..0000000 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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: 16) { - Button(action: onTap) { - Text("새로운 웹페이지 추가") - .font(.KoddiExtraBold32) - .foregroundColor(colorScheme == .dark ? .black : .white) - .padding(.horizontal, 32) - .padding(.vertical, 18) - .frame(width: 360, height: 84) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.primaryGreen) - ) - .foregroundColor(.white) - } - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - } -} - -struct AddSubscriptionButton_Previews: PreviewProvider { - static var previews: some View { - AddSubscriptionButton(colorScheme: .light, onTap: {}) - .previewLayout(.sizeThatFits) - .padding() - .background(Color(UIColor.systemBackground)) - } -} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift index cd6bfe6..cc9d814 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift @@ -13,7 +13,7 @@ struct StatusBadge: View { var body: some View { Text(text) - .font(.system(size: 14, weight: .medium)) + .font(.KoddiBold14) .foregroundColor(.primaryGreen) .padding(.horizontal, 8) .padding(.vertical, 4) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index 95980d1..6ece797 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -106,10 +106,16 @@ struct SubscriptionListView: View { .listStyle(.plain) .scrollContentBackground(.hidden) } - - AddSubscriptionButton(colorScheme: colorScheme) { - showAddSubscription = true - } + AddSubscriptionButton( + title: "새 웹페이지 추가", + colorScheme: colorScheme, + isEnabled: true + ) { + showAddSubscription = true + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + .padding(.top, 12) } } .navigationBarHidden(true) diff --git a/today-s-sound/Resources/Fonts.swift b/today-s-sound/Resources/Fonts.swift index db33503..41e779e 100644 --- a/today-s-sound/Resources/Fonts.swift +++ b/today-s-sound/Resources/Fonts.swift @@ -58,6 +58,10 @@ extension Font { .koddi(type: .regular, size: 16) } + static var KoddiRegular20: Font { + .koddi(type: .regular, size: 20) + } + static var KoddiBold14: Font { .koddi(type: .bold, size: 14) } From cc9702912946654c180ddcc8e161bc8378cbf713 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Mon, 1 Dec 2025 18:38:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20UI=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9=EB=A1=9D=20UI=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=20-=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationList/AlertCardView.swift | 96 ++++----- .../NotificationListView.swift | 197 +++++++++++------- .../NotificationListViewModel.swift | 11 + 3 files changed, 175 insertions(+), 129 deletions(-) diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift index c22ac84..b2682ec 100644 --- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -13,13 +13,13 @@ struct AlertCardView: View { alarm.isUrgent ? .urgentPink : .primaryGreen } - private var buttonBackgroundColor: Color { - Color.buttonBackground(colorScheme) + private var textColor: Color { + colorScheme == .dark ? .black : .white } var body: some View { VStack(alignment: .leading, spacing: 16) { - // 상단: 아이콘 + 제목 + 시간 + // 상단: 아이콘 + 제목 HStack(alignment: .top, spacing: 12) { Image(alarm.isUrgent ? "notice" : "mail") .resizable() @@ -27,75 +27,65 @@ struct AlertCardView: View { .frame(width: 48, height: 48) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text(alarm.alias) - .font(.KoddiExtraBold32) - .foregroundColor(colorScheme == .dark ? .black : .white) - .multilineTextAlignment(.leading) - - Text(alarm.timeAgo) - .font(.KoddiExtraBold28) - .foregroundColor(colorScheme == .dark ? .black : .white) - } + Text(alarm.alias) + .font(.KoddiExtraBold32) + .foregroundColor(textColor) + .multilineTextAlignment(.leading) Spacer() } - // 하단: (추후 음성 재생 버튼용) 지금은 단순 버튼 UI만 - Button(action: { - // TODO: 여기서 나중에 TTS/음성 재생 로직 연결 - }, label: { - HStack(spacing: 20) { - Image(systemName: "speaker.wave.2") - .resizable() - .scaledToFit() - .frame(width: 48, height: 48) - .foregroundStyle(cardColor) - .accessibilityHidden(true) + // 중간: 요약 내용 + Text(alarm.summaryContent) + .font(.KoddiRegular20) + .foregroundColor(textColor) + .multilineTextAlignment(.leading) - Text("음성으로 듣기") - .font(.KoddiExtraBold32) - .foregroundColor(Color.text(colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(16) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(buttonBackgroundColor) - ) - }) - .accessibilityLabel("음성으로 듣기 버튼") - .accessibilityHint("이중탭하여 알림 내용을 음성으로 들을 수 있습니다") + // 하단: 시간 + Text(alarm.timeAgo) + .font(.KoddiRegular16) + .foregroundColor(textColor) } .padding(16) .background( RoundedRectangle(cornerRadius: 10) .fill(cardColor) ) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(alarm.isUrgent ? "긴급 알림" : "알림"), \(alarm.alias), \(alarm.timeAgo)") } } struct AlertCardView_Previews: PreviewProvider { - private static let sampleAlarm = AlarmItem( - subscriptionId: 1, - alias: "접근성 블로그", - summaryContent: "애플이 새로운 보이스오버 기능을 발표했습니다.", - timeAgo: "3분 전", - isUrgent: false - ) + private static let sampleAlarms: [AlarmItem] = [ + AlarmItem( + subscriptionId: 1, + alias: "동국대 SW 융합교육원", + summaryContent: "동국대학교 SW 융합교육원에서 새로운 교육 프로그램 공지가 등록되었습니다. 마감 기한을 꼭 확인해주세요.", + timeAgo: "5분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 2, + alias: "오늘의 소리 팀 공지", + summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 접근성 관련 보이스오버 개선과 버그 수정이 포함되어 있습니다.", + timeAgo: "10분 전", + isUrgent: false + ) + ] static var previews: some View { Group { - AlertCardView(alarm: sampleAlarm, colorScheme: .light) - .padding() - .previewDisplayName("Alarm - Light") + ForEach(sampleAlarms) { alarm in + AlertCardView(alarm: alarm, colorScheme: .light) + .padding() + .previewDisplayName("Card Light - \(alarm.alias)") + } - AlertCardView(alarm: sampleAlarm, colorScheme: .dark) - .padding() - .background(Color.black) - .previewDisplayName("Alarm - Dark") + ForEach(sampleAlarms) { alarm in + AlertCardView(alarm: alarm, colorScheme: .dark) + .padding() + .background(Color.black) + .previewDisplayName("Card Dark - \(alarm.alias)") + } } .previewLayout(.sizeThatFits) } diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index 3c3bc1b..6645026 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -13,7 +13,8 @@ struct NotificationListView: View { ZStack { Color.background(colorScheme) .ignoresSafeArea() - VStack(alignment: .leading, spacing: 16) { + + VStack(alignment: .leading, spacing: 4) { ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) .padding(.horizontal, 20) @@ -40,7 +41,8 @@ struct NotificationListView: View { Spacer() } // 에러 - else if let errorMessage = viewModel.errorMessage, viewModel.alarms.isEmpty { Spacer() + else if let errorMessage = viewModel.errorMessage, viewModel.alarms.isEmpty { + Spacer() VStack(spacing: 16) { Text(errorMessage) .font(.KoddiBold20) @@ -61,7 +63,8 @@ struct NotificationListView: View { .accessibilityHint("탭하여 구독 목록을 다시 불러옵니다") } Spacer() - } // 알림 없음 + } + // 알림 없음 else if viewModel.alarms.isEmpty { Spacer() VStack(spacing: 16) { @@ -71,98 +74,140 @@ struct NotificationListView: View { .accessibilityLabel("새로운 알림이 없습니다") } Spacer() - } else { - ScrollView { - LazyVStack(spacing: 12) { - ForEach(viewModel.alarms) { alarm in - AlertCardView(alarm: alarm, colorScheme: colorScheme) - .onAppear { - viewModel.loadMoreIfNeeded(currentItem: alarm) + } + // 알림 목록 + else { + List { + ForEach(viewModel.alarms) { alarm in + AlertCardView(alarm: alarm, colorScheme: colorScheme) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 12, trailing: 20)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .onAppear { + viewModel.loadMoreIfNeeded(currentItem: alarm) + } + .swipeActions { + Button(role: .destructive) { + viewModel.delete(alarm: alarm) + } label: { + Label("삭제", systemImage: "trash") } - } + .accessibilityLabel("알림 삭제") + .accessibilityHint("이 알림을 목록에서 삭제합니다") + } + } - if viewModel.isLoadingMore { + if viewModel.isLoadingMore { + HStack { + Spacer() ProgressView() - .padding() + Spacer() } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } - .padding(.horizontal, 20) - .padding(.bottom, 16) } + .listStyle(.plain) + .scrollContentBackground(.hidden) } } } #if DEBUG - struct NotificationListView_Previews: PreviewProvider { - static var previews: some View { - Group { - NotificationListView(viewModel: .previewData) - .environment(\.colorScheme, .light) +struct NotificationListView_Previews: PreviewProvider { + static var previews: some View { + Group { + // 데이터 있는 상태 - 라이트 모드 + NotificationListView(viewModel: .previewData) + .environment(\.colorScheme, .light) + .previewDisplayName("알림 목록 - Light") - NotificationListView(viewModel: .previewEmpty) - .environment(\.colorScheme, .light) + // 데이터 있는 상태 - 다크 모드 + NotificationListView(viewModel: .previewData) + .environment(\.colorScheme, .dark) + .previewDisplayName("알림 목록 - Dark") - NotificationListView(viewModel: .previewError) - .environment(\.colorScheme, .light) - } + // 빈 상태 + NotificationListView(viewModel: .previewEmpty) + .environment(\.colorScheme, .light) + .previewDisplayName("알림 없음") + + // 에러 상태 + NotificationListView(viewModel: .previewError) + .environment(\.colorScheme, .light) + .previewDisplayName("에러 상태") } } +} - extension NotificationListViewModel { - private static func sampleAlarms() -> [AlarmItem] { - [ - AlarmItem( - subscriptionId: 1, - alias: "접근성 블로그", - summaryContent: "애플이 새로운 보이스오버 기능을 발표했습니다.", - timeAgo: "3분 전", - isUrgent: false - ), - AlarmItem( - subscriptionId: 2, - alias: "오늘의 소리", - summaryContent: "오늘의 소리에서 새로운 음성이 도착했습니다.", - timeAgo: "10분 전", - isUrgent: true - ) - ] - } +extension NotificationListViewModel { + private static func sampleAlarms() -> [AlarmItem] { + [ + AlarmItem( + subscriptionId: 1, + alias: "동국대 SW 융합교육원", + summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", + timeAgo: "5분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 2, + alias: "오늘의 소리 팀 공지", + summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", + timeAgo: "12분 전", + isUrgent: false + ), + AlarmItem( + subscriptionId: 3, + alias: "장학 공지", + summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", + timeAgo: "30분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 4, + alias: "동국대 일정 안내", + summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", + timeAgo: "1시간 전", + isUrgent: false + ) + ] + } - static var previewLoading: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.isLoading = true - vm.alarms = [] - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewLoading: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.isLoading = true + vm.alarms = [] + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } - static var previewError: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.errorMessage = "서버와 연결할 수 없습니다" - vm.alarms = [] - vm.isLoading = false - vm.disableAutoLoad = true - return vm - } + static var previewError: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.errorMessage = "서버와 연결할 수 없습니다" + vm.alarms = [] + vm.isLoading = false + vm.disableAutoLoad = true + return vm + } - static var previewEmpty: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = [] - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewEmpty: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = [] + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } - static var previewData: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = sampleAlarms() - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewData: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = sampleAlarms() + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm } +} #endif diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift index fc1ed16..aeecc4f 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift @@ -119,4 +119,15 @@ class NotificationListViewModel: ObservableObject { loadAlarms() } } + + /// 스와이프 삭제 처리 (추후 API 연동 시 여기에서 호출) + func delete(alarm: AlarmItem) { + // 1) 로컬 리스트에서 삭제 + alarms.removeAll { $0.id == alarm.id } + + // 2) TODO: 서버 삭제 API 연동 + // apiService.deleteAlarm(id: alarm.subscriptionId) + // .sink { ... } receiveValue: { ... } + // .store(in: &cancellables) + } } From a32d4c2464a060f8044c49626fe0395ef8adaf6a Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Mon, 1 Dec 2025 18:43:57 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20lint=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AddSubscription/AddSubscriptionView.swift | 2 - .../Component/InputFieldSection.swift | 4 +- .../Component/SheetHandler.swift | 2 +- .../Presentation/Features/Feed/FeedView.swift | 18 +- .../NotificationListView.swift | 176 +++++++++--------- .../Component/StatusBadge.swift | 2 +- .../SubscriptionListView.swift | 20 +- today-s-sound/Resources/Fonts.swift | 8 +- 8 files changed, 115 insertions(+), 117 deletions(-) diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index 85afcab..2fb1283 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -27,7 +27,6 @@ struct AddSubscriptionView: View { // 스크롤 되는 영역 (입력 필드, 키워드, 토글 등) ScrollView { VStack(spacing: 24) { - // 1) 웹사이트 URL (필수) InputFieldSection( title: "웹사이트 URL", @@ -133,7 +132,6 @@ struct AddSubscriptionView: View { } } .ignoresSafeArea(.keyboard, edges: .bottom) - // 키워드 설정 시트 .sheet(isPresented: $viewModel.showKeywordSelector) { KeywordSelectorSheet(viewModel: viewModel, colorScheme: colorScheme) diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift index ca4e4af..316961c 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift @@ -37,12 +37,12 @@ struct InputFieldSection: View { // 타이틀 + 필수(*) 표시 HStack(spacing: 4) { Text(title) - .font(.KoddiBold20) + .font(.KoddiBold20) .foregroundColor(Color.text(colorScheme)) if isRequired { Text("*") - .font(.KoddiBold20) + .font(.KoddiBold20) .foregroundColor(.red) } } diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift index 250deab..0f4ffe2 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift @@ -1,5 +1,5 @@ // -// HeaderBar.swift +// SheetHandler.swift // today-s-sound // // Reused as a simple sheet handle bar. diff --git a/today-s-sound/Presentation/Features/Feed/FeedView.swift b/today-s-sound/Presentation/Features/Feed/FeedView.swift index 7f60687..72785fd 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedView.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedView.swift @@ -9,7 +9,7 @@ struct FeedView: View { /// 필터 옵션 목록: ["전체", "교육부 보도자료", "서울시청 뉴스룸", "오늘의 소리 팀", ...] private var filterOptions: [String] { - let sources = Set(viewModel.items.map { $0.source }) + let sources = Set(viewModel.items.map(\.source)) let sorted = Array(sources).sorted() return ["전체"] + sorted } @@ -17,9 +17,9 @@ struct FeedView: View { /// 선택된 필터에 따라 걸러진 피드 아이템 private var filteredItems: [FeedItem] { if selectedFilter == "전체" { - return viewModel.items + viewModel.items } else { - return viewModel.items.filter { $0.source == selectedFilter } + viewModel.items.filter { $0.source == selectedFilter } } } @@ -146,21 +146,21 @@ struct FeedView: View { Capsule() .fill( isSelected - ? Color.primaryGreen - : Color.secondaryBackground(colorScheme) + ? Color.primaryGreen + : Color.secondaryBackground(colorScheme) ) ) .foregroundColor( isSelected - ? Color.white - : Color.text(colorScheme) + ? Color.white + : Color.text(colorScheme) ) .overlay( Capsule() .stroke( isSelected - ? Color.primaryGreen - : Color.border(colorScheme), + ? Color.primaryGreen + : Color.border(colorScheme), lineWidth: 1 ) ) diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index 6645026..4a0dca2 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -114,100 +114,100 @@ struct NotificationListView: View { } #if DEBUG -struct NotificationListView_Previews: PreviewProvider { - static var previews: some View { - Group { - // 데이터 있는 상태 - 라이트 모드 - NotificationListView(viewModel: .previewData) - .environment(\.colorScheme, .light) - .previewDisplayName("알림 목록 - Light") - - // 데이터 있는 상태 - 다크 모드 - NotificationListView(viewModel: .previewData) - .environment(\.colorScheme, .dark) - .previewDisplayName("알림 목록 - Dark") - - // 빈 상태 - NotificationListView(viewModel: .previewEmpty) - .environment(\.colorScheme, .light) - .previewDisplayName("알림 없음") - - // 에러 상태 - NotificationListView(viewModel: .previewError) - .environment(\.colorScheme, .light) - .previewDisplayName("에러 상태") + struct NotificationListView_Previews: PreviewProvider { + static var previews: some View { + Group { + // 데이터 있는 상태 - 라이트 모드 + NotificationListView(viewModel: .previewData) + .environment(\.colorScheme, .light) + .previewDisplayName("알림 목록 - Light") + + // 데이터 있는 상태 - 다크 모드 + NotificationListView(viewModel: .previewData) + .environment(\.colorScheme, .dark) + .previewDisplayName("알림 목록 - Dark") + + // 빈 상태 + NotificationListView(viewModel: .previewEmpty) + .environment(\.colorScheme, .light) + .previewDisplayName("알림 없음") + + // 에러 상태 + NotificationListView(viewModel: .previewError) + .environment(\.colorScheme, .light) + .previewDisplayName("에러 상태") + } } } -} -extension NotificationListViewModel { - private static func sampleAlarms() -> [AlarmItem] { - [ - AlarmItem( - subscriptionId: 1, - alias: "동국대 SW 융합교육원", - summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", - timeAgo: "5분 전", - isUrgent: true - ), - AlarmItem( - subscriptionId: 2, - alias: "오늘의 소리 팀 공지", - summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", - timeAgo: "12분 전", - isUrgent: false - ), - AlarmItem( - subscriptionId: 3, - alias: "장학 공지", - summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", - timeAgo: "30분 전", - isUrgent: true - ), - AlarmItem( - subscriptionId: 4, - alias: "동국대 일정 안내", - summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", - timeAgo: "1시간 전", - isUrgent: false - ) - ] - } + extension NotificationListViewModel { + private static func sampleAlarms() -> [AlarmItem] { + [ + AlarmItem( + subscriptionId: 1, + alias: "동국대 SW 융합교육원", + summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", + timeAgo: "5분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 2, + alias: "오늘의 소리 팀 공지", + summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", + timeAgo: "12분 전", + isUrgent: false + ), + AlarmItem( + subscriptionId: 3, + alias: "장학 공지", + summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", + timeAgo: "30분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 4, + alias: "동국대 일정 안내", + summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", + timeAgo: "1시간 전", + isUrgent: false + ) + ] + } - static var previewLoading: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.isLoading = true - vm.alarms = [] - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewLoading: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.isLoading = true + vm.alarms = [] + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } - static var previewError: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.errorMessage = "서버와 연결할 수 없습니다" - vm.alarms = [] - vm.isLoading = false - vm.disableAutoLoad = true - return vm - } + static var previewError: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.errorMessage = "서버와 연결할 수 없습니다" + vm.alarms = [] + vm.isLoading = false + vm.disableAutoLoad = true + return vm + } - static var previewEmpty: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = [] - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewEmpty: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = [] + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } - static var previewData: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = sampleAlarms() - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm + static var previewData: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = sampleAlarms() + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } } -} #endif diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift index cc9d814..f140a21 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift @@ -13,7 +13,7 @@ struct StatusBadge: View { var body: some View { Text(text) - .font(.KoddiBold14) + .font(.KoddiBold14) .foregroundColor(.primaryGreen) .padding(.horizontal, 8) .padding(.vertical, 4) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index 6ece797..b08fce7 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -106,16 +106,16 @@ struct SubscriptionListView: View { .listStyle(.plain) .scrollContentBackground(.hidden) } - AddSubscriptionButton( - title: "새 웹페이지 추가", - colorScheme: colorScheme, - isEnabled: true - ) { - showAddSubscription = true - } - .padding(.horizontal, 20) - .padding(.bottom, 16) - .padding(.top, 12) + AddSubscriptionButton( + title: "새 웹페이지 추가", + colorScheme: colorScheme, + isEnabled: true + ) { + showAddSubscription = true + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + .padding(.top, 12) } } .navigationBarHidden(true) diff --git a/today-s-sound/Resources/Fonts.swift b/today-s-sound/Resources/Fonts.swift index 41e779e..f1e2046 100644 --- a/today-s-sound/Resources/Fonts.swift +++ b/today-s-sound/Resources/Fonts.swift @@ -58,10 +58,10 @@ extension Font { .koddi(type: .regular, size: 16) } - static var KoddiRegular20: Font { - .koddi(type: .regular, size: 20) - } - + static var KoddiRegular20: Font { + .koddi(type: .regular, size: 20) + } + static var KoddiBold14: Font { .koddi(type: .bold, size: 14) }