diff --git a/today-s-sound.xcodeproj/project.pbxproj b/today-s-sound.xcodeproj/project.pbxproj index 7b691f4..2bf3583 100644 --- a/today-s-sound.xcodeproj/project.pbxproj +++ b/today-s-sound.xcodeproj/project.pbxproj @@ -294,6 +294,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "today-s-sound/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "오늘의 소리"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -306,6 +308,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.td.today-s-sound"; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -324,6 +329,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "today-s-sound/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "오늘의 소리"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -336,6 +343,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.td.today-s-sound"; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/today-s-sound/Assets.xcassets/AppIcon.appiconset/Contents.json b/today-s-sound/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..41d3fd5 100644 --- a/today-s-sound/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/today-s-sound/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "thumbnail.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/today-s-sound/Assets.xcassets/AppIcon.appiconset/thumbnail.png b/today-s-sound/Assets.xcassets/AppIcon.appiconset/thumbnail.png new file mode 100644 index 0000000..9292c3a Binary files /dev/null and b/today-s-sound/Assets.xcassets/AppIcon.appiconset/thumbnail.png differ diff --git a/today-s-sound/Assets.xcassets/Bell off.imageset/Bell off.svg b/today-s-sound/Assets.xcassets/Bell off.imageset/Bell off.svg new file mode 100644 index 0000000..ee17648 --- /dev/null +++ b/today-s-sound/Assets.xcassets/Bell off.imageset/Bell off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/today-s-sound/Assets.xcassets/Bell off.imageset/Contents.json b/today-s-sound/Assets.xcassets/Bell off.imageset/Contents.json new file mode 100644 index 0000000..a5f3e97 --- /dev/null +++ b/today-s-sound/Assets.xcassets/Bell off.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Bell off.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/today-s-sound/Assets.xcassets/Bell.imageset/Bell.svg b/today-s-sound/Assets.xcassets/Bell.imageset/Bell.svg new file mode 100644 index 0000000..5b4407a --- /dev/null +++ b/today-s-sound/Assets.xcassets/Bell.imageset/Bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/today-s-sound/Assets.xcassets/Bell.imageset/Contents.json b/today-s-sound/Assets.xcassets/Bell.imageset/Contents.json new file mode 100644 index 0000000..316b103 --- /dev/null +++ b/today-s-sound/Assets.xcassets/Bell.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Bell.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/today-s-sound/Assets.xcassets/mail.imageset/Contents.json b/today-s-sound/Assets.xcassets/mail.imageset/Contents.json new file mode 100644 index 0000000..0dd3148 --- /dev/null +++ b/today-s-sound/Assets.xcassets/mail.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mail.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/today-s-sound/Assets.xcassets/mail.imageset/mail.svg b/today-s-sound/Assets.xcassets/mail.imageset/mail.svg new file mode 100644 index 0000000..76bfdac --- /dev/null +++ b/today-s-sound/Assets.xcassets/mail.imageset/mail.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/today-s-sound/Assets.xcassets/notice.imageset/Contents.json b/today-s-sound/Assets.xcassets/notice.imageset/Contents.json new file mode 100644 index 0000000..f9f7fbc --- /dev/null +++ b/today-s-sound/Assets.xcassets/notice.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "notice.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/today-s-sound/Assets.xcassets/notice.imageset/notice.svg b/today-s-sound/Assets.xcassets/notice.imageset/notice.svg new file mode 100644 index 0000000..8c3e6e7 --- /dev/null +++ b/today-s-sound/Assets.xcassets/notice.imageset/notice.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/today-s-sound/Assets.xcassets/pause.imageset/Contents.json b/today-s-sound/Assets.xcassets/pause.imageset/Contents.json new file mode 100644 index 0000000..61f53a7 --- /dev/null +++ b/today-s-sound/Assets.xcassets/pause.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pause.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/today-s-sound/Assets.xcassets/pause.imageset/pause.svg b/today-s-sound/Assets.xcassets/pause.imageset/pause.svg new file mode 100644 index 0000000..53074fe --- /dev/null +++ b/today-s-sound/Assets.xcassets/pause.imageset/pause.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/today-s-sound/Assets.xcassets/play.imageset/Contents.json b/today-s-sound/Assets.xcassets/play.imageset/Contents.json new file mode 100644 index 0000000..b17e13d --- /dev/null +++ b/today-s-sound/Assets.xcassets/play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "play.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/today-s-sound/Assets.xcassets/play.imageset/play.svg b/today-s-sound/Assets.xcassets/play.imageset/play.svg new file mode 100644 index 0000000..e6f0287 --- /dev/null +++ b/today-s-sound/Assets.xcassets/play.imageset/play.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift index b9a7660..a4bf87b 100644 --- a/today-s-sound/Core/AppState/SessionStore.swift +++ b/today-s-sound/Core/AppState/SessionStore.swift @@ -157,3 +157,15 @@ final class SessionStore: ObservableObject { lastError = nil } } + +#if DEBUG + extension SessionStore { + static var preview: SessionStore { + let store = SessionStore() + store.userId = "preview-user" + store.isRegistered = true + store.lastError = nil + return store + } + } +#endif diff --git a/today-s-sound/Data/Models/Alarm.swift b/today-s-sound/Data/Models/Alarm.swift index 7974fd8..209dfb0 100644 --- a/today-s-sound/Data/Models/Alarm.swift +++ b/today-s-sound/Data/Models/Alarm.swift @@ -2,76 +2,44 @@ // Alarm.swift // today-s-sound // -// Created by Assistant -// import Foundation -// MARK: - Alarm Response Models - -/// 알림 목록 응답 -typealias AlarmListResponse = APIResponse<[AlarmItem]> +/// 최근 알림 목록 응답 +/// 서버가 [RecentAlarmResponse] 배열을 내려준다고 했으니까 그대로 [AlarmItem]으로 매핑 +typealias AlarmListResponse = [AlarmItem] -extension AlarmListResponse { - // 편의 속성: result를 alarms로 접근 - var alarms: [AlarmItem] { - result - } -} - -/// 개별 알림 아이템 +/// 개별 알림 아이템 (RecentAlarmResponse) struct AlarmItem: Codable, Identifiable { - let alias: String - let timeAgo: String - let summaries: [SummaryItem] - let isUrgent: Bool? + let subscriptionId: Int64 // 구독 ID (알림 ID 역할) + let alias: String // 구독 별칭 + let summaryContent: String // 요약 내용 + let timeAgo: String // "~분 전" 같은 상대 시간 + let isUrgent: Bool // 긴급 여부 - // Identifiable을 위한 id (alias를 고유 식별자로 사용) - var id: String { alias } + // SwiftUI ForEach에서 사용할 식별자 + var id: Int64 { subscriptionId } enum CodingKeys: String, CodingKey { + case subscriptionId case alias + case summaryContent case timeAgo - case summaries case isUrgent } - // 디코딩 시 isUrgent가 없으면 nil로 설정 - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - alias = try container.decode(String.self, forKey: .alias) - timeAgo = try container.decode(String.self, forKey: .timeAgo) - summaries = try container.decode([SummaryItem].self, forKey: .summaries) - isUrgent = try container.decodeIfPresent(Bool.self, forKey: .isUrgent) - } - - // 인코딩 - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(alias, forKey: .alias) - try container.encode(timeAgo, forKey: .timeAgo) - try container.encode(summaries, forKey: .summaries) - try container.encodeIfPresent(isUrgent, forKey: .isUrgent) - } - - // 수동 초기화 (Preview 등에서 사용) - init(alias: String, timeAgo: String, summaries: [SummaryItem], isUrgent: Bool? = nil) { + // Preview 등에서 쓰기 위한 커스텀 init + init( + subscriptionId: Int64, + alias: String, + summaryContent: String, + timeAgo: String, + isUrgent: Bool + ) { + self.subscriptionId = subscriptionId self.alias = alias + self.summaryContent = summaryContent self.timeAgo = timeAgo - self.summaries = summaries self.isUrgent = isUrgent } } - -/// 요약 아이템 -struct SummaryItem: Codable, Identifiable { - let id: Int64 - let summary: String - let updatedAt: String // ISO8601 문자열 - - // Date로 변환하는 편의 속성 - var updatedDate: Date? { - let formatter = ISO8601DateFormatter() - return formatter.date(from: updatedAt) - } -} diff --git a/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift index fd1699c..8b60440 100644 --- a/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift +++ b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift @@ -14,9 +14,10 @@ struct ScreenMainTitle: View { var body: some View { Text(text) - .font(.system(size: 28, weight: .bold)) + .font(.KoddiBold56) .foregroundColor(Color.text(colorScheme)) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) .padding(.horizontal, 24) .padding(.bottom, 16) } diff --git a/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift index 5065b2d..3c70c47 100644 --- a/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift +++ b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift @@ -13,8 +13,8 @@ struct ScreenSubTitle: View { var body: some View { Text(text) - .font(.system(size: 28, weight: .bold)) - .foregroundColor(colorScheme == .dark ? .white : .primaryGreen) + .font(.KoddiExtraBold28) + .foregroundColor(.primaryGreen) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) .padding(.bottom, 16) diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index 4665cd3..3476bb8 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -20,7 +20,7 @@ struct AddSubscriptionView: View { InputFieldSection( title: "웹사이트 URL", placeholder: "https://www.example.com", - description: "모니터링 할 웹페이지 URL을 입력하세요.", + description: "모니터링할 웹페이지의 정확한 URL을 입력하세요.", text: $viewModel.urlText, colorScheme: colorScheme ) @@ -28,7 +28,7 @@ struct AddSubscriptionView: View { InputFieldSection( title: "웹페이지 별명", placeholder: "동국대학교 공지사항", - description: "웹 페이지를 식별할 명칭을 입력하세요.", + description: "해당 페이지를 식별할 명칭을 입력하세요.", text: $viewModel.nameText, colorScheme: colorScheme ) @@ -92,7 +92,7 @@ struct AddSubscriptionView: View { .padding(.vertical, 16) .background( RoundedRectangle(cornerRadius: 12) - .fill(Color.primaryGreen90) + .fill(Color.primaryGreen) ) }) } diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift index 2aa6470..7d330f5 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift @@ -47,3 +47,26 @@ struct KeywordCheckboxRow: View { .buttonStyle(PlainButtonStyle()) } } + +struct KeywordCheckboxRow_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 12) { + KeywordCheckboxRow( + keyword: "시각장애", + isSelected: true, + colorScheme: .light, + action: {} + ) + + KeywordCheckboxRow( + keyword: "접근성", + isSelected: false, + colorScheme: .dark, + action: {} + ) + } + .previewLayout(.sizeThatFits) + .padding() + .background(Color(UIColor.systemBackground)) + } +} diff --git a/today-s-sound/Presentation/Features/Feed/FeedModel.swift b/today-s-sound/Presentation/Features/Feed/FeedModel.swift new file mode 100644 index 0000000..de12f9f --- /dev/null +++ b/today-s-sound/Presentation/Features/Feed/FeedModel.swift @@ -0,0 +1,42 @@ +import Foundation + +struct FeedItem: Identifiable, Hashable { + let id: UUID + let title: String + let summary: String + let source: String + let publishedAt: Date + + var relativeTimeText: String { + let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.unitsStyle = .full + return formatter.localizedString(for: publishedAt, relativeTo: Date()) + } +} + +enum FeedSampleData { + static let items: [FeedItem] = [ + FeedItem( + id: UUID(), + title: "교육부, 시각장애 학생을 위한 AI 오디오 교재 배포", + summary: "전국 특수학교 대상으로 접근성 강화된 음성 교재를 순차 배포합니다.", + source: "교육부 보도자료", + publishedAt: Date().addingTimeInterval(-3600) + ), + FeedItem( + id: UUID(), + title: "서울시청, 공공 서비스 음성 지원 확대 발표", + summary: "민원 앱 내 보이스오버 전용 모드를 도입해 정보 접근성을 높입니다.", + source: "서울시청 뉴스룸", + publishedAt: Date().addingTimeInterval(-8400) + ), + FeedItem( + id: UUID(), + title: "오늘의 소리 사용자 인터뷰", + summary: "베타 사용자들이 직접 전해준 알림 읽기 경험과 개선 아이디어를 소개합니다.", + source: "오늘의 소리 팀", + publishedAt: Date().addingTimeInterval(-18000) + ) + ] +} diff --git a/today-s-sound/Presentation/Features/Feed/FeedView.swift b/today-s-sound/Presentation/Features/Feed/FeedView.swift new file mode 100644 index 0000000..be77784 --- /dev/null +++ b/today-s-sound/Presentation/Features/Feed/FeedView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct FeedView: View { + @StateObject private var viewModel = FeedViewModel() + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationView { + ZStack { + Color.background(colorScheme) + .ignoresSafeArea() + + VStack(spacing: 0) { + ScreenMainTitle(text: "피드", colorScheme: colorScheme) + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationBarHidden(true) + } + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading, viewModel.items.isEmpty { + loadingState + } else if let errorMessage = viewModel.errorMessage { + errorState(message: errorMessage) + } else if viewModel.items.isEmpty { + emptyState + } else { + feedList + } + } + + private var loadingState: some View { + VStack { + Spacer() + ProgressView("피드를 불러오는 중...") + .progressViewStyle(CircularProgressViewStyle()) + .accessibilityLabel("피드를 불러오는 중입니다") + .accessibilityHint("잠시만 기다려주세요") + Spacer() + } + } + + private func errorState(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + Text(message) + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("오류: \(message)") + .padding(.bottom, 8) + + Button("다시 시도") { + Task { await viewModel.refresh() } + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .font(.KoddiBold20) + .foregroundColor(.white) + .background(Color.primaryGreen) + .cornerRadius(10) + .accessibilityHint("탭하여 피드를 다시 불러옵니다") + Spacer() + } + .padding(.horizontal, 24) + } + + private var emptyState: some View { + VStack { + Spacer() + Text("표시할 피드가 없습니다") + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("표시할 피드가 없습니다") + Spacer() + } + } + + private var feedList: some View { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.items) { item in + FeedCard(item: item, colorScheme: colorScheme) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + .padding(.top, 8) + } + .refreshable { + await viewModel.refresh() + } + } +} + +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)) + .foregroundColor(Color.secondaryText(colorScheme)) + + Text(item.title) + .font(.KoddiBold28) + .foregroundColor(Color.text(colorScheme)) + .multilineTextAlignment(.leading) + + Text(item.summary) + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(colorScheme)) + .multilineTextAlignment(.leading) + + Text(item.relativeTimeText) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primaryGreen) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondaryBackground(colorScheme)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.border(colorScheme), lineWidth: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.source) 새 글, \(item.title), \(item.summary), \(item.relativeTimeText)") + } +} + +struct FeedView_Previews: PreviewProvider { + static var previews: some View { + FeedView() + } +} diff --git a/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift new file mode 100644 index 0000000..af726a0 --- /dev/null +++ b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift @@ -0,0 +1,22 @@ +import Foundation + +@MainActor +final class FeedViewModel: ObservableObject { + @Published var items: [FeedItem] = FeedSampleData.items + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + func refresh() async { + isLoading = true + errorMessage = nil + + do { + try await Task.sleep(nanoseconds: 800_000_000) + items = FeedSampleData.items.shuffled() + isLoading = false + } catch { + isLoading = false + errorMessage = "피드를 새로고침하지 못했습니다" + } + } +} diff --git a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift index 01954a3..f9a5580 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -9,6 +9,7 @@ import SwiftUI struct HomeView: View { @StateObject private var viewModel = MainViewModel() + @ObservedObject private var speechService = SpeechService.shared @Environment(\.colorScheme) var colorScheme var body: some View { @@ -24,24 +25,28 @@ struct HomeView: View { Text("오늘의 소리") .font(.KoddiBold56) .foregroundStyle(Color.text(colorScheme)) - .shadow(color: .black25, radius: 2, x: 0, y: 4) - .padding(.bottom, 60) + .padding(.bottom, 30) Button( action: { - if let first = viewModel.recentAlerts.first { - viewModel.playAlert(first) + if speechService.isSpeaking { + speechService.stop() + } else { + if let first = viewModel.recentAlerts.first { + viewModel.playAlert(first) + } } }, label: { - Image(systemName: "play.fill") + Image(speechService.isSpeaking ? "pause" : "play") .resizable() .scaledToFit() - .frame(width: 120, height: 120) - .foregroundColor(Color.primaryGreen90) - .padding(40) + .frame(width: 180, height: 180) + .padding(20) } ) + .accessibilityLabel(speechService.isSpeaking ? "재생 중단 버튼" : "재생 시작 버튼") + .accessibilityHint(speechService.isSpeaking ? "이중탭하여 재생을 중단합니다" : "이중탭하여 알림을 재생합니다") .padding(.bottom, 60) // 속도 조절 @@ -50,13 +55,13 @@ struct HomeView: View { action: { viewModel.decreaseRate() }, label: { Image(systemName: "minus") - .font(.system(size: 35, weight: .medium)) - .foregroundColor(colorScheme == .dark ? .white : Color.primaryGreen90) + .font(.KoddiBold48) + .foregroundColor(Color.primaryGreen) } ) Text(String(format: "%.1f x", viewModel.playbackRate)) - .font(.system(size: 48, weight: .bold)) + .font(.KoddiBold48) .foregroundColor(Color.text(colorScheme)) .monospacedDigit() .frame(minWidth: 100) @@ -65,32 +70,31 @@ struct HomeView: View { action: { viewModel.increaseRate() }, label: { Image(systemName: "plus") - .font(.system(size: 35, weight: .medium)) - .foregroundColor(colorScheme == .dark ? .white : Color.primaryGreen90) + .font(.KoddiBold48) + .foregroundColor(Color.primaryGreen) } ) } - - Spacer() + .padding(.bottom, 60) VStack(spacing: 16) { Text("현재 카테고리") - .font(.system(size: 28)) - .foregroundColor(Color.secondaryText(colorScheme)) + .font(.KoddiBold28) + .foregroundColor(Color.text(colorScheme)) Text(viewModel.currentCategoryName) - .font(.system(size: 32, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .frame(width: 340, height: 85) + .font(.KoddiExtraBold32) + .foregroundColor(colorScheme == .dark ? .black : .white) + .padding(.horizontal, 32) + .padding(.vertical, 18) + .frame(width: 360, height: 84) .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color.primaryGreen90) + RoundedRectangle(cornerRadius: 10) + .fill(Color.primaryGreen) ) .foregroundColor(.white) } - .padding(.bottom, 32) + .padding(.bottom, 16) } } } diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift index 7fa25ad..1f76387 100644 --- a/today-s-sound/Presentation/Features/Main/MainView.swift +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -1,75 +1,46 @@ import SwiftUI struct MainView: View { - @StateObject private var viewModel = MainViewModel() - @State private var showingDebugSettings = false + private enum Tab: Hashable { + case home + case feed + case notifications + case subscriptions + } + + @State private var selectedTab: Tab = .home var body: some View { - TabView { + TabView(selection: $selectedTab) { HomeView() .tabItem { - Image(systemName: "house.fill") - Text("메인") + Image(systemName: "play.house.fill") + Text("홈") } + .tag(Tab.home) + + FeedView() + .tabItem { + Image(systemName: "text.bubble.fill") + Text("피드") + } + .tag(Tab.feed) NotificationListView() .tabItem { Image(systemName: "bell.fill") Text("알림") } + .tag(Tab.notifications) SubscriptionListView() .tabItem { - Image(systemName: "bookmark.fill") + Image(systemName: "books.vertical.fill") Text("구독") } - - #if DEBUG - // 디버그 모드에서만 표시 - NavigationView { - VStack(spacing: 20) { - Image(systemName: "hammer.fill") - .font(.system(size: 60)) - .foregroundColor(.orange) - - Text("개발자 도구") - .font(.title) - .fontWeight(.bold) - - List { - Section("테스트") { - NavigationLink("익명 사용자 등록 테스트") { - AnonymousTestView() - } - } - - Section("설정") { - Button(action: { - showingDebugSettings = true - }) { - HStack { - Image(systemName: "key.fill") - Text("키체인 관리") - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - } - } - .navigationTitle("디버그") - } - .tabItem { - Image(systemName: "hammer.fill") - Text("디버그") - } - .sheet(isPresented: $showingDebugSettings) { - DebugSettingsView() - } - #endif + .tag(Tab.subscriptions) } + .tint(.primaryGreen) } } diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift index 8eb2723..c22ac84 100644 --- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -2,57 +2,15 @@ // AlertCardView.swift // today-s-sound // -// Created by Assistant on 12/19/24. -// -import Combine import SwiftUI struct AlertCardView: View { - let alert: Alert? - let alarm: AlarmItem? + let alarm: AlarmItem let colorScheme: ColorScheme - @State private var isPlaying: Bool = false - @State private var currentSummaryIndex: Int = 0 - @State private var cancellables = Set() - - // Alert 또는 AlarmItem 중 하나만 있어야 함 - init(alert: Alert? = nil, alarm: AlarmItem? = nil, colorScheme: ColorScheme) { - self.alert = alert - self.alarm = alarm - self.colorScheme = colorScheme - } - private var cardColor: Color { - if let alert { - return alert.isUrgent ? .urgentPink : .primaryGreen - } else if let alarm { - // AlarmItem의 isUrgent 필드로 긴급 여부 판단 - return (alarm.isUrgent ?? false) ? .urgentPink : .primaryGreen - } - return .primaryGreen - } - - private var title: String { - alert?.title ?? alarm?.alias ?? "" - } - - private var timeText: String { - alert.map { _ in "2시간 전" } ?? alarm?.timeAgo ?? "" - } - - private var summaries: [String] { - alarm?.summaries.map(\.summary) ?? [] - } - - private var isUrgent: Bool { - if let alert { - return alert.isUrgent - } else if let alarm { - return alarm.isUrgent ?? false - } - return false + alarm.isUrgent ? .urgentPink : .primaryGreen } private var buttonBackgroundColor: Color { @@ -60,270 +18,85 @@ struct AlertCardView: View { } var body: some View { - VStack(spacing: 20) { - // 상단: 타이틀과 아이콘 + VStack(alignment: .leading, spacing: 16) { + // 상단: 아이콘 + 제목 + 시간 HStack(alignment: .top, spacing: 12) { - Image(systemName: isUrgent ? "bell.fill" : "doc.fill") - .font(.system(size: 24)) - .foregroundColor(.white) - .accessibilityHidden(true) // 아이콘은 시각적 장식이므로 숨김 - - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) + Image(alarm.isUrgent ? "notice" : "mail") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(alarm.alias) + .font(.KoddiExtraBold32) + .foregroundColor(colorScheme == .dark ? .black : .white) .multilineTextAlignment(.leading) - .accessibilityAddTraits(.isHeader) // 헤더로 인식 - .accessibilityLabel(title) - Text(timeText) - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.9)) - .accessibilityLabel("\(timeText)에 받은 알림") + Text(alarm.timeAgo) + .font(.KoddiExtraBold28) + .foregroundColor(colorScheme == .dark ? .black : .white) } Spacer() } - // 하단: 음성으로 듣기 버튼 + // 하단: (추후 음성 재생 버튼용) 지금은 단순 버튼 UI만 Button(action: { - if isPlaying { - // 재생 중단 - SpeechService.shared.stop() - isPlaying = false - currentSummaryIndex = 0 - cancellables.removeAll() - - // VoiceOver 알림 - UIAccessibility.post(notification: .announcement, argument: "재생이 중단되었습니다") - } else { - // 재생 시작 - playAllSummaries() - - // 재생 시작 VoiceOver 알림 - let summaryCount = summaries.count - if summaryCount > 0 { - UIAccessibility.post(notification: .announcement, argument: "\(summaryCount)개의 내용을 재생합니다") - } else { - UIAccessibility.post(notification: .announcement, argument: "알림 내용을 재생합니다") - } - } + // TODO: 여기서 나중에 TTS/음성 재생 로직 연결 }, label: { - HStack(spacing: 8) { - Image(systemName: isPlaying ? "stop.circle.fill" : "speaker.wave.2.fill") - .font(.system(size: 18)) - .foregroundStyle(isPlaying ? .red : Color.text(colorScheme)) - .accessibilityHidden(true) // 아이콘은 숨김, 텍스트로 전달 - - Text(isPlaying ? "재생 중단" : "음성으로 듣기") - .font(.system(size: 18, weight: .semibold)) + HStack(spacing: 20) { + Image(systemName: "speaker.wave.2") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .foregroundStyle(cardColor) + .accessibilityHidden(true) + + Text("음성으로 듣기") + .font(.KoddiExtraBold32) .foregroundColor(Color.text(colorScheme)) } - .foregroundColor(Color.buttonBackground(colorScheme)) .frame(maxWidth: .infinity) - .padding(.vertical, 14) + .padding(16) .background( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: 8) .fill(buttonBackgroundColor) ) }) - .accessibilityLabel(accessibilityButtonLabel) - .accessibilityHint(accessibilityButtonHint) - .accessibilityValue(accessibilityButtonValue) - .accessibilityAddTraits(isPlaying ? .isSelected : []) + .accessibilityLabel("음성으로 듣기 버튼") + .accessibilityHint("이중탭하여 알림 내용을 음성으로 들을 수 있습니다") } - .padding(24) + .padding(16) .background( - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: 10) .fill(cardColor) - .shadow(color: .black15, radius: 8, x: 0, y: 4) ) - .accessibilityElement(children: .combine) // 카드를 하나의 요소로 그룹화 - .accessibilityLabel(accessibilityCardLabel) - } - - // MARK: - 접근성 속성 - - private var accessibilityCardLabel: String { - let typeText = isUrgent ? "긴급 알림" : "알림" - return "\(typeText), \(title), \(timeText)" - } - - private var accessibilityButtonLabel: String { - isPlaying ? "재생 중단 버튼" : "음성으로 듣기 버튼" - } - - private var accessibilityButtonHint: String { - if isPlaying { - return "이중탭하여 재생을 중단합니다" - } else { - let count = summaries.count - if count > 0 { - return "이중탭하여 \(count)개의 알림 내용을 음성으로 들을 수 있습니다" - } else { - return "이중탭하여 알림 내용을 음성으로 들을 수 있습니다" - } - } - } - - private var accessibilityButtonValue: String { - if isPlaying { - let total = summaries.count - if total > 0 { - return "재생 중, \(currentSummaryIndex + 1)번째 내용 재생 중, 전체 \(total)개" - } else { - return "재생 중" - } - } else { - let count = summaries.count - if count > 0 { - return "대기 중, \(count)개의 내용이 있습니다" - } else { - return "대기 중" - } - } - } - - // MARK: - 음성 재생 함수 - - private func playAllSummaries() { - guard let alarm, !alarm.summaries.isEmpty else { - // AlarmItem이 없으면 Alert의 title만 재생 - if let alert { - SpeechService.shared.speak(text: alert.title) - isPlaying = true - - // 재생 완료 감지 - SpeechService.shared.didFinishSpeaking - .sink { [self] _ in - isPlaying = false - } - .store(in: &cancellables) - } - return - } - - // 첫 번째 summary 재생 - currentSummaryIndex = 0 - isPlaying = true - playSummary(at: 0) - } - - private func playSummary(at index: Int) { - guard let alarm, - index < alarm.summaries.count - else { - // 모든 summary 재생 완료 - isPlaying = false - currentSummaryIndex = 0 - cancellables.removeAll() - - // VoiceOver 알림: 재생 완료 - UIAccessibility.post(notification: .announcement, argument: "모든 내용 재생이 완료되었습니다") - return - } - - // 중복 재생 방지: 이미 다른 summary를 재생 중이면 리턴 - guard currentSummaryIndex == index || !SpeechService.shared.isSpeaking else { - print("⚠️ 이미 재생 중입니다. 중복 재생 방지: 현재 index=\(currentSummaryIndex), 요청된 index=\(index)") - return - } - - // 이전 cancellable 정리 - cancellables.removeAll() - - let summary = alarm.summaries[index] - currentSummaryIndex = index - - // 순서 안내 음성 재생 (예: "첫 번째 내용", "두 번째 내용") - let orderText = getOrderText(index: index, total: alarm.summaries.count) - let fullText = "\(orderText). \(summary.summary)" - - // 재생 시작 전에 중복 체크 - guard !SpeechService.shared.isSpeaking else { - print("⚠️ SpeechService가 이미 재생 중입니다. 중복 재생 방지") - return - } - - // 재생 시작 - SpeechService.shared.speak(text: fullText) - - // VoiceOver 알림: 현재 재생 중인 내용 - let total = alarm.summaries.count - UIAccessibility.post(notification: .announcement, argument: "\(index + 1)번째 내용 재생 중, 전체 \(total)개 중") - - // 재생 완료 감지 (index 검증으로 중복 방지) - let cancellable = SpeechService.shared.didFinishSpeaking - .sink(receiveValue: { [self] _ in - // 현재 재생 중인 index가 변경되었으면 리턴 (중복 방지) - guard currentSummaryIndex == index else { - print("⚠️ 재생 중 index 변경됨. 무시: 예상=\(index), 현재=\(currentSummaryIndex)") - return - } - - // 다음 summary 재생 - let nextIndex = index + 1 - if nextIndex < alarm.summaries.count { - // 약간의 딜레이 후 다음 재생 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // 딜레이 후에도 여전히 같은 index인지 확인 - if currentSummaryIndex == index { - playSummary(at: nextIndex) - } - } - } else { - // 모두 재생 완료 - isPlaying = false - currentSummaryIndex = 0 - cancellables.removeAll() - - // VoiceOver 알림: 재생 완료 - UIAccessibility.post(notification: .announcement, argument: "모든 내용 재생이 완료되었습니다") - } - }) - - cancellable.store(in: &cancellables) - } - - // MARK: - 순서 텍스트 생성 - - private func getOrderText(index: Int, total: Int) -> String { - let numbers = ["첫", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉", "열"] - - if index < numbers.count { - return "\(numbers[index]) 번째 내용" - } else { - // 10개 이상일 경우 숫자로 표기 - return "\(index + 1)번째 내용" - } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(alarm.isUrgent ? "긴급 알림" : "알림"), \(alarm.alias), \(alarm.timeAgo)") } } 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 - ) + private static let sampleAlarm = AlarmItem( + subscriptionId: 1, + alias: "접근성 블로그", + summaryContent: "애플이 새로운 보이스오버 기능을 발표했습니다.", + timeAgo: "3분 전", + isUrgent: false + ) - AlertCardView( - alert: Alert( - id: UUID(), - title: "잡코리아 채용 공고", - content: "채용 소식", - date: Date().addingTimeInterval(-10800), - isUrgent: false - ), - colorScheme: .dark - ) + static var previews: some View { + Group { + AlertCardView(alarm: sampleAlarm, colorScheme: .light) + .padding() + .previewDisplayName("Alarm - Light") + + AlertCardView(alarm: sampleAlarm, colorScheme: .dark) + .padding() + .background(Color.black) + .previewDisplayName("Alarm - Dark") } - .padding() + .previewLayout(.sizeThatFits) } } diff --git a/today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift b/today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift deleted file mode 100644 index b739905..0000000 --- a/today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// AlarmGroupView.swift -// today-s-sound -// -// 알림 그룹을 표시하는 카드 컴포넌트 -// - -import Combine -import SwiftUI - -struct AlarmGroupView: View { - let alarm: AlarmItem - let colorScheme: ColorScheme - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // 헤더: 구독 이름 + 시간 - HStack { - Text(alarm.alias) - .font(.custom("KoddiUD OnGothic Bold", size: 18)) - .foregroundColor(Color.text(colorScheme)) - - Spacer() - - Text(alarm.timeAgo) - .font(.system(size: 13)) - .foregroundColor(Color.secondaryText(colorScheme)) - } - - // 구분선 - Divider() - .background(Color.border(colorScheme)) - - // 요약 목록 - VStack(alignment: .leading, spacing: 8) { - ForEach(alarm.summaries) { summary in - SummaryRowView(summary: summary, colorScheme: colorScheme) - } - } - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.secondaryBackground(colorScheme)) - .shadow(color: .black5, radius: 4, x: 0, y: 2) - ) - } -} - -struct SummaryRowView: View { - let summary: SummaryItem - let colorScheme: ColorScheme - - @State private var isPlaying: Bool = false - @State private var cancellable: AnyCancellable? - - var body: some View { - HStack(alignment: .top, spacing: 12) { - // 불릿 포인트 - Circle() - .fill(Color.primaryGreen) - .frame(width: 6, height: 6) - .padding(.top, 6) - - // 요약 텍스트 - Text(summary.summary) - .font(.system(size: 15)) - .foregroundColor(Color.text(colorScheme)) - .fixedSize(horizontal: false, vertical: true) - - Spacer() - - // 재생 버튼 - Button(action: { - if isPlaying { - // 재생 중단 - SpeechService.shared.stop() - isPlaying = false - cancellable?.cancel() - } else { - // 재생 시작 - SpeechService.shared.speak(text: summary.summary) - isPlaying = true - - // 재생 완료 알림 구독 - cancellable = SpeechService.shared.didFinishSpeaking - .sink { _ in - isPlaying = false - } - } - }) { - Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") - .font(.system(size: 24)) - .foregroundColor(isPlaying ? .red : Color.primaryGreen) - } - .buttonStyle(.plain) - .padding(.top, 2) - } - } -} - -// MARK: - Preview - -#if DEBUG - struct AlarmGroupView_Previews: PreviewProvider { - static var previews: some View { - VStack(spacing: 16) { - AlarmGroupView( - alarm: AlarmItem( - alias: "동국대학교 장애학생지원센터", - timeAgo: "2시간 전", - summaries: [ - SummaryItem(id: 1, summary: "2025년 1학기 학습지원 도우미 모집 안내", updatedAt: "2025-11-01T10:00:00Z"), - SummaryItem(id: 2, summary: "장애학생 학습 보조기기 대여 신청 접수 중", updatedAt: "2025-11-01T11:00:00Z") - ], - isUrgent: false - ), - colorScheme: .light - ) - - AlarmGroupView( - alarm: AlarmItem( - alias: "서울시 긴급재난문자", - timeAgo: "5분 전", - summaries: [ - SummaryItem(id: 3, summary: "강남구 일대 호우 특보 발령", updatedAt: "2025-11-01T11:50:00Z") - ], - isUrgent: true - ), - colorScheme: .dark - ) - } - .padding() - } - } -#endif diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index 811c9e5..3c3bc1b 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -1,123 +1,168 @@ import SwiftUI struct NotificationListView: View { - @StateObject private var viewModel = NotificationListViewModel() + @StateObject private var viewModel: NotificationListViewModel @Environment(\.colorScheme) var colorScheme + init(viewModel: NotificationListViewModel = NotificationListViewModel()) { + _viewModel = StateObject(wrappedValue: viewModel) + } + var body: some View { NavigationView { ZStack { Color.background(colorScheme) .ignoresSafeArea() - - VStack(spacing: 0) { - Spacer() + VStack(alignment: .leading, spacing: 16) { ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) + .padding(.horizontal, 20) - // 로딩 상태 - if viewModel.isLoading, viewModel.alarms.isEmpty { - Spacer() - ProgressView("불러오는 중...") - .progressViewStyle(CircularProgressViewStyle()) - .accessibilityLabel("알림 목록을 불러오는 중입니다") - .accessibilityHint("잠시만 기다려주세요") - Spacer() - } - // 에러 메시지 - else if let errorMessage = viewModel.errorMessage { - Spacer() - VStack(spacing: 16) { - Text("⚠️") - .font(.system(size: 48)) - .accessibilityHidden(true) // 이모지는 숨김, 텍스트로 전달 + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.bottom, 16) + } + } + } + .onAppear { + viewModel.loadAlarms() + } + } - Text(errorMessage) - .font(.system(size: 16)) - .foregroundColor(Color.secondaryText(colorScheme)) - .accessibilityLabel("오류: \(errorMessage)") + @ViewBuilder + private var content: some View { + // 로딩 + if viewModel.isLoading, viewModel.alarms.isEmpty { + Spacer() + ProgressView("불러오는 중...") + .progressViewStyle(CircularProgressViewStyle()) + .accessibilityLabel("알림 목록을 불러오는 중입니다") + .accessibilityHint("잠시만 기다려주세요") + Spacer() + } + // 에러 + else if let errorMessage = viewModel.errorMessage, viewModel.alarms.isEmpty { Spacer() + VStack(spacing: 16) { + Text(errorMessage) + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("오류: \(errorMessage)") + .padding(.bottom) - Button("다시 시도") { - viewModel.refresh() + Button("다시 시도") { + viewModel.refresh() + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .font(.KoddiBold20) + .foregroundColor(Color.white) + .background(Color.primaryGreen) + .cornerRadius(8) + .accessibilityLabel("다시 시도 버튼") + .accessibilityHint("탭하여 구독 목록을 다시 불러옵니다") + } + Spacer() + } // 알림 없음 + else if viewModel.alarms.isEmpty { + Spacer() + VStack(spacing: 16) { + Text("새로운 알림이 없습니다") + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("새로운 알림이 없습니다") + } + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.alarms) { alarm in + AlertCardView(alarm: alarm, colorScheme: colorScheme) + .onAppear { + viewModel.loadMoreIfNeeded(currentItem: alarm) } - .padding(.horizontal, 24) - .padding(.vertical, 12) - .background(Color.primaryGreen) - .foregroundColor(.white) - .cornerRadius(8) - .accessibilityLabel("다시 시도 버튼") - .accessibilityHint("이중탭하여 알림 목록을 다시 불러옵니다") - } - Spacer() } - // 빈 상태 - else if viewModel.alarms.isEmpty { - Spacer() - VStack(spacing: 16) { - Text("📭") - .font(.system(size: 48)) - .accessibilityHidden(true) // 이모지는 숨김 - Text("최근 알림이 없습니다") - .font(.system(size: 16)) - .foregroundColor(Color.secondaryText(colorScheme)) - .accessibilityLabel("최근 알림이 없습니다") - } - Spacer() + if viewModel.isLoadingMore { + ProgressView() + .padding() } - // 알림 목록 - else { - ScrollView { - VStack(spacing: 16) { - ForEach(Array(viewModel.alarms.enumerated()), id: \.element.id) { index, alarm in - AlertCardView(alarm: alarm, colorScheme: colorScheme) - .accessibilityElement(children: .ignore) // 개별 카드 내부 접근성은 카드에서 처리 - .accessibilityLabel("알림 \(index + 1), \(viewModel.alarms.count)개 중") - .onAppear { - // 마지막에서 3번째 아이템이 보일 때만 트리거 - if let lastIndex = viewModel.alarms.indices.last, - let currentIndex = viewModel.alarms.firstIndex(where: { $0.id == alarm.id }), - currentIndex >= lastIndex - 2 - { - viewModel.loadMoreIfNeeded(currentItem: alarm) - } - } - } - - // 더 불러오는 중 인디케이터 - if viewModel.isLoadingMore { - HStack { - Spacer() - ProgressView() - .padding() - .accessibilityLabel("추가 알림을 불러오는 중입니다") - Spacer() - } - } - } - .padding(.horizontal, 16) - .padding(.top, 8) - } - .refreshable { - viewModel.refresh() - } - .accessibilityLabel("알림 목록") - .accessibilityHint("총 \(viewModel.alarms.count)개의 알림이 있습니다. 아래로 당겨서 새로고침할 수 있습니다") - } - } - } - .navigationBarHidden(true) - .onAppear { - // 처음 로드 - if viewModel.alarms.isEmpty { - viewModel.loadAlarms() } + .padding(.horizontal, 20) + .padding(.bottom, 16) } } } } -struct NotificationListView_Previews: PreviewProvider { - static var previews: some View { - NotificationListView() +#if DEBUG + struct NotificationListView_Previews: PreviewProvider { + static var previews: some View { + Group { + NotificationListView(viewModel: .previewData) + .environment(\.colorScheme, .light) + + NotificationListView(viewModel: .previewEmpty) + .environment(\.colorScheme, .light) + + NotificationListView(viewModel: .previewError) + .environment(\.colorScheme, .light) + } + } } -} + + 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 + ) + ] + } + + 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 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 + } + } +#endif diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift index 9cf6a65..fc1ed16 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift @@ -13,6 +13,7 @@ class NotificationListViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var isLoadingMore: Bool = false @Published var errorMessage: String? + var disableAutoLoad: Bool = false private let apiService: APIService private var cancellables = Set() @@ -28,50 +29,35 @@ class NotificationListViewModel: ObservableObject { /// 알림 목록 불러오기 func loadAlarms() { - print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📞 loadAlarms() 호출됨!") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━") + guard !disableAutoLoad else { return } // 이미 로딩 중이거나 더 이상 데이터가 없으면 리턴 guard !isLoading, !isLoadingMore, hasMoreData else { - print("⏸️ 알림 로딩 중단: isLoading=\(isLoading), isLoadingMore=\(isLoadingMore), hasMoreData=\(hasMoreData)") return } - print("✅ 로딩 상태 체크 통과") guard let userId = Keychain.getString(for: KeychainKey.userId), let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) else { - print("❌ 키체인에서 userId 또는 deviceSecret을 찾을 수 없음!") errorMessage = "사용자 정보가 없습니다" return } - print("✅ 키체인 정보 획득 성공") - print(" userId: \(userId)") - print(" deviceSecret: \(deviceSecret.prefix(20))...") // 첫 로딩인지 더 불러오기인지 구분 if currentPage == 0 { isLoading = true - print("📂 첫 로딩 시작") } else { isLoadingMore = true - print("📂 추가 로딩 시작") } errorMessage = nil - print("📡 알림 목록 API 요청 준비:") - print(" URL: http://localhost:8080/api/alarms") - print(" page: \(currentPage)") - print(" size: \(pageSize)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━\n") - apiService.getAlarms( userId: userId, deviceSecret: deviceSecret, page: currentPage, size: pageSize ) + // getAlarms의 리턴 타입은 AnyPublisher<[AlarmItem], APIError> 라고 가정 .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in @@ -87,41 +73,30 @@ class NotificationListViewModel: ObservableObject { switch error { case let .serverError(statusCode): errorMessage = "서버 오류 (상태: \(statusCode))" - case .decodingFailed: errorMessage = "응답 처리 실패" - case let .requestFailed(requestError): errorMessage = "요청 실패: \(requestError.localizedDescription)" - case .invalidURL: errorMessage = "잘못된 URL" - case .unknown: errorMessage = "알 수 없는 오류" } - - print("❌ 알림 목록 조회 실패: \(errorMessage ?? "")") } }, - receiveValue: { [weak self] response in + receiveValue: { [weak self] newItems in guard let self else { return } - let newItems = response.alarms - - // 기존 목록에 추가 (서버에서 이미 정렬됨!) + // 새 데이터 추가 alarms.append(contentsOf: newItems) - // 다음 페이지로 이동 + // 다음 페이지 currentPage += 1 - // 받은 개수가 pageSize보다 적으면 더 이상 데이터 없음 + // 받은 개수가 pageSize보다 적으면 마지막 페이지 if newItems.count < pageSize { hasMoreData = false - print("🏁 마지막 페이지 도달: 받은 개수(\(newItems.count)) < 예상(\(pageSize))") } - - print("✅ 알림 목록 조회 성공: \(newItems.count)개 추가 (전체: \(alarms.count)개)") } ) .store(in: &cancellables) @@ -129,7 +104,6 @@ class NotificationListViewModel: ObservableObject { /// 새로고침 (처음부터 다시 로드) func refresh() { - print("🔄 알림 새로고침") alarms = [] currentPage = 0 hasMoreData = true @@ -139,7 +113,10 @@ class NotificationListViewModel: ObservableObject { /// 특정 아이템이 보일 때 호출 (무한 스크롤 트리거) func loadMoreIfNeeded(currentItem item: AlarmItem) { - // View에서 이미 threshold 체크했으므로 바로 로드 - loadAlarms() + // 마지막 아이템 근처에서만 더 불러오기 + guard let last = alarms.last else { return } + if item.id == last.id { + loadAlarms() + } } } diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index b2e39af..b4e0ad5 100644 --- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -9,49 +9,67 @@ import SwiftUI struct OnBoardingView: View { @EnvironmentObject var session: SessionStore + @Environment(\.colorScheme) private var colorScheme @State private var isLoading = false + @State private var didStartRegistration = false var body: some View { - VStack(spacing: 20) { - Text("환영합니다 👋") - .font(.largeTitle).bold() - Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - - if isLoading { - ProgressView("등록 중…") - .padding(.top, 8) - } else { - Button { - Task { - isLoading = true - defer { isLoading = false } - await session.registerIfNeeded() + ZStack { + VStack(spacing: 100) { + Text("오늘의 소리") + .font(.KoddiBold56) + .foregroundColor(colorScheme == .dark ? .white : .black) + .accessibilityAddTraits(.isHeader) + + VStack(spacing: 20) { + Image("play") + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + .accessibilityLabel("오늘의 소리 로고") + + if isLoading { + ProgressView("초기화 중…") } - } label: { - Text("시작하기") - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) } - .padding(.top, 12) } + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .offset(y: -80) - if let err = session.lastError { - Text(err) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.top, 8) + VStack { + Spacer() + if let err = session.lastError { + Text(err) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.bottom, 24) + } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(colorScheme == .dark ? Color.black : Color.white) + .task { + guard !didStartRegistration else { return } + didStartRegistration = true + isLoading = true + defer { isLoading = false } + await session.registerIfNeeded() + } + } +} + +struct OnBoardingView_Previews: PreviewProvider { + static var previews: some View { + Group { + OnBoardingView() + .environmentObject(SessionStore.preview) + .preferredColorScheme(.light) - // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) - // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { - // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) - // } + OnBoardingView() + .environmentObject(SessionStore.preview) + .preferredColorScheme(.dark) } - .padding(24) } } diff --git a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift b/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift deleted file mode 100644 index c057001..0000000 --- a/today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift +++ /dev/null @@ -1,317 +0,0 @@ -import Combine -import FirebaseMessaging -import SwiftUI -import UIKit - -@MainActor -final class AnonymousTestViewModel: ObservableObject { - @Published var deviceSecret: String = DeviceSecretGenerator.generate() - @Published var userId: String = "" - @Published var log: String = "" - @Published var isLoading: Bool = false - @Published var keychainData: [String: String] = [:] - - private let apiService = APIService() - private var cancellables: Set = [] - - // 키체인 데이터 로드 - func loadKeychainData() { - keychainData.removeAll() - - if let secret = Keychain.getString(for: KeychainKey.deviceSecret) { - keychainData["deviceSecret"] = secret - } else { - keychainData["deviceSecret"] = "(없음)" - } - - if let uid = Keychain.getString(for: KeychainKey.userId) { - keychainData["userId"] = uid - } else { - keychainData["userId"] = "(없음)" - } - - if let apiKey = Keychain.getString(for: KeychainKey.apiKey) { - keychainData["apiKey"] = apiKey - } else { - keychainData["apiKey"] = "(없음)" - } - - log = "🔑 키체인 데이터 로드 완료" - } - - // 키체인 초기화 - func clearKeychain() { - Keychain.delete(for: KeychainKey.deviceSecret) - Keychain.delete(for: KeychainKey.userId) - Keychain.delete(for: KeychainKey.apiKey) - - loadKeychainData() - log = "🗑️ 키체인 데이터 삭제 완료" - } - - // 디바이스 시크릿 재생성 - func regenerateDeviceSecret() { - deviceSecret = DeviceSecretGenerator.generate() - log = "새로운 deviceSecret 생성됨" - } - - // 익명 사용자 등록 - func registerAnonymous() { - isLoading = true - userId = "" - log = "📤 익명 사용자 등록 요청 중...\ndeviceSecret: \(deviceSecret.prefix(20))..." - - // 디바이스 모델과 FCM 토큰 가져오기 - let deviceModel = UIDevice.current.model - let fcmToken = Messaging.messaging().fcmToken - - // 요청 객체 생성 - let request = RegisterAnonymousRequest( - deviceSecret: deviceSecret, - model: deviceModel, - fcmToken: fcmToken - ) - - log += "\nModel: \(deviceModel)" - if let fcmToken { - log += "\nFCM Token: \(fcmToken.prefix(20))..." - } else { - log += "\nFCM Token: (없음)" - } - - apiService.registerAnonymous(request: request) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - guard let self else { return } - isLoading = false - - switch completion { - case .finished: - break - - case let .failure(error): - // 상세한 에러 메시지 - var errorLog = "❌ 등록 실패\n" - switch error { - case let .serverError(statusCode): - errorLog += "서버 오류 (상태: \(statusCode))" - - case let .decodingFailed(decodeError): - errorLog += "응답 처리 실패\n\(decodeError.localizedDescription)" - - case let .requestFailed(requestError): - errorLog += "요청 실패\n\(requestError.localizedDescription)" - - case .invalidURL: - errorLog += "잘못된 URL" - - case .unknown: - errorLog += "알 수 없는 오류" - } - - log = errorLog - print("❌ \(errorLog)") - } - }, - receiveValue: { [weak self] response in - guard let self else { return } - - userId = response.result.userId - - var successLog = "✅ 등록 성공!\n" - successLog += "━━━━━━━━━━━━━━━━━━\n" - successLog += "User ID: \(response.result.userId)\n" - successLog += "Message: \(response.message)\n" - if let errorCode = response.errorCode { - successLog += "Error Code: \(errorCode)\n" - } - successLog += "━━━━━━━━━━━━━━━━━━" - - log = successLog - print("✅ 익명 사용자 등록 성공: \(response.result.userId)") - } - ) - .store(in: &cancellables) - } -} - -struct AnonymousTestView: View { - @StateObject private var viewModel = AnonymousTestViewModel() - - var body: some View { - Form { - // MARK: - Device Secret 섹션 - - Section { - VStack(alignment: .leading, spacing: 8) { - Text("Device Secret") - .font(.caption) - .foregroundColor(.secondary) - - Text(viewModel.deviceSecret) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(.primary) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(8) - - Button(action: viewModel.regenerateDeviceSecret) { - HStack { - Image(systemName: "arrow.clockwise") - Text("새로 생성") - } - .font(.caption) - } - .buttonStyle(.borderless) - } - .padding(.vertical, 4) - } header: { - Label("디바이스 시크릿", systemImage: "key.fill") - } - - // MARK: - 동작 섹션 - - Section { - Button(action: viewModel.registerAnonymous) { - HStack { - if viewModel.isLoading { - ProgressView() - .progressViewStyle(.circular) - } else { - Image(systemName: "person.badge.plus") - } - Text(viewModel.isLoading ? "등록 중..." : "익명 사용자 등록") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - } - .disabled(viewModel.isLoading || viewModel.deviceSecret.isEmpty) - } header: { - Label("동작", systemImage: "bolt.fill") - } footer: { - Text("서버에 익명 사용자를 등록합니다.") - .font(.caption) - } - - // MARK: - 결과 섹션 - - if !viewModel.userId.isEmpty { - Section { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("User ID") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button(action: { - UIPasteboard.general.string = viewModel.userId - }) { - Label("복사", systemImage: "doc.on.doc") - .font(.caption) - } - .buttonStyle(.borderless) - } - - Text(viewModel.userId) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.green) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.green.opacity(0.1)) - .cornerRadius(8) - } - .padding(.vertical, 4) - } header: { - Label("결과", systemImage: "checkmark.circle.fill") - .foregroundColor(.green) - } - } - - // MARK: - 키체인 확인 섹션 - - Section { - Button(action: viewModel.loadKeychainData) { - HStack { - Image(systemName: "key.fill") - Text("키체인 데이터 확인") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - } - - if !viewModel.keychainData.isEmpty { - VStack(alignment: .leading, spacing: 12) { - ForEach(viewModel.keychainData.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in - VStack(alignment: .leading, spacing: 4) { - Text(key) - .font(.caption) - .foregroundColor(.secondary) - - Text(value) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(value == "(없음)" ? .red : .primary) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(value == "(없음)" ? Color.red.opacity(0.1) : Color.secondary.opacity(0.1)) - .cornerRadius(8) - } - } - } - .padding(.vertical, 8) - - Button(action: viewModel.clearKeychain) { - HStack { - Image(systemName: "trash.fill") - Text("키체인 초기화") - } - .font(.caption) - .foregroundColor(.red) - } - .buttonStyle(.borderless) - } - } header: { - Label("키체인 확인 (디버그)", systemImage: "externaldrive.fill") - } footer: { - Text("시뮬레이터에 저장된 Keychain 데이터를 확인합니다.") - .font(.caption) - } - - // MARK: - 로그 섹션 - - if !viewModel.log.isEmpty { - Section { - ScrollView { - Text(viewModel.log) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(viewModel.log.contains("❌") ? .red : - viewModel.log.contains("✅") ? .green : .secondary) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(minHeight: 100) - } header: { - Label("로그", systemImage: "text.alignleft") - } - } - } - .navigationTitle("익명 사용자 등록 테스트") - .navigationBarTitleDisplayMode(.inline) - .onAppear { - viewModel.loadKeychainData() - } - } -} - -#if DEBUG - struct AnonymousTestView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { AnonymousTestView() } - } - } -#endif diff --git a/today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift b/today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift deleted file mode 100644 index f249107..0000000 --- a/today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// DebugSettingsView.swift -// today-s-sound -// -// 디버그용 설정 화면 -// - -import SwiftUI - -struct DebugSettingsView: View { - @EnvironmentObject var sessionStore: SessionStore - @Environment(\.dismiss) var dismiss - @State private var showingAlert = false - - var body: some View { - NavigationView { - Form { - // MARK: - 키체인 정보 - - Section { - if let userId = sessionStore.userId { - VStack(alignment: .leading, spacing: 8) { - Text("User ID") - .font(.caption) - .foregroundColor(.secondary) - - Text(userId) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } else { - Text("User ID: (없음)") - .foregroundColor(.secondary) - } - - if let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) { - VStack(alignment: .leading, spacing: 8) { - Text("Device Secret") - .font(.caption) - .foregroundColor(.secondary) - - Text(deviceSecret.prefix(40) + "...") - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } else { - Text("Device Secret: (없음)") - .foregroundColor(.secondary) - } - } header: { - Label("키체인 정보", systemImage: "key.fill") - } - - // MARK: - 위험 구역 - - Section { - Button(role: .destructive, action: { - showingAlert = true - }) { - HStack { - Image(systemName: "trash.fill") - Text("키체인 초기화 (로그아웃)") - } - } - } header: { - Label("위험 구역", systemImage: "exclamationmark.triangle.fill") - } footer: { - Text("⚠️ 키체인을 초기화하면 다시 온보딩 화면으로 돌아갑니다.") - .font(.caption) - } - } - .navigationTitle("디버그 설정") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("닫기") { - dismiss() - } - } - } - .alert("키체인 초기화", isPresented: $showingAlert) { - Button("취소", role: .cancel) {} - Button("초기화", role: .destructive) { - sessionStore.logout() - dismiss() - } - } message: { - Text("모든 키체인 데이터를 삭제하고 처음부터 시작합니다.") - } - } - } -} - -#if DEBUG - struct DebugSettingsView_Previews: PreviewProvider { - static var previews: some View { - DebugSettingsView() - .environmentObject(SessionStore()) - } - } -#endif diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift index ca739ce..87f9324 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift @@ -12,24 +12,31 @@ struct AddSubscriptionButton: View { let onTap: () -> Void var body: some View { - VStack(spacing: 12) { + VStack(spacing: 16) { 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) - ) + 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/EmptyStateView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift deleted file mode 100644 index 1004f8e..0000000 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// 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 index 2c05c4b..cd6bfe6 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift @@ -14,12 +14,12 @@ struct StatusBadge: View { var body: some View { Text(text) .font(.system(size: 14, weight: .medium)) - .foregroundColor(colorScheme == .dark ? .white : .primaryGreen) + .foregroundColor(.primaryGreen) .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 20) - .fill(Color.badgeGreen) + .fill(Color.badgeGreenBackground) ) } } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift index d8e9100..5002d22 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -16,20 +16,23 @@ struct SubscriptionCardView: View { VStack(alignment: .leading, spacing: 8) { // 구독 이름 (alias) Text(subscription.alias) - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(Color.text(colorScheme)) + .font(.KoddiBold20) + .foregroundColor(Color.primaryGrey) + .accessibilityLabel("구독 페이지 이름: \(subscription.alias)") // URL Text(subscription.url) - .font(.system(size: 13)) - .foregroundColor(Color.secondaryText(colorScheme)) + .font(.KoddiRegular16) + .foregroundColor(Color.primaryGrey) .lineLimit(1) + .accessibilityLabel("주소: \(subscription.url)") // 키워드 배지들 if !subscription.keywords.isEmpty { HStack(spacing: 8) { ForEach(subscription.keywords.prefix(3)) { keyword in StatusBadge(text: keyword.name, colorScheme: colorScheme) + .accessibilityLabel("설정 키워드: \(keyword.name)") } // 더 많은 키워드가 있으면 "+" 표시 @@ -38,6 +41,7 @@ struct SubscriptionCardView: View { text: "+\(subscription.keywords.count - 3)", colorScheme: colorScheme ) + .accessibilityLabel("그외 \(subscription.keywords.count - 3)개") } } } @@ -47,16 +51,55 @@ struct SubscriptionCardView: View { // 긴급 알림 아이콘 Button(action: {}, label: { - Image(systemName: subscription.isUrgent ? "bell.fill" : "bell") - .font(.system(size: 40)) - .foregroundColor(subscription.isUrgent ? .red : .green) + Image(subscription.isUrgent ? "Bell" : "Bell off") + .frame(width: 40, height: 40) + .accessibilityLabel(subscription.isUrgent ? "긴급 알림 설정됨" : "긴급 알림 해제됨") }) + .accessibilityHint("탭하여 긴급 알림 설정을 변경합니다") } .padding(16) .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.secondaryBackground(colorScheme)) - .shadow(color: .black5, radius: 4, x: 0, y: 2) + RoundedRectangle(cornerRadius: 8) + .fill(Color.greyBackground) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.borderGrey, lineWidth: 1) + ) ) } } + +struct SubscriptionCardView_Previews: PreviewProvider { + private static let sampleSubscription = SubscriptionItem( + id: 1, + url: "https://newsroom.apple.com", + alias: "애플 뉴스룸", + isUrgent: false, + keywords: [ + KeywordItem(id: 1, name: "아이폰"), + KeywordItem(id: 2, name: "접근성"), + KeywordItem(id: 3, name: "애플워치"), + KeywordItem(id: 4, name: "iOS") + ] + ) + + static var previews: some View { + Group { + SubscriptionCardView( + subscription: sampleSubscription, + colorScheme: .light + ) + .padding() + .previewDisplayName("Light") + + SubscriptionCardView( + subscription: sampleSubscription, + colorScheme: .dark + ) + .padding() + .previewDisplayName("Dark") + .background(Color.black) + } + .previewLayout(.sizeThatFits) + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift deleted file mode 100644 index fbd8b4b..0000000 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// SubscriptionsListSection.swift -// today-s-sound -// -// Created by Assistant on 12/19/24. -// - -import SwiftUI - -struct SubscriptionsListSection: View { - let subscriptions: [SubscriptionItem] - let colorScheme: ColorScheme - let onLoadMore: (SubscriptionItem) -> Void - let onDelete: (SubscriptionItem) -> Void - let isLoadingMore: Bool - - var body: some View { - if subscriptions.isEmpty { - EmptyStateView(message: "구독 중인 페이지가 없어요.", colorScheme: colorScheme) - } else { - List { - ForEach(subscriptions) { subscription in - SubscriptionCardView(subscription: subscription, colorScheme: colorScheme) - .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - onDelete(subscription) - } label: { - Label("삭제", systemImage: "trash") - } - } - .onAppear { - // 마지막에서 5번째 아이템이 보일 때만 트리거 - if let lastIndex = subscriptions.indices.last, - let currentIndex = subscriptions.firstIndex(where: { $0.id == subscription.id }), - currentIndex >= lastIndex - 4 - { // 마지막에서 5번째부터 - onLoadMore(subscription) - } - } - } - - // 더 불러오는 중 인디케이터 - if isLoadingMore { - HStack { - Spacer() - ProgressView() - .padding() - Spacer() - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - } - } -} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index b8f09fd..95980d1 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -1,61 +1,110 @@ import SwiftUI struct SubscriptionListView: View { - @StateObject private var viewModel = SubscriptionListViewModel() + @StateObject private var viewModel: SubscriptionListViewModel @Environment(\.colorScheme) var colorScheme @State private var showAddSubscription = false + init(viewModel: SubscriptionListViewModel = SubscriptionListViewModel()) { + _viewModel = StateObject(wrappedValue: viewModel) + } + var body: some View { NavigationView { ZStack { Color.background(colorScheme) .ignoresSafeArea() - VStack(alignment: .leading, spacing: 12) { - Spacer() + VStack(spacing: 12) { ScreenMainTitle(text: "구독 설정", colorScheme: colorScheme) ScreenSubTitle(text: "구독 중인 페이지", colorScheme: colorScheme) + .padding(.top, 16) // 로딩 상태 if viewModel.isLoading, viewModel.subscriptions.isEmpty { Spacer() ProgressView("불러오는 중...") .progressViewStyle(CircularProgressViewStyle()) + .accessibilityLabel("구독 목록을 불러오는 중입니다") + .accessibilityHint("잠시만 기다려주세요") Spacer() } + // 에러 메시지 else if let errorMessage = viewModel.errorMessage { Spacer() VStack(spacing: 16) { - Text("⚠️") - .font(.system(size: 48)) Text(errorMessage) - .font(.system(size: 16)) + .font(.KoddiBold20) .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("오류: \(errorMessage)") + .padding(.bottom) + Button("다시 시도") { viewModel.refresh() } .padding(.horizontal, 24) .padding(.vertical, 12) + .font(.KoddiBold20) + .foregroundColor(Color.white) .background(Color.primaryGreen) - .foregroundColor(.white) .cornerRadius(8) + .accessibilityLabel("다시 시도 버튼") + .accessibilityHint("탭하여 구독 목록을 다시 불러옵니다") + } + Spacer() + } + // 빈 상태 + else if viewModel.subscriptions.isEmpty { + Spacer() + VStack(spacing: 16) { + Text("구독 중인 페이지가 없습니다") + .font(.KoddiBold20) + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("구독 중인 페이지가 없습니다") } Spacer() } - // 구독 목록 + + // 데이터 있을 때(main) else { - SubscriptionsListSection( - subscriptions: viewModel.subscriptions, - colorScheme: colorScheme, - onLoadMore: { item in - viewModel.loadMoreIfNeeded(currentItem: item) - }, - onDelete: { item in - viewModel.deleteSubscription(item) - }, - isLoadingMore: viewModel.isLoadingMore - ) + List { + ForEach(viewModel.subscriptions) { subscription in + SubscriptionCardView(subscription: subscription, colorScheme: colorScheme) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + viewModel.deleteSubscription(subscription) + } label: { + Label("삭제", systemImage: "trash") + } + } + .onAppear { + if let lastIndex = viewModel.subscriptions.indices.last, + let currentIndex = viewModel.subscriptions.firstIndex(where: { $0.id == subscription.id }), + currentIndex >= lastIndex - 4 + { + viewModel.loadMoreIfNeeded(currentItem: subscription) + } + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .padding() + .accessibilityLabel("추가 구독을 불러오는 중입니다") + Spacer() + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) } AddSubscriptionButton(colorScheme: colorScheme) { @@ -66,7 +115,7 @@ struct SubscriptionListView: View { .navigationBarHidden(true) .onAppear { // 처음 로드 - if viewModel.subscriptions.isEmpty { + if viewModel.subscriptions.isEmpty, !viewModel.disableAutoLoad { viewModel.loadSubscriptions() } } @@ -83,6 +132,18 @@ struct SubscriptionListView: View { struct SubscriptionListView_Previews: PreviewProvider { static var previews: some View { - SubscriptionListView() + Group { + SubscriptionListView(viewModel: .previewLoading) + .previewDisplayName("Loading") + + SubscriptionListView(viewModel: .previewError) + .previewDisplayName("Error") + + SubscriptionListView(viewModel: .previewEmpty) + .previewDisplayName("Empty") + + SubscriptionListView(viewModel: .previewData) + .previewDisplayName("With Data") + } } } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift index 99a79f6..f4bbe5c 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -13,6 +13,7 @@ class SubscriptionListViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var isLoadingMore: Bool = false @Published var errorMessage: String? + var disableAutoLoad: Bool = false private let apiService: APIService private var cancellables = Set() @@ -28,6 +29,8 @@ class SubscriptionListViewModel: ObservableObject { /// 구독 목록 불러오기 func loadSubscriptions() { + guard !disableAutoLoad else { return } + // 이미 로딩 중이거나 더 이상 데이터가 없으면 리턴 guard !isLoading, !isLoadingMore, hasMoreData else { print("⏸️ 로딩 중단: isLoading=\(isLoading), isLoadingMore=\(isLoadingMore), hasMoreData=\(hasMoreData)") @@ -114,6 +117,7 @@ class SubscriptionListViewModel: ObservableObject { /// 새로고침 (처음부터 다시 로드) func refresh() { + guard !disableAutoLoad else { return } print("🔄 새로고침") subscriptions = [] currentPage = 0 @@ -124,6 +128,7 @@ class SubscriptionListViewModel: ObservableObject { /// 특정 아이템이 보일 때 호출 (무한 스크롤 트리거) func loadMoreIfNeeded(currentItem item: SubscriptionItem) { + guard !disableAutoLoad else { return } // View에서 이미 threshold 체크했으므로 바로 로드 loadSubscriptions() } @@ -184,3 +189,69 @@ class SubscriptionListViewModel: ObservableObject { .store(in: &cancellables) } } + +#if DEBUG + extension SubscriptionListViewModel { + private static func sampleSubscriptions() -> [SubscriptionItem] { + [ + SubscriptionItem( + id: 1, + url: "https://newsroom.apple.com", + alias: "애플 뉴스룸", + isUrgent: false, + keywords: [ + KeywordItem(id: 1, name: "아이폰"), + KeywordItem(id: 2, name: "애플워치") + ] + ), + SubscriptionItem( + id: 2, + url: "https://blog.naver.com/accessibility", + alias: "접근성 블로그", + isUrgent: true, + keywords: [ + KeywordItem(id: 3, name: "시각"), + KeywordItem(id: 4, name: "보이스오버"), + KeywordItem(id: 5, name: "스크린리더") + ] + ) + ] + } + + static var previewLoading: SubscriptionListViewModel { + let vm = SubscriptionListViewModel(apiService: APIService()) + vm.disableAutoLoad = true + vm.isLoading = true + vm.subscriptions = [] + vm.errorMessage = nil + return vm + } + + static var previewError: SubscriptionListViewModel { + let vm = SubscriptionListViewModel(apiService: APIService()) + vm.disableAutoLoad = true + vm.isLoading = false + vm.subscriptions = [] + vm.errorMessage = "서버와 연결할 수 없습니다" + return vm + } + + static var previewEmpty: SubscriptionListViewModel { + let vm = SubscriptionListViewModel(apiService: APIService()) + vm.disableAutoLoad = true + vm.isLoading = false + vm.subscriptions = [] + vm.errorMessage = nil + return vm + } + + static var previewData: SubscriptionListViewModel { + let vm = SubscriptionListViewModel(apiService: APIService()) + vm.disableAutoLoad = true + vm.isLoading = false + vm.subscriptions = sampleSubscriptions() + vm.errorMessage = nil + return vm + } + } +#endif diff --git a/today-s-sound/Resources/Colors.swift b/today-s-sound/Resources/Colors.swift index dc07531..cebf6b3 100644 --- a/today-s-sound/Resources/Colors.swift +++ b/today-s-sound/Resources/Colors.swift @@ -2,7 +2,7 @@ // Colors.swift // today-s-sound // -// Created by Assistant on 12/19/24. +// Created by 하승연 on 18/11/25. // import SwiftUI @@ -14,13 +14,22 @@ extension Color { 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) + static let urgentPink = Color(red: 255 / 255, green: 76 / 255, blue: 186 / 255) /// 배지 배경 그린 색상 (Badge Background Green) - static let badgeGreen = Color(red: 52 / 255, green: 199 / 255, blue: 89 / 255, opacity: 0.16) + static let badgeGreenBackground = 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) + /// 구독 페이지 목록 배경 + static let greyBackground = Color(red: 245 / 255, green: 245 / 255, blue: 245 / 255) + + /// 구독 페이지 목록 폰트 + static let primaryGrey = Color(red: 51 / 255, green: 51 / 255, blue: 51 / 255) + + /// 새 페이지 추가 입력 + static let secondaryGrey = Color(red: 115 / 255, green: 115 / 255, blue: 115 / 255) + + /// 페이지 목록 테두리 + static let borderGrey = Color(red: 197 / 255, green: 197 / 255, blue: 197 / 255) } // MARK: - Semantic Colors @@ -60,9 +69,6 @@ extension Color { // 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) diff --git a/today-s-sound/Resources/Fonts.swift b/today-s-sound/Resources/Fonts.swift index 3064588..db33503 100644 --- a/today-s-sound/Resources/Fonts.swift +++ b/today-s-sound/Resources/Fonts.swift @@ -33,4 +33,32 @@ extension Font { static var KoddiBold56: Font { .koddi(type: .bold, size: 56) } + + static var KoddiBold48: Font { + .koddi(type: .bold, size: 48) + } + + static var KoddiExtraBold32: Font { + .koddi(type: .extraBold, size: 32) + } + + static var KoddiBold28: Font { + .koddi(type: .bold, size: 28) + } + + static var KoddiExtraBold28: Font { + .koddi(type: .extraBold, size: 28) + } + + static var KoddiBold20: Font { + .koddi(type: .bold, size: 20) + } + + static var KoddiRegular16: Font { + .koddi(type: .regular, size: 16) + } + + static var KoddiBold14: Font { + .koddi(type: .bold, size: 14) + } }