diff --git a/today-s-sound/Core/AppState/AppThemeManager.swift b/today-s-sound/Core/AppState/AppThemeManager.swift index 396aac8..ad692e0 100644 --- a/today-s-sound/Core/AppState/AppThemeManager.swift +++ b/today-s-sound/Core/AppState/AppThemeManager.swift @@ -31,8 +31,8 @@ final class AppThemeManager: ObservableObject { { self.theme = theme } else { - // 기본값은 일반 모드 - theme = .normal + // 기본값은 고대비 모드 + theme = .highContrast } } diff --git a/today-s-sound/Core/Auth/UserCredentialsProvider.swift b/today-s-sound/Core/Auth/UserCredentialsProvider.swift index daacca3..dc88ae8 100644 --- a/today-s-sound/Core/Auth/UserCredentialsProvider.swift +++ b/today-s-sound/Core/Auth/UserCredentialsProvider.swift @@ -12,10 +12,10 @@ import Foundation protocol UserCredentialsProvider { /// 사용자 ID를 반환 func getUserId() -> String? - + /// 디바이스 시크릿을 반환 func getDeviceSecret() -> String? - + /// 사용자 ID와 디바이스 시크릿을 튜플로 반환 /// 둘 다 존재할 때만 반환, 없으면 nil func getCredentials() -> (userId: String, deviceSecret: String)? @@ -26,11 +26,11 @@ final class KeychainCredentialsProvider: UserCredentialsProvider { func getUserId() -> String? { Keychain.getString(for: KeychainKey.userId) } - + func getDeviceSecret() -> String? { Keychain.getString(for: KeychainKey.deviceSecret) } - + func getCredentials() -> (userId: String, deviceSecret: String)? { guard let userId = Keychain.getString(for: KeychainKey.userId), let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret) @@ -46,22 +46,22 @@ final class KeychainCredentialsProvider: UserCredentialsProvider { final class MockCredentialsProvider: UserCredentialsProvider { var userId: String? var deviceSecret: String? - + init(userId: String? = "test-user-id", deviceSecret: String? = "test-device-secret") { self.userId = userId self.deviceSecret = deviceSecret } - + func getUserId() -> String? { userId } - + func getDeviceSecret() -> String? { deviceSecret } - + func getCredentials() -> (userId: String, deviceSecret: String)? { - guard let userId = userId, let deviceSecret = deviceSecret else { + guard let userId, let deviceSecret else { return nil } return (userId, deviceSecret) diff --git a/today-s-sound/Core/Error/ErrorHandler.swift b/today-s-sound/Core/Error/ErrorHandler.swift index 6d6eeea..0112068 100644 --- a/today-s-sound/Core/Error/ErrorHandler.swift +++ b/today-s-sound/Core/Error/ErrorHandler.swift @@ -8,23 +8,23 @@ import Foundation /// 네트워크 에러를 사용자 친화적인 메시지로 변환하는 유틸리티 -struct ErrorHandler { +enum ErrorHandler { /// NetworkError를 사용자에게 표시할 메시지로 변환 static func userFacingMessage(from error: NetworkError) -> String { switch error { case let .serverError(statusCode): - return "서버 오류 (상태: \(statusCode))" + "서버 오류 (상태: \(statusCode))" case .decodingFailed: - return "응답 처리 실패" + "응답 처리 실패" case let .requestFailed(requestError): - return "요청 실패: \(requestError.localizedDescription)" + "요청 실패: \(requestError.localizedDescription)" case .invalidURL: - return "잘못된 URL" + "잘못된 URL" case .unknown: - return "알 수 없는 오류" + "알 수 없는 오류" } } - + /// 에러를 로깅하고 사용자 메시지 반환 static func handleError(_ error: NetworkError, context: String = "") -> String { let message = userFacingMessage(from: error) diff --git a/today-s-sound/Core/Network/Config.swift b/today-s-sound/Core/Network/Config.swift index e8931d6..4c3f90e 100644 --- a/today-s-sound/Core/Network/Config.swift +++ b/today-s-sound/Core/Network/Config.swift @@ -3,8 +3,8 @@ import Foundation enum Config { static var baseURL: String { #if DEBUG -// return "https://www.today-sound.com" - return "http://localhost:8080" // 개발 서버 + return "https://www.today-sound.com" + // return "http://localhost:8080" // 개발 서버 #else return "https://api.todays-sound.com" // 운영 서버 #endif diff --git a/today-s-sound/Core/Pagination/PaginationState.swift b/today-s-sound/Core/Pagination/PaginationState.swift index 9f6a60d..a83119c 100644 --- a/today-s-sound/Core/Pagination/PaginationState.swift +++ b/today-s-sound/Core/Pagination/PaginationState.swift @@ -13,27 +13,27 @@ final class PaginationState { private(set) var currentPage: Int = 0 private(set) var hasMoreData: Bool = true let pageSize: Int - + init(pageSize: Int = 10) { self.pageSize = pageSize } - + /// 다음 페이지로 이동 func moveToNextPage() { currentPage += 1 } - + /// 받은 데이터 개수를 기반으로 더 이상 데이터가 있는지 확인 func updateHasMoreData(receivedCount: Int) { hasMoreData = receivedCount >= pageSize } - + /// 페이지네이션 상태 초기화 (새로고침 시 사용) func reset() { currentPage = 0 hasMoreData = true } - + /// 첫 페이지인지 확인 var isFirstPage: Bool { currentPage == 0 diff --git a/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift b/today-s-sound/Presentation/Base/Component/ScreenMainTitle.swift index 00c0ee0..ba790ce 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(.KoddiBold48) + .font(.KoddiBold56) .foregroundColor(Color.text(theme)) .frame(maxWidth: .infinity, alignment: .center) .multilineTextAlignment(.center) diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index adb5049..9b498f4 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -399,5 +399,6 @@ struct FlowLayout: Layout { struct AddSubscriptionView_Previews: PreviewProvider { static var previews: some View { AddSubscriptionView() + .environmentObject(AppThemeManager()) } } diff --git a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift index 8fd8285..ac38051 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -16,8 +16,8 @@ struct HomeView: View { Text("오늘의 소리") .font(.KoddiBold56) .foregroundStyle(Color.text(appTheme.theme)) - .padding(.top, 60) - .padding(.bottom, 30) + .padding(.top, 120) + .padding(.bottom, 60) .accessibilityElement() // 이 텍스트를 독립 요소로 .accessibilityLabel("오늘의 소리") // 👉 "오늘의 소리"라고 읽기 .accessibilityAddTraits(.isHeader) // 머리말(헤더)로 인식 @@ -112,4 +112,5 @@ struct HomeView: View { #Preview { HomeView() + .environmentObject(AppThemeManager()) } diff --git a/today-s-sound/Presentation/Features/Main/MainView.swift b/today-s-sound/Presentation/Features/Main/MainView.swift index 98ce685..f70f484 100644 --- a/today-s-sound/Presentation/Features/Main/MainView.swift +++ b/today-s-sound/Presentation/Features/Main/MainView.swift @@ -1,63 +1,91 @@ import SwiftUI +private struct TabBarForegroundUpdater: UIViewControllerRepresentable { + let theme: AppTheme + + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = .clear + return vc + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + guard let tabBar = uiViewController.tabBarController?.tabBar else { return } + + let selectedColor = UIColor(Color.primaryGreen) + + let unselectedColor: UIColor = (theme == .highContrast) + ? UIColor(white: 1.0, alpha: 0.85) + : UIColor(Color.primaryGrey) + + tabBar.tintColor = selectedColor + tabBar.unselectedItemTintColor = unselectedColor + + tabBar.items?.forEach { item in + item.setTitleTextAttributes([.foregroundColor: unselectedColor], for: .normal) + item.setTitleTextAttributes([.foregroundColor: selectedColor], for: .selected) + + item.image = item.image?.withRenderingMode(.alwaysTemplate) + item.selectedImage = item.selectedImage?.withRenderingMode(.alwaysTemplate) + } + } +} + struct MainView: View { private enum Tab: Hashable { - case home - case feed - case notifications - case settings + case home, feed, notifications, settings } @State private var selectedTab: Tab = .home + @EnvironmentObject private var appTheme: AppThemeManager var body: some View { + let theme = appTheme.theme + TabView(selection: $selectedTab) { HomeView() - .tabItem { - VStack { - Image(systemName: "play.house.fill") - .accessibilityHidden(true) // 아이콘은 숨기고 - Text("홈") // 이름만 읽히게 - } - } + .tabItem { Label("홈", systemImage: "play.house.fill") } .tag(Tab.home) FeedView() - .tabItem { - VStack { - Image(systemName: "text.bubble.fill") - .accessibilityHidden(true) - Text("피드") - } - } + .tabItem { Label("피드", systemImage: "text.bubble.fill") } .tag(Tab.feed) NotificationListView() - .tabItem { - VStack { - Image(systemName: "bell.fill") - .accessibilityHidden(true) - Text("알림") - } - } + .tabItem { Label("알림", systemImage: "bell.fill") } .tag(Tab.notifications) SettingsView() - .tabItem { - VStack { - Image(systemName: "gearshape.fill") - .accessibilityHidden(true) - Text("관리") - } - } + .tabItem { Label("관리", systemImage: "gearshape.fill") } .tag(Tab.settings) } - .tint(.primaryGreen) + .tint(Color.primaryGreen) + .background(TabBarForegroundUpdater(theme: theme).frame(width: 0, height: 0)) } } struct MainView_Previews: PreviewProvider { + private static var normalThemeManager: AppThemeManager { + let m = AppThemeManager() + m.theme = .normal + return m + } + + private static var highContrastThemeManager: AppThemeManager { + let m = AppThemeManager() + m.theme = .highContrast + return m + } + static var previews: some View { - MainView() + Group { + MainView() + .environmentObject(normalThemeManager) + .previewDisplayName("Theme: Normal") + + MainView() + .environmentObject(highContrastThemeManager) + .previewDisplayName("Theme: HighContrast") + } } } diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index 42b5058..3acf125 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -67,19 +67,20 @@ struct NotificationListView: View { // 알림 없음 else if viewModel.alarms.isEmpty { Spacer() - VStack(spacing: 16) { + VStack(spacing: 0) { Text("새로운 알림이 없습니다") .font(.KoddiBold20) .foregroundColor(Color.secondaryText(appTheme.theme)) .accessibilityLabel("새로운 알림이 없습니다") } + .padding(.top, 28) Spacer() } // 알림 목록 else { List { ForEach(viewModel.alarms) { alarm in - AlertCardView(alarm: alarm, theme: appTheme.theme) + AlertCardView(alarm: alarm, theme: appTheme.theme) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 12, trailing: 20)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) @@ -119,23 +120,19 @@ struct NotificationListView: View { Group { // 데이터 있는 상태 - 라이트 모드 NotificationListView(viewModel: .previewData) - .environment(\.colorScheme, .light) - .previewDisplayName("알림 목록 - Light") + .environmentObject(AppThemeManager()).previewDisplayName("알림 목록 - Light") // 데이터 있는 상태 - 다크 모드 NotificationListView(viewModel: .previewData) - .environment(\.colorScheme, .dark) - .previewDisplayName("알림 목록 - Dark") + .environmentObject(AppThemeManager()).previewDisplayName("알림 목록 - Dark") // 빈 상태 NotificationListView(viewModel: .previewEmpty) - .environment(\.colorScheme, .light) - .previewDisplayName("알림 없음") + .environmentObject(AppThemeManager()).previewDisplayName("알림 없음") // 에러 상태 NotificationListView(viewModel: .previewError) - .environment(\.colorScheme, .light) - .previewDisplayName("에러 상태") + .environmentObject(AppThemeManager()).previewDisplayName("에러 상태") } } } diff --git a/today-s-sound/Presentation/Features/Practice/ButtonPracticeView.swift b/today-s-sound/Presentation/Features/Practice/ButtonPracticeView.swift deleted file mode 100644 index 88babd5..0000000 --- a/today-s-sound/Presentation/Features/Practice/ButtonPracticeView.swift +++ /dev/null @@ -1,266 +0,0 @@ -// -// ButtonPracticeView.swift -// today-s-sound -// -// Button 스타일링 연습용 뷰 -// - -import SwiftUI - -struct ButtonPracticeView: View { - @State private var count = 0 - @State private var isEnabled = true - @EnvironmentObject var appTheme: AppThemeManager - - var body: some View { - ScrollView { - VStack(spacing: 30) { - // 제목 - Text("Button 스타일링 연습") - .font(.KoddiBold28) - .foregroundColor(Color.text(appTheme.theme)) - .padding(.top, 20) - - // ============================================ - // 1. 기본 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("1. 기본 Button") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button("클릭하세요") { - count += 1 - } - .font(.KoddiBold18) - .foregroundColor(.white) - .padding() - .background(Color.primaryGreen) - .cornerRadius(8) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 2. 아이콘 + 텍스트 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("2. 아이콘 + 텍스트 Button") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button(action: { - count += 1 - }) { - HStack { - Image(systemName: "plus.circle.fill") - Text("증가") - } - .font(.KoddiBold18) - .foregroundColor(.white) - .padding() - .background(Color.primaryGreen) - .cornerRadius(8) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 3. 전체 너비 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("3. 전체 너비 Button (maxWidth: .infinity)") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button("전체 너비 버튼") { - count += 1 - } - .font(.KoddiBold18) - .foregroundColor(.white) - .frame(maxWidth: .infinity) // 👈 이게 핵심! - .padding(.vertical, 16) - .background(Color.primaryGreen) - .cornerRadius(8) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 4. 둥근 모서리 배경 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("4. RoundedRectangle 배경") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button("둥근 모서리") { - count += 1 - } - .font(.KoddiBold18) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.primaryGreen) - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 5. 테두리 있는 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("5. 테두리 있는 Button (overlay)") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button("테두리 버튼") { - count += 1 - } - .font(.KoddiBold18) - .foregroundColor(Color.primaryGreen) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.background(appTheme.theme)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.primaryGreen, lineWidth: 2) - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 6. 비활성화 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("6. 비활성화 Button") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button("비활성화 버튼") { - count += 1 - } - .font(.KoddiBold18) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background( - isEnabled ? Color.primaryGreen : Color.primaryGreen.opacity(0.4) - ) - .cornerRadius(8) - .disabled(!isEnabled) - - Toggle("버튼 활성화", isOn: $isEnabled) - .font(.KoddiRegular16) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 7. 원형 Button (Circle) - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("7. 원형 Button") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button(action: { - count += 1 - }) { - Image(systemName: "play.fill") - .font(.system(size: 24)) - .foregroundColor(.white) - .frame(width: 80, height: 80) - .background( - Circle() - .fill(Color.primaryGreen) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 8. 그림자 있는 Button - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("8. 그림자 있는 Button") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button("그림자 버튼") { - count += 1 - } - .font(.KoddiBold18) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color.primaryGreen) - .cornerRadius(8) - .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 9. 프로젝트 스타일 Button (AddSubscriptionButton 참고) - // ============================================ - VStack(alignment: .leading, spacing: 10) { - Text("9. 프로젝트 스타일 Button") - .font(.KoddiBold20) - .foregroundColor(Color.text(appTheme.theme)) - - Button(action: { - count += 1 - }) { - Text("등록 승인 요청") - .font(.KoddiExtraBold32) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .frame(height: 82) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.primaryGreen) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - - // ============================================ - // 10. 카운터 표시 - // ============================================ - VStack(spacing: 10) { - Text("버튼 클릭 횟수: \(count)") - .font(.KoddiBold24) - .foregroundColor(Color.text(appTheme.theme)) - - Button("리셋") { - count = 0 - } - .font(.KoddiBold18) - .foregroundColor(.white) - .padding(.horizontal, 30) - .padding(.vertical, 12) - .background(Color.urgentPink) - .cornerRadius(8) - } - .padding(.vertical, 20) - } - } - .background(Color.background(appTheme.theme)) - } -} - -// MARK: - Preview - -#Preview { - ButtonPracticeView() - .environmentObject(AppThemeManager()) -} - diff --git a/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift b/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift index 261445a..9f01c09 100644 --- a/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift +++ b/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift @@ -12,9 +12,6 @@ struct ContactDeveloperView: View { .ignoresSafeArea() VStack(spacing: 0) { - ScreenMainTitle(text: "개발자 문의", theme: appTheme.theme) - .padding(.top, 16) - Spacer() VStack(spacing: 24) { @@ -33,7 +30,7 @@ struct ContactDeveloperView: View { // 이메일 주소 버튼 Button { - if let url = URL(string: "mailto:support@todayssound.com?subject=문의사항") { + if let url = URL(string: "mailto:todaysound.official@gmail.com?subject=문의사항") { UIApplication.shared.open(url) } } label: { @@ -41,12 +38,12 @@ struct ContactDeveloperView: View { Image(systemName: "envelope") .font(.KoddiBold20) .foregroundColor(Color.primaryGreen) - Text("support@todayssound.com") + Text("todaysound.official@gmail.com") .font(.KoddiBold20) .foregroundColor(Color.primaryGreen) } .frame(maxWidth: .infinity) - .padding(.horizontal, 24) + .padding(.horizontal, 8) .padding(.vertical, 16) .overlay( RoundedRectangle(cornerRadius: 8) @@ -84,6 +81,7 @@ struct ContactDeveloperView_Previews: PreviewProvider { static var previews: some View { NavigationView { ContactDeveloperView() + .environmentObject(AppThemeManager()) } } } diff --git a/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift b/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift index d3d7152..d2aef3b 100644 --- a/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift +++ b/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift @@ -11,9 +11,6 @@ struct PlaybackSettingsView: View { .ignoresSafeArea() VStack(spacing: 0) { - ScreenMainTitle(text: "재생 설정", theme: appTheme.theme) - .padding(.top, 16) - // 재생 속도 설정 VStack(alignment: .leading, spacing: 16) { Text("재생 속도 설정") @@ -134,6 +131,7 @@ struct PlaybackSettingsView_Previews: PreviewProvider { static var previews: some View { NavigationView { PlaybackSettingsView() + .environmentObject(AppThemeManager()) } } } diff --git a/today-s-sound/Presentation/Features/Settings/SettingsView.swift b/today-s-sound/Presentation/Features/Settings/SettingsView.swift index c02483b..c5c9306 100644 --- a/today-s-sound/Presentation/Features/Settings/SettingsView.swift +++ b/today-s-sound/Presentation/Features/Settings/SettingsView.swift @@ -13,42 +13,62 @@ struct SettingsView: View { VStack(spacing: 0) { ScreenMainTitle(text: "관리", theme: appTheme.theme) - .padding(.top, 16) + .padding(.bottom, 16) + .accessibilityAddTraits(.isHeader) + .accessibilityLabel("관리 화면") - Spacer() - - // 관리 항목 리스트 VStack(spacing: 0) { - NavigationLink(destination: SubscriptionListView()) { - SettingsRow(title: "구독 페이지 관리", theme: appTheme.theme) + NavigationLink( + destination: BackHeaderContainer(title: "구독 관리", theme: appTheme.theme) { + SubscriptionListView() + } + ) { + SettingsRow(title: "구독 페이지 관리 및 추가", theme: appTheme.theme) } .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("구독 페이지 관리 및 추가") + .accessibilityHint("탭하면 구독 페이지 관리 및 추가 화면으로 이동합니다") Divider() .background(Color.border(appTheme.theme)) - .padding(.horizontal, 20) - - NavigationLink(destination: PlaybackSettingsView()) { + .padding(.horizontal, 4) + .accessibilityHidden(true) + + NavigationLink( + destination: BackHeaderContainer(title: "재생 설정", theme: appTheme.theme) { + PlaybackSettingsView() + } + ) { SettingsRow(title: "재생 설정", theme: appTheme.theme) } .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("재생 설정") + .accessibilityHint("탭하면 재생 설정 화면으로 이동합니다") Divider() .background(Color.border(appTheme.theme)) - .padding(.horizontal, 20) - - NavigationLink(destination: ContactDeveloperView()) { + .padding(.horizontal, 4) + .accessibilityHidden(true) + + NavigationLink( + destination: BackHeaderContainer(title: "개발자 문의", theme: appTheme.theme) { + ContactDeveloperView() + } + ) { SettingsRow(title: "개발자에게 문의", theme: appTheme.theme) } .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("개발자에게 문의") + .accessibilityHint("탭하면 개발자 문의 화면으로 이동합니다") Divider() .background(Color.border(appTheme.theme)) - .padding(.horizontal, 20) + .padding(.horizontal, 4) + .accessibilityHidden(true) HStack { Text("고대비 모드 설정") - .font(.KoddiBold24) + .font(.KoddiBold28) .foregroundColor(Color.text(appTheme.theme)) Spacer() Toggle("", isOn: Binding( @@ -67,25 +87,25 @@ struct SettingsView: View { } .background(Color.background(appTheme.theme)) .cornerRadius(12) - .padding(.horizontal, 20) + .padding(.horizontal, 8) .padding(.bottom, 40) + .accessibilityElement(children: .contain) - // 앱 초기화 버튼 Button { showDeleteAlert = true } label: { Text("앱 초기화") - .font(.KoddiBold20) + .font(.KoddiExtraBold32) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding(.vertical, 16) .background(Color.urgentPink) .cornerRadius(8) } - .padding(.horizontal, 20) + .padding(.horizontal, 8) .padding(.bottom, 40) - .accessibilityLabel("앱 초기화 버튼") - .accessibilityHint("탭하여 앱을 초기화합니다") + .accessibilityLabel("앱 초기화") + .accessibilityHint("탭하면 앱 초기화 확인 창이 열립니다") Spacer() } @@ -93,17 +113,81 @@ struct SettingsView: View { .navigationBarHidden(true) .alert("앱 초기화", isPresented: $showDeleteAlert) { Button("취소", role: .cancel) {} + .accessibilityLabel("취소") + Button("초기화하기", role: .destructive) { session.logout() - // 앱 종료 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - exit(0) - } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) } } + .accessibilityLabel("초기화하기") + .accessibilityHint("모든 데이터를 삭제하고 앱을 종료합니다") } message: { - Text("정말 앱을 초기화하시겠습니까?\n모든 데이터가 삭제되고 앱이 종료됩니다.") + Text("정말 앱을 초기화하시겠습니까? 모든 데이터가 삭제되고 앱이 종료됩니다.") + } + } + } +} + +// MARK: - 목적지 화면용 커스텀 헤더 래퍼 + +private struct BackHeaderContainer: View { + @Environment(\.dismiss) private var dismiss + + let title: String + let theme: AppTheme + let content: Content + + init(title: String, theme: AppTheme, @ViewBuilder content: () -> Content) { + self.title = title + self.theme = theme + self.content = content() + } + + var body: some View { + ZStack(alignment: .top) { + Color.background(theme) + .ignoresSafeArea() + + VStack(spacing: 0) { + header + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + } + .navigationBarHidden(true) + .accessibilityHint("이전 화면으로 돌아가려면 왼쪽 상단의 뒤로가기 버튼을 탭하세요. 보이스오버 사용 중에는 두 손가락으로 Z 모양으로 쓸면 뒤로 갈 수 있습니다.") + } + + private var header: some View { + ZStack { + // 가운데 고정 타이틀 + Text(title) + .font(.KoddiBold56) + .foregroundColor(Color.text(theme)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 30) // ← 좌/우 버튼 영역만큼 항상 확보 + .accessibilityAddTraits(.isHeader) + + // 왼쪽 고정 뒤로 버튼 + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(Color.text(theme)) + .frame(width: 44, height: 44) + } + .accessibilityLabel("뒤로가기") + .accessibilityHint("탭하면 이전 화면으로 돌아갑니다") + + Spacer() } } + .padding(.horizontal, 16) + .padding(.vertical, 8) } } @@ -116,12 +200,12 @@ struct SettingsRow: View { var body: some View { HStack { Text(title) - .font(.KoddiBold24) + .font(.KoddiBold28) .foregroundColor(Color.text(theme)) Spacer() } .padding(.horizontal, 20) - .padding(.vertical, 16) + .padding(.vertical, 24) .contentShape(Rectangle()) } } @@ -130,5 +214,6 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView() .environmentObject(SessionStore.preview) + .environmentObject(AppThemeManager()) } } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift index cbdf7cc..e5a9658 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -11,6 +11,17 @@ struct SubscriptionCardView: View { let subscription: SubscriptionItem let theme: AppTheme var onToggleAlarm: ((SubscriptionItem) -> Void)? + private var resolvedTextColor: Color { + theme == .highContrast ? .white : Color.primaryGrey + } + + private var resolvedCardBackgroundColor: Color { + theme == .highContrast ? Color(white: 0.12) : Color.greyBackground + } + + private var resolvedBorderColor: Color { + theme == .highContrast ? Color(white: 0.25) : Color.borderGrey + } var body: some View { HStack(spacing: 12) { @@ -18,13 +29,13 @@ struct SubscriptionCardView: View { // 구독 이름 (alias) Text(subscription.alias) .font(.KoddiBold20) - .foregroundColor(Color.primaryGrey) + .foregroundColor(resolvedTextColor) .accessibilityLabel("구독 페이지 이름: \(subscription.alias)") // URL Text(subscription.url) .font(.KoddiRegular16) - .foregroundColor(Color.primaryGrey) + .foregroundColor(resolvedTextColor) .lineLimit(1) .accessibilityLabel("주소: \(subscription.url)") @@ -63,10 +74,10 @@ struct SubscriptionCardView: View { .padding(16) .background( RoundedRectangle(cornerRadius: 8) - .fill(Color.greyBackground) + .fill(resolvedCardBackgroundColor) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(Color.borderGrey, lineWidth: 1) + .stroke(resolvedBorderColor, lineWidth: 1) ) ) } @@ -94,6 +105,7 @@ struct SubscriptionCardView_Previews: PreviewProvider { ) .padding() .previewDisplayName("Normal") + .environmentObject(AppThemeManager()) SubscriptionCardView( subscription: sampleSubscription, @@ -102,6 +114,7 @@ struct SubscriptionCardView_Previews: PreviewProvider { .padding() .previewDisplayName("High Contrast") .background(Color.black) + .environmentObject(AppThemeManager()) } .previewLayout(.sizeThatFits) } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift index 13b6c0b..c42b6cc 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift @@ -15,8 +15,7 @@ struct SubscriptionListView: View { Color.background(appTheme.theme) .ignoresSafeArea() - VStack(spacing: 12) { - ScreenMainTitle(text: "구독 페이지 관리", theme: appTheme.theme) + VStack { ScreenSubTitle(text: "구독 중인 페이지", theme: appTheme.theme) .padding(.top, 16) @@ -33,7 +32,7 @@ struct SubscriptionListView: View { // 에러 메시지 else if let errorMessage = viewModel.errorMessage { Spacer() - VStack(spacing: 16) { + VStack { Text(errorMessage) .font(.KoddiBold20) .foregroundColor(Color.secondaryText(appTheme.theme)) @@ -157,6 +156,7 @@ struct SubscriptionListView: View { } .sheet(isPresented: $showAddSubscription) { AddSubscriptionView() + .environmentObject(AppThemeManager()) } } } @@ -166,15 +166,19 @@ struct SubscriptionListView_Previews: PreviewProvider { Group { SubscriptionListView(viewModel: .previewLoading) .previewDisplayName("Loading") + .environmentObject(AppThemeManager()) SubscriptionListView(viewModel: .previewError) .previewDisplayName("Error") + .environmentObject(AppThemeManager()) SubscriptionListView(viewModel: .previewEmpty) .previewDisplayName("Empty") + .environmentObject(AppThemeManager()) SubscriptionListView(viewModel: .previewData) .previewDisplayName("With Data") + .environmentObject(AppThemeManager()) } } }