From 93e3166951c399cc0e9b056391b1c77ab4b3f6e0 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Mon, 17 Nov 2025 19:59:03 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat/#12]=20UI=20=EA=B0=9C=EC=84=A0-1=20-?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=ED=95=9C=20=EC=97=90=EC=85=8B=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EB=B7=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- today-s-sound.xcodeproj/project.pbxproj | 10 +++ .../AppIcon.appiconset/Contents.json | 1 + .../AppIcon.appiconset/thumbnail.png | Bin 0 -> 72441 bytes .../mail.imageset/Contents.json | 21 +++++ .../Assets.xcassets/mail.imageset/mail.svg | 9 +++ .../notice.imageset/Contents.json | 21 +++++ .../notice.imageset/notice.svg | 9 +++ .../play.imageset/Contents.json | 21 +++++ .../Assets.xcassets/play.imageset/play.svg | 9 +++ .../Base/Component/ScreenMainTitle.swift | 2 +- .../Base/Component/ScreenSubTitle.swift | 2 +- .../Features/OnBoarding/OnBoardingView.swift | 72 +++++++++--------- today-s-sound/Resources/Fonts.swift | 28 +++++++ 13 files changed, 169 insertions(+), 36 deletions(-) create mode 100644 today-s-sound/Assets.xcassets/AppIcon.appiconset/thumbnail.png create mode 100644 today-s-sound/Assets.xcassets/mail.imageset/Contents.json create mode 100644 today-s-sound/Assets.xcassets/mail.imageset/mail.svg create mode 100644 today-s-sound/Assets.xcassets/notice.imageset/Contents.json create mode 100644 today-s-sound/Assets.xcassets/notice.imageset/notice.svg create mode 100644 today-s-sound/Assets.xcassets/play.imageset/Contents.json create mode 100644 today-s-sound/Assets.xcassets/play.imageset/play.svg 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 0000000000000000000000000000000000000000..9292c3a97aa9c599401734a8f890e286aa1a25a9 GIT binary patch literal 72441 zcmeEN_dC`9`#<(dBr7r^gsd`(I4ZJ2C3_qtAuBtaXMNA{{vWV5Gh>0$7=; z52%RzE9!&oxuIKbLzIQtXp6ZqsR%UkLe+BGSc81w7s=pt7a+}Ra+=SjJ##H=5Vq)Uu>cedN zyOSBqlh^#NO2}7D{|&2>Bcql(5dN|6|Gxj*iRh_Y&bE&+l%k#v0GP3pXF7Rt2)}sp z1PB6n{(CtEVEgZdIg)mB>OTfu`jclQojBde3mTAk@&q^o4F7vk2K;ZC|Ht;6A48AFKYgeQT&f4{r^!&8F~O(I;2z1@kXKf;PF8P)eC3XC8+F- zTXt=*3A>uSHi#j*UneO69T$*5Ij$SnUG6*^m3}Mh z!7bp(rLM(;&u|5|ZK7zBuvlfi4 |BWY-I%Hux#&N@NPY;7|i+xw%HV%4me{USP3 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%0AT3vM{ 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

8V8|ymw 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 VF0QHZyPfa5YI5DU_>yzv{{bC#l|cXi literal 0 HcmV?d00001 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/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/Presentation/Base/Component/ScreenMainTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift index fd1699c..bc37586 100644 --- a/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift +++ b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift @@ -14,7 +14,7 @@ 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) .padding(.horizontal, 24) diff --git a/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift index 5065b2d..4df98d7 100644 --- a/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift +++ b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift @@ -13,7 +13,7 @@ struct ScreenSubTitle: View { var body: some View { Text(text) - .font(.system(size: 28, weight: .bold)) + .font(.KoddiExtraBold28) .foregroundColor(colorScheme == .dark ? .white : .primaryGreen) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index b2e39af..f2982b3 100644 --- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -9,49 +9,53 @@ 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) + ZStack { + VStack(spacing: 100) { + Text("오늘의 소리") + .font(.KoddiBold56) + .foregroundColor(colorScheme == .dark ? .white : .black) + .accessibilityAddTraits(.isHeader) - if isLoading { - ProgressView("등록 중…") - .padding(.top, 8) - } else { - Button { - Task { - isLoading = true - defer { isLoading = false } - await session.registerIfNeeded() + 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) + } } - - // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) - // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { - // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) - // } } - .padding(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() + } } } 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) + } } From e9701820a3c6906da9c78d68ff41f32c3a6aabb7 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Tue, 18 Nov 2025 18:53:08 +0900 Subject: [PATCH 2/5] =?UTF-8?q?main=20=EB=B7=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/AppState/SessionStore.swift | 12 +++++ .../Component/KeywordCheckboxRow.swift | 23 +++++++++ .../Features/Main/Home/HomeView.swift | 35 ++++++------- .../Features/OnBoarding/OnBoardingView.swift | 14 ++++++ .../Component/AddSubscriptionButton.swift | 9 ++++ .../Component/EmptyStateView.swift | 21 ++++++++ .../Component/SubscriptionCardView.swift | 35 +++++++++++++ .../Component/SubscriptionsListSection.swift | 49 +++++++++++++++++++ today-s-sound/today-s-sound.entitlements | 5 +- 9 files changed, 180 insertions(+), 23 deletions(-) diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift index b9a7660..6c390c3 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/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/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift index 01954a3..c577577 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -24,8 +24,7 @@ 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: { @@ -34,12 +33,11 @@ struct HomeView: View { } }, label: { - Image(systemName: "play.fill") + Image("play") .resizable() .scaledToFit() - .frame(width: 120, height: 120) - .foregroundColor(Color.primaryGreen90) - .padding(40) + .frame(width: 180, height: 180) + .padding(20) } ) .padding(.bottom, 60) @@ -50,13 +48,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.primaryGreen90) } ) Text(String(format: "%.1f x", viewModel.playbackRate)) - .font(.system(size: 48, weight: .bold)) + .font(.KoddiBold48) .foregroundColor(Color.text(colorScheme)) .monospacedDigit() .frame(minWidth: 100) @@ -65,22 +63,21 @@ 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.primaryGreen90) } ) } - - 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) + .font(.KoddiExtraBold32) + .foregroundColor(colorScheme == .dark ? .black : .white) .padding(.horizontal, 24) .padding(.vertical, 12) .frame(width: 340, height: 85) @@ -90,7 +87,7 @@ struct HomeView: View { ) .foregroundColor(.white) } - .padding(.bottom, 32) + .padding(.bottom, 16) } } } diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index f2982b3..b4e0ad5 100644 --- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -59,3 +59,17 @@ struct OnBoardingView: View { } } } + +struct OnBoardingView_Previews: PreviewProvider { + static var previews: some View { + Group { + OnBoardingView() + .environmentObject(SessionStore.preview) + .preferredColorScheme(.light) + + OnBoardingView() + .environmentObject(SessionStore.preview) + .preferredColorScheme(.dark) + } + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift index ca739ce..6eef6ee 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift @@ -33,3 +33,12 @@ struct AddSubscriptionButton: View { .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 index 1004f8e..be5eb2b 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift @@ -22,3 +22,24 @@ struct EmptyStateView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +struct EmptyStateView_Previews: PreviewProvider { + static var previews: some View { + Group { + EmptyStateView( + message: "구독 중인 페이지가 없어요.", + colorScheme: .light + ) + .previewDisplayName("Light") + + EmptyStateView( + message: "최근 알림이 없어요.", + colorScheme: .dark + ) + .previewDisplayName("Dark") + .background(Color.black) + } + .previewLayout(.sizeThatFits) + .padding() + } +} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift index d8e9100..2bbd76c 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -60,3 +60,38 @@ struct SubscriptionCardView: View { ) } } + +struct SubscriptionCardView_Previews: PreviewProvider { + private static let sampleSubscription = SubscriptionItem( + id: 1, + url: "https://newsroom.apple.com", + alias: "애플 뉴스룸", + isUrgent: true, + 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 index fbd8b4b..e683c97 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift @@ -59,3 +59,52 @@ struct SubscriptionsListSection: View { } } } + +struct SubscriptionsListSection_Previews: PreviewProvider { + private static let 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 previews: some View { + Group { + SubscriptionsListSection( + subscriptions: sampleSubscriptions, + colorScheme: .light, + onLoadMore: { _ in }, + onDelete: { _ in }, + isLoadingMore: true + ) + .previewDisplayName("List - Light") + + SubscriptionsListSection( + subscriptions: [], + colorScheme: .dark, + onLoadMore: { _ in }, + onDelete: { _ in }, + isLoadingMore: false + ) + .previewDisplayName("Empty - Dark") + .background(Color.black) + } + } +} diff --git a/today-s-sound/today-s-sound.entitlements b/today-s-sound/today-s-sound.entitlements index 903def2..0c67376 100644 --- a/today-s-sound/today-s-sound.entitlements +++ b/today-s-sound/today-s-sound.entitlements @@ -1,8 +1,5 @@ - - aps-environment - development - + From 1ff6651dce300723106af50f23c20978eec86eb2 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Wed, 19 Nov 2025 02:33:16 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20UI=20=EA=B0=9C=EC=84=A0=20-=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=99=94=EB=A9=B4=20=EC=8A=A4=ED=94=BC?= =?UTF-8?q?=EC=B9=98=20=EA=B8=B0=EB=8A=A5=20=EC=A3=BC=EC=84=9D=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20-=20=EC=95=8C=EB=A6=BC=20=EC=B9=B4=EB=93=9C=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0(=EC=9E=84=EC=8B=9C)=20-=20MainView=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EB=B0=94=20UI=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=BC=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20FeedV?= =?UTF-8?q?iew=20=EC=B6=94=EA=B0=80=20-=20=EA=B5=AC=EB=8F=85=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bell off.imageset/Bell off.svg | 10 + .../Bell off.imageset/Contents.json | 21 ++ .../Assets.xcassets/Bell.imageset/Bell.svg | 3 + .../Bell.imageset/Contents.json | 21 ++ .../Base/Component/ScreenMainTitle.swift | 3 +- .../Base/Component/ScreenSubTitle.swift | 2 +- .../AddSubscription/AddSubscriptionView.swift | 6 +- .../Features/Feed/FeedModel.swift | 43 +++ .../Presentation/Features/Feed/FeedView.swift | 143 ++++++++ .../Features/Feed/FeedViewModel.swift | 23 ++ .../Features/Main/Home/HomeView.swift | 14 +- .../Presentation/Features/Main/MainView.swift | 74 ++-- .../NotificationList/AlertCardView.swift | 71 ++-- .../Component/AlarmGroupView.swift | 136 -------- .../NotificationListView.swift | 106 +++++- .../NotificationListViewModel.swift | 2 + .../PostsDemo/AnonymousTestView.swift | 317 ------------------ .../Features/Settings/DebugSettingsView.swift | 101 ------ .../Component/AddSubscriptionButton.swift | 24 +- .../Component/EmptyStateView.swift | 45 --- .../Component/StatusBadge.swift | 4 +- .../Component/SubscriptionCardView.swift | 30 +- .../Component/SubscriptionsListSection.swift | 110 ------ .../SubscriptionListView.swift | 103 ++++-- .../SubscriptionListViewModel.swift | 71 ++++ today-s-sound/Resources/Colors.swift | 23 +- today-s-sound/today-s-sound.entitlements | 5 +- 27 files changed, 637 insertions(+), 874 deletions(-) create mode 100644 today-s-sound/Assets.xcassets/Bell off.imageset/Bell off.svg create mode 100644 today-s-sound/Assets.xcassets/Bell off.imageset/Contents.json create mode 100644 today-s-sound/Assets.xcassets/Bell.imageset/Bell.svg create mode 100644 today-s-sound/Assets.xcassets/Bell.imageset/Contents.json create mode 100644 today-s-sound/Presentation/Features/Feed/FeedModel.swift create mode 100644 today-s-sound/Presentation/Features/Feed/FeedView.swift create mode 100644 today-s-sound/Presentation/Features/Feed/FeedViewModel.swift delete mode 100644 today-s-sound/Presentation/Features/NotificationList/Component/AlarmGroupView.swift delete mode 100644 today-s-sound/Presentation/Features/PostsDemo/AnonymousTestView.swift delete mode 100644 today-s-sound/Presentation/Features/Settings/DebugSettingsView.swift delete mode 100644 today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift delete mode 100644 today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift 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/Presentation/Base/Component/ScreenMainTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift index bc37586..8b60440 100644 --- a/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift +++ b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift @@ -16,7 +16,8 @@ struct ScreenMainTitle: View { Text(text) .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 4df98d7..3c70c47 100644 --- a/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift +++ b/today-s-sound/Presentation/Base/Component/ScreenSubTitle.swift @@ -14,7 +14,7 @@ struct ScreenSubTitle: View { var body: some View { Text(text) .font(.KoddiExtraBold28) - .foregroundColor(colorScheme == .dark ? .white : .primaryGreen) + .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/Feed/FeedModel.swift b/today-s-sound/Presentation/Features/Feed/FeedModel.swift new file mode 100644 index 0000000..f3bec4c --- /dev/null +++ b/today-s-sound/Presentation/Features/Feed/FeedModel.swift @@ -0,0 +1,43 @@ +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(-3_600) + ), + FeedItem( + id: UUID(), + title: "서울시청, 공공 서비스 음성 지원 확대 발표", + summary: "민원 앱 내 보이스오버 전용 모드를 도입해 정보 접근성을 높입니다.", + source: "서울시청 뉴스룸", + publishedAt: Date().addingTimeInterval(-8_400) + ), + FeedItem( + id: UUID(), + title: "오늘의 소리 사용자 인터뷰", + summary: "베타 사용자들이 직접 전해준 알림 읽기 경험과 개선 아이디어를 소개합니다.", + source: "오늘의 소리 팀", + publishedAt: Date().addingTimeInterval(-18_000) + ) + ] +} + 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..5cdafa9 --- /dev/null +++ b/today-s-sound/Presentation/Features/Feed/FeedView.swift @@ -0,0 +1,143 @@ +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..895b85d --- /dev/null +++ b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift @@ -0,0 +1,23 @@ +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 c577577..49af92d 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -49,7 +49,7 @@ struct HomeView: View { label: { Image(systemName: "minus") .font(.KoddiBold48) - .foregroundColor(Color.primaryGreen90) + .foregroundColor(Color.primaryGreen) } ) @@ -64,7 +64,7 @@ struct HomeView: View { label: { Image(systemName: "plus") .font(.KoddiBold48) - .foregroundColor(Color.primaryGreen90) + .foregroundColor(Color.primaryGreen) } ) } @@ -78,12 +78,12 @@ struct HomeView: View { Text(viewModel.currentCategoryName) .font(.KoddiExtraBold32) .foregroundColor(colorScheme == .dark ? .black : .white) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .frame(width: 340, height: 85) + .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) } diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift index 7fa25ad..2b1c849 100644 --- a/today-s-sound/Presentation/Features/Main/MainView.swift +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -1,75 +1,47 @@ 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.rectangle") + 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("구독") } + .tag(Tab.subscriptions) - #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 } + .tint(.primaryGreen) } } diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift index 8eb2723..7736b2f 100644 --- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -63,22 +63,24 @@ struct AlertCardView: View { VStack(spacing: 20) { // 상단: 타이틀과 아이콘 HStack(alignment: .top, spacing: 12) { - Image(systemName: isUrgent ? "bell.fill" : "doc.fill") - .font(.system(size: 24)) - .foregroundColor(.white) + Image(isUrgent ? "mail" : "notice") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .foregroundColor(colorScheme == .dark ? .black : .white) .accessibilityHidden(true) // 아이콘은 시각적 장식이므로 숨김 VStack(alignment: .leading, spacing: 8) { Text(title) - .font(.system(size: 20, weight: .bold)) + .font(.KoddiExtraBold32) .foregroundColor(.white) .multilineTextAlignment(.leading) .accessibilityAddTraits(.isHeader) // 헤더로 인식 .accessibilityLabel(title) Text(timeText) - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.9)) + .font(.KoddiExtraBold28) + .foregroundColor(colorScheme == .dark ? .black : .white) .accessibilityLabel("\(timeText)에 받은 알림") } @@ -87,6 +89,7 @@ struct AlertCardView: View { // 하단: 음성으로 듣기 버튼 Button(action: { + /* if isPlaying { // 재생 중단 SpeechService.shared.stop() @@ -96,6 +99,7 @@ struct AlertCardView: View { // VoiceOver 알림 UIAccessibility.post(notification: .announcement, argument: "재생이 중단되었습니다") + } else { // 재생 시작 playAllSummaries() @@ -108,6 +112,7 @@ struct AlertCardView: View { UIAccessibility.post(notification: .announcement, argument: "알림 내용을 재생합니다") } } + */ }, label: { HStack(spacing: 8) { Image(systemName: isPlaying ? "stop.circle.fill" : "speaker.wave.2.fill") @@ -184,8 +189,8 @@ struct AlertCardView: View { } } + /* // MARK: - 음성 재생 함수 - private func playAllSummaries() { guard let alarm, !alarm.summaries.isEmpty else { // AlarmItem이 없으면 Alert의 title만 재생 @@ -297,33 +302,39 @@ struct AlertCardView: View { return "\(index + 1)번째 내용" } } + */ } struct AlertCardView_Previews: PreviewProvider { + private static let sampleAlert = Alert( + id: UUID(), + title: "긴급 공지: 서비스 점검 안내", + content: "오늘 밤 11시부터 자정까지 점검이 진행됩니다.", + date: Date().addingTimeInterval(-7200), + isUrgent: true + ) + + private static let sampleAlarm = AlarmItem( + alias: "접근성 블로그", + timeAgo: "3분 전", + summaries: [ + SummaryItem(id: 1, summary: "애플이 새로운 보이스오버 기능을 발표했습니다.", updatedAt: "2024-12-19T09:00:00Z"), + SummaryItem(id: 2, summary: "iOS 18에서 접근성 옵션이 대폭 개선됩니다.", updatedAt: "2024-12-19T09:05:00Z") + ], + isUrgent: false + ) + static var previews: some View { - VStack(spacing: 16) { - AlertCardView( - alert: Alert( - id: UUID(), - title: "일이삼사오육칠팔", - content: "공지 내용 예시", - date: Date().addingTimeInterval(-7200), - isUrgent: true - ), - colorScheme: .light - ) - - AlertCardView( - alert: Alert( - id: UUID(), - title: "잡코리아 채용 공고", - content: "채용 소식", - date: Date().addingTimeInterval(-10800), - isUrgent: false - ), - colorScheme: .dark - ) + Group { + AlertCardView(alert: sampleAlert, colorScheme: .light) + .padding() + .previewDisplayName("Alert - 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..e7590c8 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -1,9 +1,13 @@ 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 { @@ -11,7 +15,6 @@ struct NotificationListView: View { .ignoresSafeArea() VStack(spacing: 0) { - Spacer() ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) // 로딩 상태 @@ -23,47 +26,44 @@ struct NotificationListView: View { .accessibilityHint("잠시만 기다려주세요") Spacer() } + // 에러 메시지 else if let errorMessage = viewModel.errorMessage { Spacer() VStack(spacing: 16) { - Text("⚠️") - .font(.system(size: 48)) - .accessibilityHidden(true) // 이모지는 숨김, 텍스트로 전달 - Text(errorMessage) - .font(.system(size: 16)) + .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("이중탭하여 알림 목록을 다시 불러옵니다") + .accessibilityHint("탭하여 알림 목록을 다시 불러옵니다") } Spacer() } + // 빈 상태 else if viewModel.alarms.isEmpty { Spacer() VStack(spacing: 16) { - Text("📭") - .font(.system(size: 48)) - .accessibilityHidden(true) // 이모지는 숨김 - Text("최근 알림이 없습니다") - .font(.system(size: 16)) + .font(.KoddiBold20) .foregroundColor(Color.secondaryText(colorScheme)) .accessibilityLabel("최근 알림이 없습니다") } Spacer() } + // 알림 목록 else { ScrollView { @@ -108,7 +108,7 @@ struct NotificationListView: View { .navigationBarHidden(true) .onAppear { // 처음 로드 - if viewModel.alarms.isEmpty { + if viewModel.alarms.isEmpty, !viewModel.disableAutoLoad { viewModel.loadAlarms() } } @@ -118,6 +118,80 @@ struct NotificationListView: View { struct NotificationListView_Previews: PreviewProvider { static var previews: some View { - NotificationListView() + Group { + NotificationListView(viewModel: .previewLoading) + .previewDisplayName("Loading") + + NotificationListView(viewModel: .previewError) + .previewDisplayName("Error") + + NotificationListView(viewModel: .previewEmpty) + .previewDisplayName("Empty") + + NotificationListView(viewModel: .previewData) + .previewDisplayName("With Data") + } + } +} + +#if DEBUG +extension NotificationListViewModel { + private static func sampleAlarms() -> [AlarmItem] { + [ + AlarmItem( + alias: "접근성 블로그", + timeAgo: "3분 전", + summaries: [ + SummaryItem(id: 1, summary: "애플이 새로운 보이스오버 기능을 발표했습니다.", updatedAt: "2024-12-19T09:00:00Z"), + SummaryItem(id: 2, summary: "iOS 18에서 접근성 옵션이 대폭 개선됩니다.", updatedAt: "2024-12-19T09:05:00Z") + ], + isUrgent: false + ), + AlarmItem( + alias: "오늘의 소리 알림", + timeAgo: "10분 전", + summaries: [ + SummaryItem(id: 3, summary: "오늘의 소리에서 새 음성이 도착했습니다.", updatedAt: "2024-12-19T08:50:00Z") + ], + 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..f9adc68 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,6 +29,7 @@ class NotificationListViewModel: ObservableObject { /// 알림 목록 불러오기 func loadAlarms() { + guard !disableAutoLoad else { return } print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━") print("📞 loadAlarms() 호출됨!") print("━━━━━━━━━━━━━━━━━━━━━━━━━━") 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 6eef6ee..5f6d2ca 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift @@ -12,21 +12,19 @@ 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) - ) + .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) 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 be5eb2b..0000000 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/EmptyStateView.swift +++ /dev/null @@ -1,45 +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) - } -} - -struct EmptyStateView_Previews: PreviewProvider { - static var previews: some View { - Group { - EmptyStateView( - message: "구독 중인 페이지가 없어요.", - colorScheme: .light - ) - .previewDisplayName("Light") - - EmptyStateView( - message: "최근 알림이 없어요.", - colorScheme: .dark - ) - .previewDisplayName("Dark") - .background(Color.black) - } - .previewLayout(.sizeThatFits) - .padding() - } -} 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 2bbd76c..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,20 @@ 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) + ) ) } } @@ -66,7 +74,7 @@ struct SubscriptionCardView_Previews: PreviewProvider { id: 1, url: "https://newsroom.apple.com", alias: "애플 뉴스룸", - isUrgent: true, + isUrgent: false, keywords: [ KeywordItem(id: 1, name: "아이폰"), KeywordItem(id: 2, name: "접근성"), 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 e683c97..0000000 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionsListSection.swift +++ /dev/null @@ -1,110 +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) - } - } -} - -struct SubscriptionsListSection_Previews: PreviewProvider { - private static let 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 previews: some View { - Group { - SubscriptionsListSection( - subscriptions: sampleSubscriptions, - colorScheme: .light, - onLoadMore: { _ in }, - onDelete: { _ in }, - isLoadingMore: true - ) - .previewDisplayName("List - Light") - - SubscriptionsListSection( - subscriptions: [], - colorScheme: .dark, - onLoadMore: { _ in }, - onDelete: { _ in }, - isLoadingMore: false - ) - .previewDisplayName("Empty - Dark") - .background(Color.black) - } - } -} diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index b8f09fd..f731e9c 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..adfb083 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..731e6b2 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) - - /// 카드 그레이 색상 (Card Grey) - static let cardGrey = Color(red: 245 / 255, green: 245 / 255, blue: 245 / 255) + static let badgeGreenBackground = Color(red: 52 / 255, green: 199 / 255, blue: 89 / 255, opacity: 0.16) + + /// 구독 페이지 목록 배경 + 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,8 +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/today-s-sound.entitlements b/today-s-sound/today-s-sound.entitlements index 0c67376..903def2 100644 --- a/today-s-sound/today-s-sound.entitlements +++ b/today-s-sound/today-s-sound.entitlements @@ -1,5 +1,8 @@ - + + aps-environment + development + From ffd2bad2fb9d47ff48fc64089813fffab45f5586 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sun, 30 Nov 2025 16:06:17 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20UI=20=EA=B0=9C=EC=84=A0=20-2=20-=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=97=90=EC=85=8B=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=B5=9C=EA=B7=BC=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B7=B0=20=EA=B0=9C=EC=84=A0=20-=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=94=ED=85=80=20=EB=B0=94=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20-=20=ED=94=BC=EB=93=9C=EB=B7=B0=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EA=B0=81=20=EB=B7=B0=EC=97=90=20=EB=82=98=ED=83=80=EB=82=98?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pause.imageset/Contents.json | 21 ++ .../Assets.xcassets/pause.imageset/pause.svg | 9 + today-s-sound/Data/Models/Alarm.swift | 78 ++--- .../Features/Main/Home/HomeView.swift | 13 +- .../Presentation/Features/Main/MainView.swift | 2 +- .../NotificationList/AlertCardView.swift | 314 +++--------------- .../NotificationListView.swift | 185 +++++------ .../NotificationListViewModel.swift | 67 ++-- 8 files changed, 201 insertions(+), 488 deletions(-) create mode 100644 today-s-sound/Assets.xcassets/pause.imageset/Contents.json create mode 100644 today-s-sound/Assets.xcassets/pause.imageset/pause.svg 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/Data/Models/Alarm.swift b/today-s-sound/Data/Models/Alarm.swift index 7974fd8..4f67f42 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/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift index 49af92d..7ebddb3 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 { @@ -28,18 +29,24 @@ struct HomeView: View { 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("play") + Image(speechService.isSpeaking ? "pause" : "play") .resizable() .scaledToFit() .frame(width: 180, height: 180) .padding(20) } ) + .accessibilityLabel(speechService.isSpeaking ? "재생 중단 버튼" : "재생 시작 버튼") + .accessibilityHint(speechService.isSpeaking ? "이중탭하여 재생을 중단합니다" : "이중탭하여 알림을 재생합니다") .padding(.bottom, 60) // 속도 조절 diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift index 2b1c849..b9e4295 100644 --- a/today-s-sound/Presentation/Features/Main/MainView.swift +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -21,7 +21,7 @@ struct MainView: View { FeedView() .tabItem { - Image(systemName: "text.rectangle") + Image(systemName: "text.bubble.fill") Text("피드") } .tag(Tab.feed) diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift index 7736b2f..024dce5 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,275 +18,79 @@ struct AlertCardView: View { } var body: some View { - VStack(spacing: 20) { - // 상단: 타이틀과 아이콘 + VStack(alignment: .leading, spacing: 16) { + // 상단: 아이콘 + 제목 + 시간 HStack(alignment: .top, spacing: 12) { - Image(isUrgent ? "mail" : "notice") - .resizable() - .scaledToFit() - .frame(width: 48, height: 48) - .foregroundColor(colorScheme == .dark ? .black : .white) - .accessibilityHidden(true) // 아이콘은 시각적 장식이므로 숨김 - - VStack(alignment: .leading, spacing: 8) { - Text(title) + 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(.white) + .foregroundColor(colorScheme == .dark ? .black : .white) .multilineTextAlignment(.leading) - .accessibilityAddTraits(.isHeader) // 헤더로 인식 - .accessibilityLabel(title) - Text(timeText) + Text(alarm.timeAgo) .font(.KoddiExtraBold28) .foregroundColor(colorScheme == .dark ? .black : .white) - .accessibilityLabel("\(timeText)에 받은 알림") } 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 "대기 중" - } - } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(alarm.isUrgent ? "긴급 알림" : "알림"), \(alarm.alias), \(alarm.timeAgo)") } - - /* - // 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)번째 내용" - } - } - */ } struct AlertCardView_Previews: PreviewProvider { - private static let sampleAlert = Alert( - id: UUID(), - title: "긴급 공지: 서비스 점검 안내", - content: "오늘 밤 11시부터 자정까지 점검이 진행됩니다.", - date: Date().addingTimeInterval(-7200), - isUrgent: true - ) - private static let sampleAlarm = AlarmItem( + subscriptionId: 1, alias: "접근성 블로그", + summaryContent: "애플이 새로운 보이스오버 기능을 발표했습니다.", timeAgo: "3분 전", - summaries: [ - SummaryItem(id: 1, summary: "애플이 새로운 보이스오버 기능을 발표했습니다.", updatedAt: "2024-12-19T09:00:00Z"), - SummaryItem(id: 2, summary: "iOS 18에서 접근성 옵션이 대폭 개선됩니다.", updatedAt: "2024-12-19T09:05:00Z") - ], isUrgent: false ) static var previews: some View { Group { - AlertCardView(alert: sampleAlert, colorScheme: .light) + AlertCardView(alarm: sampleAlarm, colorScheme: .light) .padding() - .previewDisplayName("Alert - Light") + .previewDisplayName("Alarm - Light") AlertCardView(alarm: sampleAlarm, colorScheme: .dark) .padding() diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index e7590c8..3e8d5a7 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -13,146 +13,117 @@ struct NotificationListView: View { ZStack { Color.background(colorScheme) .ignoresSafeArea() + VStack(alignment: .leading, spacing: 16) { + ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) + .padding(.horizontal, 20) - VStack(spacing: 0) { - ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) - - // 로딩 상태 - if viewModel.isLoading, viewModel.alarms.isEmpty { - Spacer() - ProgressView("불러오는 중...") - .progressViewStyle(CircularProgressViewStyle()) - .accessibilityLabel("알림 목록을 불러오는 중입니다") - .accessibilityHint("잠시만 기다려주세요") - Spacer() + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.bottom, 16) } - - // 에러 메시지 - else if let errorMessage = viewModel.errorMessage { - Spacer() - VStack(spacing: 16) { - Text(errorMessage) - .font(.KoddiBold20) - .foregroundColor(Color.secondaryText(colorScheme)) - .accessibilityLabel("오류: \(errorMessage)") - .padding(.bottom) + } + } + .onAppear { + viewModel.loadAlarms() + } + } - 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() + @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() } - - // 알림 목록 - 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, 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, 16) - .padding(.top, 8) } - .refreshable { - viewModel.refresh() + + if viewModel.isLoadingMore { + ProgressView() + .padding() } - .accessibilityLabel("알림 목록") - .accessibilityHint("총 \(viewModel.alarms.count)개의 알림이 있습니다. 아래로 당겨서 새로고침할 수 있습니다") } - } - } - .navigationBarHidden(true) - .onAppear { - // 처음 로드 - if viewModel.alarms.isEmpty, !viewModel.disableAutoLoad { - viewModel.loadAlarms() + .padding(.horizontal, 20) + .padding(.bottom, 16) } } } - } } +#if DEBUG struct NotificationListView_Previews: PreviewProvider { static var previews: some View { Group { - NotificationListView(viewModel: .previewLoading) - .previewDisplayName("Loading") - - NotificationListView(viewModel: .previewError) - .previewDisplayName("Error") + NotificationListView(viewModel: .previewData) + .environment(\.colorScheme, .light) NotificationListView(viewModel: .previewEmpty) - .previewDisplayName("Empty") + .environment(\.colorScheme, .light) - NotificationListView(viewModel: .previewData) - .previewDisplayName("With Data") + NotificationListView(viewModel: .previewError) + .environment(\.colorScheme, .light) } } } -#if DEBUG extension NotificationListViewModel { private static func sampleAlarms() -> [AlarmItem] { [ AlarmItem( + subscriptionId: 1, alias: "접근성 블로그", + summaryContent: "애플이 새로운 보이스오버 기능을 발표했습니다.", timeAgo: "3분 전", - summaries: [ - SummaryItem(id: 1, summary: "애플이 새로운 보이스오버 기능을 발표했습니다.", updatedAt: "2024-12-19T09:00:00Z"), - SummaryItem(id: 2, summary: "iOS 18에서 접근성 옵션이 대폭 개선됩니다.", updatedAt: "2024-12-19T09:05:00Z") - ], isUrgent: false ), AlarmItem( - alias: "오늘의 소리 알림", + subscriptionId: 2, + alias: "오늘의 소리", + summaryContent: "오늘의 소리에서 새로운 음성이 도착했습니다.", timeAgo: "10분 전", - summaries: [ - SummaryItem(id: 3, summary: "오늘의 소리에서 새 음성이 도착했습니다.", updatedAt: "2024-12-19T08:50:00Z") - ], isUrgent: true ) ] diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift index f9adc68..91e9b2b 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift @@ -30,56 +30,40 @@ class NotificationListViewModel: ObservableObject { /// 알림 목록 불러오기 func loadAlarms() { guard !disableAutoLoad else { return } - print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("📞 loadAlarms() 호출됨!") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━") // 이미 로딩 중이거나 더 이상 데이터가 없으면 리턴 guard !isLoading, !isLoadingMore, hasMoreData else { - print("⏸️ 알림 로딩 중단: isLoading=\(isLoading), isLoadingMore=\(isLoadingMore), hasMoreData=\(hasMoreData)") return } - print("✅ 로딩 상태 체크 통과") guard let userId = Keychain.getString(for: KeychainKey.userId), let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) else { - print("❌ 키체인에서 userId 또는 deviceSecret을 찾을 수 없음!") errorMessage = "사용자 정보가 없습니다" return } - print("✅ 키체인 정보 획득 성공") - print(" userId: \(userId)") - print(" deviceSecret: \(deviceSecret.prefix(20))...") // 첫 로딩인지 더 불러오기인지 구분 if currentPage == 0 { isLoading = true - print("📂 첫 로딩 시작") } else { isLoadingMore = true - print("📂 추가 로딩 시작") } errorMessage = nil - print("📡 알림 목록 API 요청 준비:") - print(" URL: http://localhost:8080/api/alarms") - print(" page: \(currentPage)") - print(" size: \(pageSize)") - print("━━━━━━━━━━━━━━━━━━━━━━━━━━\n") - apiService.getAlarms( userId: userId, deviceSecret: deviceSecret, page: currentPage, size: pageSize ) + // getAlarms의 리턴 타입은 AnyPublisher<[AlarmItem], APIError> 라고 가정 .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in guard let self else { return } - isLoading = false - isLoadingMore = false + self.isLoading = false + self.isLoadingMore = false switch completion { case .finished: @@ -88,42 +72,31 @@ class NotificationListViewModel: ObservableObject { case let .failure(error): switch error { case let .serverError(statusCode): - errorMessage = "서버 오류 (상태: \(statusCode))" - + self.errorMessage = "서버 오류 (상태: \(statusCode))" case .decodingFailed: - errorMessage = "응답 처리 실패" - + self.errorMessage = "응답 처리 실패" case let .requestFailed(requestError): - errorMessage = "요청 실패: \(requestError.localizedDescription)" - + self.errorMessage = "요청 실패: \(requestError.localizedDescription)" case .invalidURL: - errorMessage = "잘못된 URL" - + self.errorMessage = "잘못된 URL" case .unknown: - errorMessage = "알 수 없는 오류" + self.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) + // 새 데이터 추가 + self.alarms.append(contentsOf: newItems) - // 다음 페이지로 이동 - currentPage += 1 + // 다음 페이지 + self.currentPage += 1 - // 받은 개수가 pageSize보다 적으면 더 이상 데이터 없음 - if newItems.count < pageSize { - hasMoreData = false - print("🏁 마지막 페이지 도달: 받은 개수(\(newItems.count)) < 예상(\(pageSize))") + // 받은 개수가 pageSize보다 적으면 마지막 페이지 + if newItems.count < self.pageSize { + self.hasMoreData = false } - - print("✅ 알림 목록 조회 성공: \(newItems.count)개 추가 (전체: \(alarms.count)개)") } ) .store(in: &cancellables) @@ -131,7 +104,6 @@ class NotificationListViewModel: ObservableObject { /// 새로고침 (처음부터 다시 로드) func refresh() { - print("🔄 알림 새로고침") alarms = [] currentPage = 0 hasMoreData = true @@ -141,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() + } } } From f588cf15b12ed19a138fac961ee98a42ea540479 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sun, 30 Nov 2025 16:27:36 +0900 Subject: [PATCH 5/5] =?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 --- .../Core/AppState/SessionStore.swift | 16 +- today-s-sound/Data/Models/Alarm.swift | 10 +- .../Features/Feed/FeedModel.swift | 7 +- .../Presentation/Features/Feed/FeedView.swift | 3 +- .../Features/Feed/FeedViewModel.swift | 1 - .../Features/Main/Home/HomeView.swift | 6 +- .../Presentation/Features/Main/MainView.swift | 1 - .../NotificationList/AlertCardView.swift | 10 +- .../NotificationListView.swift | 236 +++++++++--------- .../NotificationListViewModel.swift | 22 +- .../Component/AddSubscriptionButton.swift | 22 +- .../SubscriptionListView.swift | 10 +- .../SubscriptionListViewModel.swift | 116 ++++----- today-s-sound/Resources/Colors.swift | 27 +- 14 files changed, 241 insertions(+), 246 deletions(-) diff --git a/today-s-sound/Core/AppState/SessionStore.swift b/today-s-sound/Core/AppState/SessionStore.swift index 6c390c3..a4bf87b 100644 --- a/today-s-sound/Core/AppState/SessionStore.swift +++ b/today-s-sound/Core/AppState/SessionStore.swift @@ -159,13 +159,13 @@ final class SessionStore: ObservableObject { } #if DEBUG -extension SessionStore { - static var preview: SessionStore { - let store = SessionStore() - store.userId = "preview-user" - store.isRegistered = true - store.lastError = nil - return store + 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 4f67f42..209dfb0 100644 --- a/today-s-sound/Data/Models/Alarm.swift +++ b/today-s-sound/Data/Models/Alarm.swift @@ -11,11 +11,11 @@ typealias AlarmListResponse = [AlarmItem] /// 개별 알림 아이템 (RecentAlarmResponse) struct AlarmItem: Codable, Identifiable { - let subscriptionId: Int64 // 구독 ID (알림 ID 역할) - let alias: String // 구독 별칭 - let summaryContent: String // 요약 내용 - let timeAgo: String // "~분 전" 같은 상대 시간 - let isUrgent: Bool // 긴급 여부 + let subscriptionId: Int64 // 구독 ID (알림 ID 역할) + let alias: String // 구독 별칭 + let summaryContent: String // 요약 내용 + let timeAgo: String // "~분 전" 같은 상대 시간 + let isUrgent: Bool // 긴급 여부 // SwiftUI ForEach에서 사용할 식별자 var id: Int64 { subscriptionId } diff --git a/today-s-sound/Presentation/Features/Feed/FeedModel.swift b/today-s-sound/Presentation/Features/Feed/FeedModel.swift index f3bec4c..de12f9f 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedModel.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedModel.swift @@ -22,22 +22,21 @@ enum FeedSampleData { title: "교육부, 시각장애 학생을 위한 AI 오디오 교재 배포", summary: "전국 특수학교 대상으로 접근성 강화된 음성 교재를 순차 배포합니다.", source: "교육부 보도자료", - publishedAt: Date().addingTimeInterval(-3_600) + publishedAt: Date().addingTimeInterval(-3600) ), FeedItem( id: UUID(), title: "서울시청, 공공 서비스 음성 지원 확대 발표", summary: "민원 앱 내 보이스오버 전용 모드를 도입해 정보 접근성을 높입니다.", source: "서울시청 뉴스룸", - publishedAt: Date().addingTimeInterval(-8_400) + publishedAt: Date().addingTimeInterval(-8400) ), FeedItem( id: UUID(), title: "오늘의 소리 사용자 인터뷰", summary: "베타 사용자들이 직접 전해준 알림 읽기 경험과 개선 아이디어를 소개합니다.", source: "오늘의 소리 팀", - publishedAt: Date().addingTimeInterval(-18_000) + publishedAt: Date().addingTimeInterval(-18000) ) ] } - diff --git a/today-s-sound/Presentation/Features/Feed/FeedView.swift b/today-s-sound/Presentation/Features/Feed/FeedView.swift index 5cdafa9..be77784 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedView.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedView.swift @@ -22,7 +22,7 @@ struct FeedView: View { @ViewBuilder private var content: some View { - if viewModel.isLoading && viewModel.items.isEmpty { + if viewModel.isLoading, viewModel.items.isEmpty { loadingState } else if let errorMessage = viewModel.errorMessage { errorState(message: errorMessage) @@ -140,4 +140,3 @@ struct FeedView_Previews: PreviewProvider { FeedView() } } - diff --git a/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift index 895b85d..af726a0 100644 --- a/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift +++ b/today-s-sound/Presentation/Features/Feed/FeedViewModel.swift @@ -20,4 +20,3 @@ final class FeedViewModel: ObservableObject { } } } - diff --git a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift index 7ebddb3..f9a5580 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -55,7 +55,7 @@ struct HomeView: View { action: { viewModel.decreaseRate() }, label: { Image(systemName: "minus") - .font(.KoddiBold48) + .font(.KoddiBold48) .foregroundColor(Color.primaryGreen) } ) @@ -79,9 +79,9 @@ struct HomeView: View { VStack(spacing: 16) { Text("현재 카테고리") - .font(.KoddiBold28) + .font(.KoddiBold28) .foregroundColor(Color.text(colorScheme)) - + Text(viewModel.currentCategoryName) .font(.KoddiExtraBold32) .foregroundColor(colorScheme == .dark ? .black : .white) diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift index b9e4295..1f76387 100644 --- a/today-s-sound/Presentation/Features/Main/MainView.swift +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -39,7 +39,6 @@ struct MainView: View { Text("구독") } .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 024dce5..c22ac84 100644 --- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -47,14 +47,14 @@ struct AlertCardView: View { }, label: { HStack(spacing: 20) { Image(systemName: "speaker.wave.2") - .resizable() - .scaledToFit() - .frame(width: 48, height: 48) + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) .foregroundStyle(cardColor) .accessibilityHidden(true) - + Text("음성으로 듣기") - .font(.KoddiExtraBold32) + .font(.KoddiExtraBold32) .foregroundColor(Color.text(colorScheme)) } .frame(maxWidth: .infinity) diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index 3e8d5a7..3c3bc1b 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -13,14 +13,14 @@ struct NotificationListView: View { ZStack { Color.background(colorScheme) .ignoresSafeArea() - VStack(alignment: .leading, spacing: 16) { - ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) - .padding(.horizontal, 20) + VStack(alignment: .leading, spacing: 16) { + ScreenMainTitle(text: "최근 알림", colorScheme: colorScheme) + .padding(.horizontal, 20) - content - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.bottom, 16) - } + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.bottom, 16) + } } } .onAppear { @@ -30,139 +30,139 @@ struct NotificationListView: View { @ViewBuilder private var content: some View { - // 로딩 - if viewModel.isLoading && viewModel.alarms.isEmpty { - Spacer() - ProgressView("불러오는 중...") - .progressViewStyle(CircularProgressViewStyle()) - .accessibilityLabel("알림 목록을 불러오는 중입니다") - .accessibilityHint("잠시만 기다려주세요") - Spacer() + // 로딩 + 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() - } - .padding(.horizontal, 24) - .padding(.vertical, 12) + // 에러 + else if let errorMessage = viewModel.errorMessage, viewModel.alarms.isEmpty { Spacer() + VStack(spacing: 16) { + Text(errorMessage) .font(.KoddiBold20) - .foregroundColor(Color.white) - .background(Color.primaryGreen) - .cornerRadius(8) - .accessibilityLabel("다시 시도 버튼") - .accessibilityHint("탭하여 구독 목록을 다시 불러옵니다") + .foregroundColor(Color.secondaryText(colorScheme)) + .accessibilityLabel("오류: \(errorMessage)") + .padding(.bottom) + + Button("다시 시도") { + viewModel.refresh() } - Spacer() + .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 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) - } - } + LazyVStack(spacing: 12) { + ForEach(viewModel.alarms) { alarm in + AlertCardView(alarm: alarm, colorScheme: colorScheme) + .onAppear { + viewModel.loadMoreIfNeeded(currentItem: alarm) + } + } - if viewModel.isLoadingMore { - ProgressView() - .padding() - } + if viewModel.isLoadingMore { + ProgressView() + .padding() } - .padding(.horizontal, 20) - .padding(.bottom, 16) } + .padding(.horizontal, 20) + .padding(.bottom, 16) } } + } } #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) - NotificationListView(viewModel: .previewEmpty) - .environment(\.colorScheme, .light) + NotificationListView(viewModel: .previewEmpty) + .environment(\.colorScheme, .light) - NotificationListView(viewModel: .previewError) - .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 - ) - ] - } + 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 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 91e9b2b..fc1ed16 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift @@ -62,8 +62,8 @@ class NotificationListViewModel: ObservableObject { .sink( receiveCompletion: { [weak self] completion in guard let self else { return } - self.isLoading = false - self.isLoadingMore = false + isLoading = false + isLoadingMore = false switch completion { case .finished: @@ -72,15 +72,15 @@ class NotificationListViewModel: ObservableObject { case let .failure(error): switch error { case let .serverError(statusCode): - self.errorMessage = "서버 오류 (상태: \(statusCode))" + errorMessage = "서버 오류 (상태: \(statusCode))" case .decodingFailed: - self.errorMessage = "응답 처리 실패" + errorMessage = "응답 처리 실패" case let .requestFailed(requestError): - self.errorMessage = "요청 실패: \(requestError.localizedDescription)" + errorMessage = "요청 실패: \(requestError.localizedDescription)" case .invalidURL: - self.errorMessage = "잘못된 URL" + errorMessage = "잘못된 URL" case .unknown: - self.errorMessage = "알 수 없는 오류" + errorMessage = "알 수 없는 오류" } } }, @@ -88,14 +88,14 @@ class NotificationListViewModel: ObservableObject { guard let self else { return } // 새 데이터 추가 - self.alarms.append(contentsOf: newItems) + alarms.append(contentsOf: newItems) // 다음 페이지 - self.currentPage += 1 + currentPage += 1 // 받은 개수가 pageSize보다 적으면 마지막 페이지 - if newItems.count < self.pageSize { - self.hasMoreData = false + if newItems.count < pageSize { + hasMoreData = false } } ) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift index 5f6d2ca..87f9324 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/AddSubscriptionButton.swift @@ -14,17 +14,17 @@ struct AddSubscriptionButton: View { 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) + 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) diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index f731e9c..95980d1 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -18,7 +18,7 @@ struct SubscriptionListView: View { VStack(spacing: 12) { ScreenMainTitle(text: "구독 설정", colorScheme: colorScheme) ScreenSubTitle(text: "구독 중인 페이지", colorScheme: colorScheme) - .padding(.top, 16) + .padding(.top, 16) // 로딩 상태 if viewModel.isLoading, viewModel.subscriptions.isEmpty { @@ -29,7 +29,7 @@ struct SubscriptionListView: View { .accessibilityHint("잠시만 기다려주세요") Spacer() } - + // 에러 메시지 else if let errorMessage = viewModel.errorMessage { Spacer() @@ -54,7 +54,7 @@ struct SubscriptionListView: View { } Spacer() } - // 빈 상태 + // 빈 상태 else if viewModel.subscriptions.isEmpty { Spacer() VStack(spacing: 16) { @@ -65,8 +65,8 @@ struct SubscriptionListView: View { } Spacer() } - - // 데이터 있을 때(main) + + // 데이터 있을 때(main) else { List { ForEach(viewModel.subscriptions) { subscription in diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift index adfb083..f4bbe5c 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListViewModel.swift @@ -191,67 +191,67 @@ class SubscriptionListViewModel: ObservableObject { } #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: "스크린리더") - ] - ) - ] - } + 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 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 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 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 + 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 731e6b2..cebf6b3 100644 --- a/today-s-sound/Resources/Colors.swift +++ b/today-s-sound/Resources/Colors.swift @@ -14,22 +14,22 @@ extension Color { static let primaryGreen = Color(red: 0 / 255, green: 223 / 255, blue: 119 / 255) /// 긴급 알림 핑크 색상 (Urgent Pink) - static let urgentPink = Color(red: 255 / 255, green: 76 / 255, blue: 186 / 255) + static let urgentPink = Color(red: 255 / 255, green: 76 / 255, blue: 186 / 255) /// 배지 배경 그린 색상 (Badge Background Green) static let badgeGreenBackground = Color(red: 52 / 255, green: 199 / 255, blue: 89 / 255, opacity: 0.16) - - /// 구독 페이지 목록 배경 - 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) + + /// 구독 페이지 목록 배경 + 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 @@ -69,7 +69,6 @@ extension Color { // MARK: - Opacity Variants extension Color { - /// Primary Green with 20% opacity static let primaryGreen20 = Color.primaryGreen.opacity(0.2)