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