diff --git a/today-s-sound.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/today-s-sound.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/today-s-sound.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Base/Component/MainButton.swift
similarity index 56%
rename from today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift
rename to today-s-sound/Presentation/Base/Component/MainButton.swift
index 4f115ad..5fdd6fa 100644
--- a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift
+++ b/today-s-sound/Presentation/Base/Component/MainButton.swift
@@ -1,5 +1,5 @@
//
-// AddSubscriptionButton.swift
+// MainButton.swift
// today-s-sound
//
// 공통 액션 버튼 컴포넌트
@@ -7,7 +7,7 @@
import SwiftUI
-struct AddSubscriptionButton: View {
+struct MainButton: View {
/// 버튼에 표시할 텍스트 (예: "등록 승인 요청", "저장하기")
let title: String
@@ -46,35 +46,3 @@ struct AddSubscriptionButton: View {
.accessibilityHint(isEnabled ? "탭하여 \(title)합니다" : "현재 사용할 수 없습니다")
}
}
-
-// MARK: - Preview
-
-struct AddSubscriptionButton_Previews: PreviewProvider {
- static var previews: some View {
- Group {
- // Normal Mode
- AddSubscriptionButton(
- title: "등록 승인 요청",
- theme: .normal,
- isEnabled: true,
- action: {}
- )
- .previewDisplayName("Normal Mode")
- .previewLayout(.sizeThatFits)
- .padding()
- .background(Color.background(.normal))
-
- // High Contrast Mode
- AddSubscriptionButton(
- title: "등록 승인 요청",
- theme: .highContrast,
- isEnabled: true,
- action: {}
- )
- .previewDisplayName("High Contrast Mode")
- .previewLayout(.sizeThatFits)
- .padding()
- .background(Color.background(.highContrast))
- }
- }
-}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift
index daf3a5f..fe86c3f 100644
--- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift
+++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift
@@ -9,21 +9,19 @@ struct AddSubscriptionView: View {
ZStack {
Color.background(appTheme.theme)
.ignoresSafeArea()
+ .accessibilityHidden(true)
.onTapGesture {
- // 배경 탭하면 키보드만 닫기
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
VStack(spacing: 0) {
- // 상단 핸들 바 (X 대신)
- SheetHandleBar(theme: appTheme.theme)
- .accessibilityElement()
- .accessibilityLabel("새 웹페이지 추가 창 닫기")
- .accessibilityHint("이 영역을 두 번 탭하거나 아래로 스와이프하면 창이 닫힙니다.")
- .onTapGesture {
- // 핸들 바를 두 번 탭해서도 창을 닫을 수 있게
- dismiss()
- }
+ // 상단 핸들 바
+ SheetHandleBar(theme: appTheme.theme) {
+ dismiss()
+ }
+ .accessibilityElement()
+ .accessibilityLabel("새 웹페이지 추가 창 닫기")
+ .accessibilityHint("탭하거나 두 손가락을 아래로 스와이프하면 창이 닫힙니다")
// 화면 제목
ScreenSubTitle(text: "새 웹페이지 추가", theme: appTheme.theme)
@@ -32,164 +30,13 @@ struct AddSubscriptionView: View {
// 콘텐츠 + 하단 버튼 영역
VStack(spacing: 0) {
- // 스크롤 되는 영역 (입력 필드, 키워드, 토글 등)
+ // 스크롤 되는 영역
ScrollView {
VStack(spacing: 24) {
- // 1) 웹사이트 URL 선택 (필수)
- VStack(alignment: .leading, spacing: 12) {
- VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 4) {
- Text("웹사이트 URL")
- .font(.KoddiBold20)
- .foregroundColor(Color.text(appTheme.theme))
- .accessibilityLabel("웹사이트 URL 필수 선택")
-
- Text("*")
- .font(.KoddiBold20)
- .foregroundColor(.red)
- .accessibilityHidden(true)
- }
-
- Button(action: {
- viewModel.showURLSelector = true
- }) {
- HStack {
- Text(viewModel.selectedURL?.title ?? "URL 선택...")
- .font(.KoddiRegular16)
- .foregroundColor(
- viewModel.selectedURL == nil
- ? Color.secondaryText(appTheme.theme)
- : Color.text(appTheme.theme)
- )
- Spacer()
- }
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .background(
- RoundedRectangle(cornerRadius: 8)
- .fill(Color.secondaryBackground(appTheme.theme))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.border(appTheme.theme), lineWidth: 1)
- )
- }
- .accessibilityLabel(viewModel.selectedURL == nil ? "URL 선택 버튼" : "URL 수정 버튼")
- .accessibilityHint("탭하여 URL을 선택합니다")
- .accessibilityValue(viewModel.selectedURL?.title ?? "")
-
- Text("모니터링할 웹페이지를 선택하세요.")
- .font(.KoddiRegular16)
- .foregroundColor(Color.secondaryText(appTheme.theme))
- .fixedSize(horizontal: false, vertical: true)
- .accessibilityLabel("모니터링할 웹페이지를 선택하세요")
- }
-
- // 선택된 URL 표시
- if let selectedURL = viewModel.selectedURL {
- HStack {
- Text(selectedURL.link)
- .font(.KoddiRegular16)
- .foregroundColor(Color.secondaryText(appTheme.theme))
- .lineLimit(1)
- Spacer()
- Button(action: {
- viewModel.clearURL()
- }) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 16))
- .foregroundColor(Color.secondaryText(appTheme.theme))
- }
- .accessibilityLabel("선택된 URL 삭제")
- .accessibilityHint("탭하여 선택된 URL을 제거합니다")
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(Color.secondaryBackground(appTheme.theme).opacity(0.5))
- )
- }
- }
-
- // 2) 웹페이지 별명 (선택)
- InputFieldSection(
- title: "웹페이지 별명",
- description: "해당 페이지를 식별할 명칭을 입력하세요.",
- isRequired: false,
- text: $viewModel.nameText,
- theme: appTheme.theme
- )
-
- // 3) 키워드 필터
- VStack(alignment: .leading, spacing: 12) {
- VStack(alignment: .leading, spacing: 8) {
- Text("키워드 필터")
- .font(.KoddiBold20)
- .foregroundColor(Color.text(appTheme.theme))
-
- Button(action: {
- viewModel.showKeywordSelector = true
- }) {
- HStack {
- Text(viewModel.selectedKeywordNames.isEmpty ? "키워드 추가..." : "키워드 수정...")
- .font(.KoddiRegular16)
- .foregroundColor(Color.secondaryText(appTheme.theme))
- Spacer()
- }
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .background(
- RoundedRectangle(cornerRadius: 8)
- .fill(Color.secondaryBackground(appTheme.theme))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.border(appTheme.theme), lineWidth: 1)
- )
- }
- .accessibilityLabel(viewModel.selectedKeywordNames.isEmpty ? "키워드 추가 버튼" : "키워드 수정 버튼")
- .accessibilityHint("탭하여 키워드를 선택합니다")
-
- Text("관심 키워드가 포함된 글을 알림으로 받아보세요.")
- .font(.KoddiRegular16)
- .foregroundColor(Color.secondaryText(appTheme.theme))
- .fixedSize(horizontal: false, vertical: true)
- .accessibilityLabel("관심 키워드가 포함된 글을 알림으로 받아보세요")
- }
-
- // 선택된 키워드 배지들
- if !viewModel.selectedKeywordNames.isEmpty {
- FlowLayout(spacing: 8) {
- ForEach(viewModel.selectedKeywordNames, id: \.self) { keywordName in
- KeywordBadgeWithDelete(
- text: keywordName,
- theme: appTheme.theme
- ) {
- // 키워드 이름으로 ID 찾기
- if let keyword = viewModel.availableKeywords.first(where: { $0.name == keywordName }) {
- viewModel.removeKeyword(keyword.id)
- }
- }
- }
- }
- }
- }
-
- // 4) 긴급 알림 토글
- HStack {
- Text("긴급 알림으로 설정")
- .font(.KoddiBold20)
- .foregroundColor(Color.text(appTheme.theme))
- .accessibilityLabel("긴급 알림으로 설정")
- Spacer()
- Toggle("", isOn: $viewModel.isUrgent)
- .labelsHidden()
- .accessibilityLabel("긴급 알림 토글")
- .accessibilityValue(viewModel.isUrgent ? "켜짐" : "꺼짐")
- .accessibilityHint("탭하여 긴급 알림 설정을 변경합니다")
- }
- .padding(.vertical)
+ urlSelectionSection
+ nameInputSection
+ keywordFilterSection
+ urgentToggleSection
}
.padding(.horizontal, 16)
.padding(.top, 8)
@@ -198,29 +45,8 @@ struct AddSubscriptionView: View {
.scrollDismissesKeyboard(.interactively)
.frame(maxWidth: .infinity, maxHeight: .infinity)
- // 하단 고정 "등록 승인 요청" 버튼
- AddSubscriptionButton(
- title: viewModel.isLoading ? "등록 중..." : "등록 승인 요청",
- theme: appTheme.theme,
- isEnabled: viewModel.isSubmitEnabled && !viewModel.isLoading
- ) {
- viewModel.createSubscription { success in
- if success {
- dismiss()
- }
- }
- }
- // 접근성: 활성/비활성 상태에 따라 안내 문구 변경
- .accessibilityLabel(viewModel.isLoading ? "등록 중, 버튼" : "등록 승인 요청, 버튼")
- .accessibilityHint(
- viewModel.isLoading
- ? "구독을 등록하는 중입니다"
- : viewModel.isSubmitEnabled
- ? "이 웹사이트 등록 승인을 요청합니다."
- : "웹사이트 URL을 선택해야 활성화됩니다."
- )
- .padding(.horizontal, 16)
- .padding(.vertical, 16)
+ // 하단 고정 버튼
+ submitButtonSection
// 에러 메시지 표시
if let errorMessage = viewModel.errorMessage {
@@ -236,27 +62,22 @@ struct AddSubscriptionView: View {
}
}
.ignoresSafeArea(.keyboard, edges: .bottom)
- // URL 선택 시트
.sheet(isPresented: $viewModel.showURLSelector) {
URLSelectorSheet(viewModel: viewModel, theme: appTheme.theme)
.onAppear {
- // URL 설정 시트가 열릴 때 URL 목록 로드
if viewModel.availableURLs.isEmpty {
viewModel.loadURLs()
}
}
}
- // 키워드 설정 시트
.sheet(isPresented: $viewModel.showKeywordSelector) {
KeywordSelectorSheet(viewModel: viewModel, theme: appTheme.theme)
.onAppear {
- // 키워드 설정 시트가 열릴 때 키워드 목록 로드
if viewModel.availableKeywords.isEmpty {
viewModel.loadKeywords()
}
}
}
- // 키보드 상단에 항상 "키보드 닫기" 버튼 제공
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
@@ -269,364 +90,174 @@ struct AddSubscriptionView: View {
)
}
.accessibilityLabel("키보드 닫기")
- .accessibilityHint("탭하여 키보드를 숨깁니다.")
+ .accessibilityHint("탭하여 키보드를 숨깁니다")
}
}
}
-}
-
-// MARK: - URL 선택 시트
-struct URLSelectorSheet: View {
- @ObservedObject var viewModel: AddSubscriptionViewModel
- let theme: AppTheme
- @Environment(\.dismiss) var dismiss
-
- var body: some View {
- ZStack {
- Color.background(theme)
- .ignoresSafeArea()
- .onTapGesture {
- UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
- }
-
- VStack(spacing: 0) {
- SheetHandleBar(theme: theme)
- .padding(.top, 20)
- .accessibilityElement()
- .accessibilityLabel("URL 설정 창 닫기")
- .accessibilityHint("이 영역을 두 번 탭하거나 아래로 스와이프하면 창이 닫힙니다.")
- .onTapGesture {
- dismiss()
- }
-
- // URL 설정 화면 제목
- ScreenSubTitle(text: "URL 선택", theme: theme)
-
- VStack(spacing: 0) {
- // 스크롤 가능한 URL 목록
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- if viewModel.isLoadingURLs {
- VStack(spacing: 16) {
- ProgressView("불러오는 중...")
- .progressViewStyle(CircularProgressViewStyle())
- .accessibilityLabel("URL 목록을 불러오는 중입니다")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- } else if let errorMessage = viewModel.urlErrorMessage {
- VStack(spacing: 16) {
- Text(errorMessage)
- .font(.KoddiBold20)
- .foregroundColor(Color.secondaryText(theme))
- .accessibilityLabel("오류: \(errorMessage)")
-
- Button("다시 시도") {
- viewModel.loadURLs()
- }
- .padding(.horizontal, 24)
- .padding(.vertical, 12)
- .font(.KoddiBold20)
- .foregroundColor(.white)
- .background(Color.primaryGreen)
- .cornerRadius(8)
- .accessibilityLabel("다시 시도 버튼")
- .accessibilityHint("탭하여 URL 목록을 다시 불러옵니다")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- } else if viewModel.availableURLs.isEmpty {
- VStack(spacing: 16) {
- Text("등록된 URL이 없습니다.")
- .font(.KoddiBold20)
- .foregroundColor(Color.secondaryText(theme))
- .accessibilityLabel("등록된 URL이 없습니다")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- } else {
- VStack(spacing: 0) {
- ForEach(Array(viewModel.availableURLs.enumerated()), id: \.element.id) { index, url in
- URLRow(
- url: url,
- isSelected: viewModel.selectedURL?.id == url.id,
- theme: theme
- ) {
- viewModel.selectURL(url)
- dismiss()
- }
-
- if index < viewModel.availableURLs.count - 1 {
- Divider()
- .background(Color.border(theme))
- .padding(.horizontal, 20)
- }
- }
- }
- }
- }
- .padding(.top, 8)
- .padding(.bottom, 8)
+ // MARK: - URL 선택 섹션
+
+ private var urlSelectionSection: some View {
+ FormFieldSection(
+ title: "웹사이트 URL",
+ description: "모니터링할 웹페이지를 선택하세요.",
+ isRequired: true,
+ theme: appTheme.theme,
+ fieldContent: {
+ Button(action: {
+ viewModel.showURLSelector = true
+ }) {
+ HStack {
+ Text(viewModel.selectedURL?.title ?? "URL 선택...")
+ .font(.KoddiRegular16)
+ .foregroundColor(
+ viewModel.selectedURL == nil
+ ? Color.secondaryText(appTheme.theme)
+ : Color.text(appTheme.theme)
+ )
+ Spacer()
}
- .scrollDismissesKeyboard(.interactively)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 16)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.secondaryBackground(appTheme.theme))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.border(appTheme.theme), lineWidth: 1)
+ )
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("URL 선택")
+ .accessibilityValue(viewModel.selectedURL?.title ?? "선택 안 됨")
+ .accessibilityHint("탭하면 웹사이트 선택 창이 나타납니다")
}
- }
+ )
}
-}
-
-// MARK: - URL 행 컴포넌트
-
-struct URLRow: View {
- let url: URLItem
- let isSelected: Bool
- let theme: AppTheme
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: 16) {
- VStack(alignment: .leading, spacing: 4) {
- Text(url.title)
- .font(.KoddiBold20)
- .foregroundColor(Color.text(theme))
-
- Text(url.link)
- .font(.KoddiRegular16)
- .foregroundColor(Color.secondaryText(theme))
- .lineLimit(1)
- }
- Spacer()
+ // MARK: - 웹페이지 별명 입력 섹션
- if isSelected {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 24))
- .foregroundColor(Color.primaryGreen)
- } else {
- Image(systemName: "circle")
- .font(.system(size: 24))
- .foregroundColor(Color.border(theme))
- }
- }
- .padding(.vertical, 16)
- .padding(.horizontal, 20)
- }
- .buttonStyle(PlainButtonStyle())
- .accessibilityLabel("URL \(url.title)")
- .accessibilityValue(isSelected ? "선택됨" : "선택 안 됨")
- .accessibilityHint("탭하여 이 URL을 선택합니다")
+ private var nameInputSection: some View {
+ FormFieldSection(
+ title: "웹페이지 별명",
+ description: "해당 페이지를 식별할 명칭을 입력하세요.",
+ isRequired: false,
+ text: $viewModel.nameText,
+ theme: appTheme.theme
+ )
}
-}
-
-// MARK: - 키워드 선택 시트
-
-struct KeywordSelectorSheet: View {
- @ObservedObject var viewModel: AddSubscriptionViewModel
- let theme: AppTheme
- @Environment(\.dismiss) var dismiss
- var body: some View {
- ZStack {
- Color.background(theme)
- .ignoresSafeArea()
- .onTapGesture {
- UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
- }
-
- VStack(spacing: 0) {
- SheetHandleBar(theme: theme)
- .padding(.top, 20)
- .accessibilityElement()
- .accessibilityLabel("키워드 설정 창 닫기")
- .accessibilityHint("이 영역을 두 번 탭하거나 아래로 스와이프하면 창이 닫힙니다.")
- .onTapGesture {
- dismiss()
+ // MARK: - 키워드 필터 섹션
+
+ private var keywordFilterSection: some View {
+ FormFieldSection(
+ title: "키워드 필터",
+ description: "관심 키워드가 포함된 글을 알림으로 받아보세요.",
+ isRequired: false,
+ theme: appTheme.theme,
+ fieldContent: {
+ Button(action: {
+ viewModel.showKeywordSelector = true
+ }) {
+ HStack {
+ Text(viewModel.selectedKeywordNames.isEmpty ? "키워드 추가..." : "키워드 수정...")
+ .font(.KoddiRegular16)
+ .foregroundColor(Color.secondaryText(appTheme.theme))
+ Spacer()
}
-
- // 키워드 설정 화면 제목
- ScreenSubTitle(text: "키워드 설정", theme: theme)
-
- VStack(spacing: 0) {
- // 스크롤 가능한 키워드 목록
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- if viewModel.isLoadingKeywords {
- VStack(spacing: 16) {
- ProgressView("불러오는 중...")
- .progressViewStyle(CircularProgressViewStyle())
- .accessibilityLabel("키워드 목록을 불러오는 중입니다")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- } else if let errorMessage = viewModel.keywordErrorMessage {
- VStack(spacing: 16) {
- Text(errorMessage)
- .font(.KoddiBold20)
- .foregroundColor(Color.secondaryText(theme))
- .accessibilityLabel("오류: \(errorMessage)")
-
- Button("다시 시도") {
- viewModel.loadKeywords()
- }
- .padding(.horizontal, 24)
- .padding(.vertical, 12)
- .font(.KoddiBold20)
- .foregroundColor(.white)
- .background(Color.primaryGreen)
- .cornerRadius(8)
- .accessibilityLabel("다시 시도 버튼")
- .accessibilityHint("탭하여 키워드 목록을 다시 불러옵니다")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- } else if viewModel.availableKeywords.isEmpty {
- VStack(spacing: 16) {
- Text("등록된 키워드가 없습니다.")
- .font(.KoddiBold20)
- .foregroundColor(Color.secondaryText(theme))
- .accessibilityLabel("등록된 키워드가 없습니다")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- } else {
- VStack(spacing: 0) {
- ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.element.id) { index, keyword in
- KeywordCheckboxRow(
- keyword: keyword.name,
- isSelected: viewModel.selectedKeywordIds.contains(keyword.id),
- theme: theme
- ) {
- viewModel.toggleKeyword(keyword.id)
- }
-
- if index < viewModel.availableKeywords.count - 1 {
- Divider()
- .background(Color.border(theme))
- .padding(.horizontal, 20)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 16)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.secondaryBackground(appTheme.theme))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.border(appTheme.theme), lineWidth: 1)
+ )
+ }
+ .accessibilityLabel(viewModel.selectedKeywordNames.isEmpty ? "키워드 추가" : "키워드 수정")
+ .accessibilityHint("탭하면 키워드 선택 창이 나타납니다")
+ .accessibilityValue(
+ viewModel.selectedKeywordNames.isEmpty
+ ? "선택 안 됨"
+ : "\(viewModel.selectedKeywordNames.count)개 선택됨"
+ )
+ },
+ additionalContent: {
+ AnyView(
+ Group {
+ if !viewModel.selectedKeywordNames.isEmpty {
+ FlowLayout(spacing: 8) {
+ ForEach(viewModel.selectedKeywordNames, id: \.self) { keywordName in
+ KeywordBadgeWithDelete(
+ text: keywordName,
+ theme: appTheme.theme
+ ) {
+ if let keyword = viewModel.availableKeywords.first(where: { $0.name == keywordName }) {
+ viewModel.removeKeyword(keyword.id)
}
}
}
}
}
- .padding(.top, 8)
- .padding(.bottom, 8)
- }
- .scrollDismissesKeyboard(.interactively)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- // 하단 고정 "저장하기" 버튼
- AddSubscriptionButton(
- title: "저장하기",
- theme: theme,
- isEnabled: true
- ) {
- dismiss()
}
- .padding(.horizontal, 20)
- .padding(.top, 12)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ )
}
- }
+ )
}
-}
-// MARK: - 키워드 배지 + FlowLayout (파일 내부용)
+ // MARK: - 긴급 알림 토글 섹션
-/// 삭제 버튼이 있는 키워드 배지
-struct KeywordBadgeWithDelete: View {
- let text: String
- let theme: AppTheme
- let onDelete: () -> Void
- // theme 파라미터는 현재 사용되지 않지만, 향후 테마 적용을 위해 유지
+ private var urgentToggleSection: some View {
+ HStack(alignment: .top, spacing: 16) {
+ // 타이틀과 설명을 왼쪽에 배치 (보이스오버 순서: 타이틀 → 설명)
+ VStack(alignment: .leading, spacing: 12) {
+ Text("긴급 알림으로 설정")
+ .font(.KoddiBold20)
+ .foregroundColor(Color.text(appTheme.theme))
- var body: some View {
- HStack(spacing: 6) {
- Text(text)
- .font(.KoddiBold14)
- .foregroundColor(.primaryGreen)
-
- Button(action: onDelete) {
- Image(systemName: "xmark")
- .font(.KoddiBold14)
- .foregroundColor(.primaryGreen)
+ Text("이 사이트의 모든 새로운 글에 대해 알림을 받습니다.")
+ .font(.KoddiRegular16)
+ .foregroundColor(Color.secondaryText(appTheme.theme))
+ .fixedSize(horizontal: false, vertical: true)
}
- .accessibilityLabel("\(text) 키워드 삭제")
- .accessibilityHint("탭하여 이 키워드를 제거합니다")
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(
- RoundedRectangle(cornerRadius: 20)
- .fill(Color.badgeGreenBackground)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 20)
- .stroke(Color.primaryGreen, lineWidth: 1)
- )
- .accessibilityElement(children: .combine)
- .accessibilityLabel("선택된 키워드: \(text)")
- }
-}
-
-/// 여러 배지를 자동으로 줄바꿈해 배치해주는 레이아웃
-struct FlowLayout: Layout {
- var spacing: CGFloat = 8
+ .accessibilityElement(children: .combine)
- func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
- let maxWidth = proposal.replacingUnspecifiedDimensions().width
- let result = FlowResult(in: maxWidth, subviews: subviews, spacing: spacing)
- return result.size
- }
+ Spacer()
- func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
- let result = FlowResult(in: bounds.width, subviews: subviews, spacing: spacing)
- for (index, subview) in subviews.enumerated() {
- subview.place(
- at: CGPoint(
- x: bounds.minX + result.positions[index].x,
- y: bounds.minY + result.positions[index].y
- ),
- proposal: .unspecified
- )
+ // 토글을 오른쪽에 배치 (보이스오버에서는 마지막에 읽힘)
+ Toggle("", isOn: $viewModel.isUrgent)
+ .labelsHidden()
+ .accessibilityLabel("긴급 알림")
+ .accessibilityValue(viewModel.isUrgent ? "켜짐" : "꺼짐")
}
+ .padding(.vertical)
}
- struct FlowResult {
- var size: CGSize = .zero
- var positions: [CGPoint] = []
-
- init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) {
- var currentX: CGFloat = 0
- var currentY: CGFloat = 0
- var lineHeight: CGFloat = 0
-
- for subview in subviews {
- let size = subview.sizeThatFits(.unspecified)
-
- if currentX + size.width > maxWidth, currentX > 0 {
- currentX = 0
- currentY += lineHeight + spacing
- lineHeight = 0
+ // MARK: - 제출 버튼 섹션
+
+ private var submitButtonSection: some View {
+ MainButton(
+ title: viewModel.isLoading ? "등록 중..." : "구독 목록에 추가",
+ theme: appTheme.theme,
+ isEnabled: viewModel.isSubmitEnabled && !viewModel.isLoading
+ ) {
+ viewModel.createSubscription { success in
+ if success {
+ dismiss()
}
-
- positions.append(CGPoint(x: currentX, y: currentY))
- lineHeight = max(lineHeight, size.height)
- currentX += size.width + spacing
}
-
- size = CGSize(width: maxWidth, height: currentY + lineHeight)
}
- }
-}
-
-struct AddSubscriptionView_Previews: PreviewProvider {
- static var previews: some View {
- AddSubscriptionView()
- .environmentObject(AppThemeManager())
+ .accessibilityLabel(viewModel.isLoading ? "등록 중" : "구독 목록에 추가")
+ .accessibilityHint(
+ viewModel.isLoading
+ ? "등록 중입니다"
+ : viewModel.isSubmitEnabled
+ ? "이 웹사이트를 구독 목록에 추가합니다"
+ : "웹사이트 URL을 선택해야 활성화됩니다"
+ )
+ .padding(.horizontal, 16)
+ .padding(.vertical, 16)
}
}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift
index 06fde88..072e3eb 100644
--- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift
+++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift
@@ -47,7 +47,7 @@ final class AddSubscriptionViewModel: ObservableObject {
urlId: selectedURL.id,
keywordIds: selectedKeywordIds,
alias: nameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- ? nil
+ ? selectedURL.title
: nameText.trimmingCharacters(in: .whitespacesAndNewlines),
isUrgent: isUrgent
)
@@ -130,11 +130,6 @@ final class AddSubscriptionViewModel: ObservableObject {
urlText = url.link
}
- func clearURL() {
- selectedURL = nil
- urlText = ""
- }
-
// MARK: - 키워드 선택 로직
func addKeyword(_ keywordId: Int64) {
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift
new file mode 100644
index 0000000..47c8f97
--- /dev/null
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift
@@ -0,0 +1,59 @@
+//
+// FlowLayout.swift
+// today-s-sound
+//
+// 여러 배지를 자동으로 줄바꿈해 배치해주는 레이아웃
+//
+
+import SwiftUI
+
+/// 여러 배지를 자동으로 줄바꿈해 배치해주는 레이아웃
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let maxWidth = proposal.replacingUnspecifiedDimensions().width
+ let result = FlowResult(in: maxWidth, subviews: subviews, spacing: spacing)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = FlowResult(in: bounds.width, subviews: subviews, spacing: spacing)
+ for (index, subview) in subviews.enumerated() {
+ subview.place(
+ at: CGPoint(
+ x: bounds.minX + result.positions[index].x,
+ y: bounds.minY + result.positions[index].y
+ ),
+ proposal: .unspecified
+ )
+ }
+ }
+
+ struct FlowResult {
+ var size: CGSize = .zero
+ var positions: [CGPoint] = []
+
+ init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) {
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+
+ if currentX + size.width > maxWidth, currentX > 0 {
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ lineHeight = max(lineHeight, size.height)
+ currentX += size.width + spacing
+ }
+
+ size = CGSize(width: maxWidth, height: currentY + lineHeight)
+ }
+ }
+}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift
new file mode 100644
index 0000000..bdd5318
--- /dev/null
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift
@@ -0,0 +1,114 @@
+//
+// FormFieldSection.swift
+// today-s-sound
+//
+// 소제목-설명-필드 구조를 가진 범용 폼 섹션 컴포넌트
+// 필드는 TextField일 수도 있고 Button일 수도 있음
+//
+
+import SwiftUI
+
+struct FormFieldSection: View {
+ let title: String
+ let description: String
+ let isRequired: Bool
+ let theme: AppTheme
+ let fieldContent: () -> FieldContent
+ let additionalContent: (() -> AnyView)?
+
+ init(
+ title: String,
+ description: String,
+ isRequired: Bool = false,
+ theme: AppTheme,
+ @ViewBuilder fieldContent: @escaping () -> FieldContent,
+ additionalContent: (() -> AnyView)? = nil
+ ) {
+ self.title = title
+ self.description = description
+ self.isRequired = isRequired
+ self.theme = theme
+ self.fieldContent = fieldContent
+ self.additionalContent = additionalContent
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 12) {
+ // 소제목 + 필수(*) 표시
+ HStack(spacing: 4) {
+ Text(title)
+ .font(.KoddiBold20)
+ .foregroundColor(Color.text(theme))
+
+ if isRequired {
+ Text("*")
+ .font(.KoddiBold20)
+ .foregroundColor(.red)
+ .accessibilityHidden(true)
+ }
+ }
+ // 설명
+ Text(description)
+ .font(.KoddiRegular16)
+ .foregroundColor(Color.secondaryText(theme))
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(isRequired ? "\(title), 필수 항목, \(description)" : "\(title), \(description)")
+
+ // 필드 (TextField 또는 Button)
+ fieldContent()
+
+ // 추가 컨텐츠
+ if let additionalContent {
+ additionalContent()
+ }
+ }
+ }
+}
+
+// MARK: - TextField 전용 편의 이니셜라이저
+
+extension FormFieldSection {
+ /// TextField를 위한 편의 이니셜라이저
+ /// InputFieldSection을 대체하는 용도
+ init(
+ title: String,
+ description: String,
+ isRequired: Bool = false,
+ text: Binding,
+ theme: AppTheme,
+ additionalContent: (() -> AnyView)? = nil
+ ) where FieldContent == AnyView {
+ self.title = title
+ self.description = description
+ self.isRequired = isRequired
+ self.theme = theme
+ self.additionalContent = additionalContent
+
+ // TextField 생성
+ fieldContent = {
+ AnyView(
+ TextField("", text: text)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(isRequired ? .URL : .default)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 16)
+ .foregroundColor(Color.text(theme))
+ .font(.KoddiRegular16)
+ .contentShape(Rectangle())
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.secondaryBackground(theme))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.border(theme), lineWidth: 1)
+ )
+ .accessibilityLabel(title)
+ )
+ }
+ }
+}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift
deleted file mode 100644
index 0cd7730..0000000
--- a/today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift
+++ /dev/null
@@ -1,133 +0,0 @@
-//
-// InputFieldSection.swift
-// today-s-sound
-//
-
-import SwiftUI
-
-struct InputFieldSection: View {
- let title: String
- let description: String
- let isRequired: Bool
- @Binding var text: String
- let theme: AppTheme
- let additionalContent: (() -> AnyView)?
-
- init(
- title: String,
- description: String,
- isRequired: Bool = false,
- text: Binding,
- theme: AppTheme,
- additionalContent: (() -> AnyView)? = nil
- ) {
- self.title = title
- self.description = description
- self.isRequired = isRequired
- _text = text
- self.theme = theme
- self.additionalContent = additionalContent
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- // 타이틀 + 필수(*) 표시
- HStack(spacing: 4) {
- Text(title)
- .font(.KoddiBold20)
- .foregroundColor(Color.text(theme))
- .accessibilityLabel(isRequired ? "\(title) 필수 입력" : title)
-
- if isRequired {
- Text("*")
- .font(.KoddiBold20)
- .foregroundColor(.red)
- .accessibilityHidden(true)
- }
- }
-
- // TextField
- TextField("", text: $text)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .keyboardType(isRequired ? .URL : .default)
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .foregroundColor(Color.text(theme))
- .font(.KoddiRegular16)
- .contentShape(Rectangle())
- .background(
- RoundedRectangle(cornerRadius: 8)
- .fill(Color.secondaryBackground(theme))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.border(theme), lineWidth: 1)
- )
- .accessibilityLabel("\(title) 편집창")
- .accessibilityHint(description)
- .accessibilityValue(text.isEmpty ? "" : text)
-
- // 추가 컨텐츠 (예: 추천 키워드 배지 등)
- if let additionalContent {
- additionalContent()
- }
-
- // 설명 텍스트
- Text(description)
- .font(.KoddiRegular16)
- .foregroundColor(Color.secondaryText(theme))
- .accessibilityLabel(description)
- }
- }
-}
-
-// MARK: - Preview
-
-struct InputFieldSection_Previews: PreviewProvider {
- static var previews: some View {
- Group {
- VStack(spacing: 24) {
- InputFieldSection(
- title: "웹사이트 URL",
- description: "모니터링할 웹페이지 URL을 입력하세요.",
- isRequired: true,
- text: .constant(""),
- theme: .normal
- )
-
- InputFieldSection(
- title: "웹페이지 별명",
- description: "해당 페이지를 식별할 명칭을 입력하세요. (선택 사항)",
- isRequired: false,
- text: .constant("이미 입력된 값"),
- theme: .normal
- )
- }
- .padding()
- .background(Color.background(.normal))
- .previewDisplayName("Normal Mode")
-
- VStack(spacing: 24) {
- InputFieldSection(
- title: "웹사이트 URL",
- description: "모니터링할 웹페이지 URL을 입력하세요.",
- isRequired: true,
- text: .constant(""),
- theme: .highContrast
- )
-
- InputFieldSection(
- title: "웹페이지 별명",
- description: "해당 페이지를 식별할 명칭을 입력하세요. (선택 사항)",
- isRequired: false,
- text: .constant("이미 입력된 값"),
- theme: .highContrast
- )
- }
- .padding()
- .background(Color.background(.highContrast))
- .previewDisplayName("High Contrast Mode")
- }
- }
-}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift
new file mode 100644
index 0000000..c86d3c3
--- /dev/null
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift
@@ -0,0 +1,43 @@
+//
+// KeywordBadgeWithDelete.swift
+// today-s-sound
+//
+// 삭제 버튼이 있는 키워드 배지 컴포넌트
+//
+
+import SwiftUI
+
+/// 삭제 버튼이 있는 키워드 배지
+struct KeywordBadgeWithDelete: View {
+ let text: String
+ let theme: AppTheme
+ let onDelete: () -> Void
+ // theme 파라미터는 현재 사용되지 않지만, 향후 테마 적용을 위해 유지
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Text(text)
+ .font(.KoddiBold14)
+ .foregroundColor(.primaryGreen)
+
+ Button(action: onDelete) {
+ Image(systemName: "xmark")
+ .font(.KoddiBold14)
+ .foregroundColor(.primaryGreen)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 20)
+ .fill(Color.badgeGreenBackground)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(Color.primaryGreen, lineWidth: 1)
+ )
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("선택된 키워드: \(text)")
+ .accessibilityHint("탭하여 이 키워드 추가를 취소합니다")
+ }
+}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift
index 21cd79e..de5e2ad 100644
--- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordCheckboxRow.swift
@@ -42,31 +42,7 @@ struct KeywordCheckboxRow: View {
.padding(.horizontal, 20)
}
.buttonStyle(PlainButtonStyle())
- .accessibilityLabel("키워드 \(keyword)")
.accessibilityValue(isSelected ? "선택됨" : "선택 안 됨")
.accessibilityHint("탭하여 이 키워드를 선택하거나 해제합니다")
}
}
-
-struct KeywordCheckboxRow_Previews: PreviewProvider {
- static var previews: some View {
- VStack(spacing: 12) {
- KeywordCheckboxRow(
- keyword: "시각장애",
- isSelected: true,
- theme: .normal,
- action: {}
- )
-
- KeywordCheckboxRow(
- keyword: "접근성",
- isSelected: false,
- theme: .highContrast,
- action: {}
- )
- }
- .previewLayout(.sizeThatFits)
- .padding()
- .background(Color(UIColor.systemBackground))
- }
-}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift
new file mode 100644
index 0000000..6e4bfb4
--- /dev/null
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift
@@ -0,0 +1,118 @@
+//
+// KeywordSelectorSheet.swift
+// today-s-sound
+//
+// 키워드 선택 시트 컴포넌트
+//
+
+import SwiftUI
+
+struct KeywordSelectorSheet: View {
+ @ObservedObject var viewModel: AddSubscriptionViewModel
+ let theme: AppTheme
+ @Environment(\.dismiss) var dismiss
+
+ var body: some View {
+ ZStack {
+ Color.background(theme)
+ .ignoresSafeArea()
+ .onTapGesture {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+
+ VStack(spacing: 0) {
+ SheetHandleBar(theme: theme) {
+ dismiss()
+ }
+ .padding(.top, 20)
+ .accessibilityElement()
+ .accessibilityLabel("키워드 설정 창 닫기")
+ .accessibilityHint("탭하거나 아래로 스와이프하면 창이 닫힙니다")
+
+ // 키워드 설정 화면 제목
+ ScreenSubTitle(text: "키워드 설정", theme: theme)
+
+ VStack(spacing: 0) {
+ // 스크롤 가능한 키워드 목록
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ if viewModel.isLoadingKeywords {
+ VStack(spacing: 16) {
+ ProgressView("불러오는 중...")
+ .progressViewStyle(CircularProgressViewStyle())
+ .accessibilityLabel("키워드 목록을 불러오는 중입니다")
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else if let errorMessage = viewModel.keywordErrorMessage {
+ VStack(spacing: 16) {
+ Text(errorMessage)
+ .font(.KoddiBold20)
+ .foregroundColor(Color.secondaryText(theme))
+ .accessibilityLabel("오류: \(errorMessage)")
+
+ Button("다시 시도") {
+ viewModel.loadKeywords()
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 12)
+ .font(.KoddiBold20)
+ .foregroundColor(.white)
+ .background(Color.primaryGreen)
+ .cornerRadius(8)
+ .accessibilityLabel("다시 시도")
+ .accessibilityHint("탭하여 키워드 목록을 다시 불러옵니다")
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else if viewModel.availableKeywords.isEmpty {
+ VStack(spacing: 16) {
+ Text("등록된 키워드가 없습니다.")
+ .font(.KoddiBold20)
+ .foregroundColor(Color.secondaryText(theme))
+ .accessibilityLabel("등록된 키워드가 없습니다")
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(viewModel.availableKeywords.enumerated()), id: \.element.id) { index, keyword in
+ KeywordCheckboxRow(
+ keyword: keyword.name,
+ isSelected: viewModel.selectedKeywordIds.contains(keyword.id),
+ theme: theme
+ ) {
+ viewModel.toggleKeyword(keyword.id)
+ }
+
+ if index < viewModel.availableKeywords.count - 1 {
+ Divider()
+ .background(Color.border(theme))
+ .padding(.horizontal, 20)
+ }
+ }
+ }
+ }
+ }
+ .padding(.top, 8)
+ .padding(.bottom, 8)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ // 하단 고정 "저장하기" 버튼
+ MainButton(
+ title: "저장하기",
+ theme: theme,
+ isEnabled: true
+ ) {
+ dismiss()
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+}
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift
index 740e2ad..f18e0ef 100644
--- a/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/SheetHandler.swift
@@ -10,14 +10,32 @@ import SwiftUI
/// 시트 상단에 보이는 작은 핸들 바
struct SheetHandleBar: View {
let theme: AppTheme
+ let onTap: (() -> Void)?
+
+ init(theme: AppTheme, onTap: (() -> Void)? = nil) {
+ self.theme = theme
+ self.onTap = onTap
+ }
var body: some View {
VStack(spacing: 8) {
- Capsule()
- .fill(Color.secondaryText(theme).opacity(0.3))
- .frame(width: 80, height: 5)
- .padding(.top, 8)
- .accessibilityHidden(true)
+ // 터치 영역을 넓히기 위한 투명한 영역
+ ZStack {
+ // 실제 핸들 바
+ Capsule()
+ .fill(Color.secondaryText(theme).opacity(0.3))
+ .frame(width: 80, height: 5)
+ .padding(.top, 8)
+
+ // 넓은 터치 영역 (투명)
+ Color.clear
+ .frame(height: 44) // 최소 터치 영역 확보
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onTap?()
+ }
+ }
+ .accessibilityHidden(true)
// 핸들 바와 실제 콘텐츠 사이 살짝 여백
Spacer()
diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift
new file mode 100644
index 0000000..a039300
--- /dev/null
+++ b/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift
@@ -0,0 +1,151 @@
+//
+// URLSelectorSheet.swift
+// today-s-sound
+//
+// URL 선택 시트 컴포넌트
+//
+
+import SwiftUI
+
+struct URLSelectorSheet: View {
+ @ObservedObject var viewModel: AddSubscriptionViewModel
+ let theme: AppTheme
+ @Environment(\.dismiss) var dismiss
+
+ var body: some View {
+ ZStack {
+ Color.background(theme)
+ .ignoresSafeArea()
+ .onTapGesture {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+
+ VStack(spacing: 0) {
+ SheetHandleBar(theme: theme) {
+ dismiss()
+ }
+ .padding(.top, 20)
+ .accessibilityElement()
+ .accessibilityLabel("URL 설정 창 닫기")
+ .accessibilityHint("탭하거나 두 손가락을 아래로 스와이프하면 창이 닫힙니다")
+
+ // URL 설정 화면 제목
+ ScreenSubTitle(text: "URL 선택", theme: theme)
+
+ VStack(spacing: 0) {
+ // 스크롤 가능한 URL 목록
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ if viewModel.isLoadingURLs {
+ VStack(spacing: 16) {
+ ProgressView("불러오는 중...")
+ .progressViewStyle(CircularProgressViewStyle())
+ .accessibilityLabel("URL 목록을 불러오는 중입니다")
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else if let errorMessage = viewModel.urlErrorMessage {
+ VStack(spacing: 16) {
+ Text(errorMessage)
+ .font(.KoddiBold20)
+ .foregroundColor(Color.secondaryText(theme))
+ .accessibilityLabel("오류: \(errorMessage)")
+
+ Button("다시 시도") {
+ viewModel.loadURLs()
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 12)
+ .font(.KoddiBold20)
+ .foregroundColor(.white)
+ .background(Color.primaryGreen)
+ .cornerRadius(8)
+ .accessibilityLabel("다시 시도")
+ .accessibilityHint("탭하여 URL 목록을 다시 불러옵니다")
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else if viewModel.availableURLs.isEmpty {
+ VStack(spacing: 16) {
+ Text("등록된 URL이 없습니다.")
+ .font(.KoddiBold20)
+ .foregroundColor(Color.secondaryText(theme))
+ .accessibilityLabel("등록된 URL이 없습니다")
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(viewModel.availableURLs.enumerated()), id: \.element.id) { index, url in
+ URLRow(
+ url: url,
+ isSelected: viewModel.selectedURL?.id == url.id,
+ theme: theme
+ ) {
+ viewModel.selectURL(url)
+ dismiss()
+ }
+
+ if index < viewModel.availableURLs.count - 1 {
+ Divider()
+ .background(Color.border(theme))
+ .padding(.horizontal, 20)
+ }
+ }
+ }
+ }
+ }
+ .padding(.top, 8)
+ .padding(.bottom, 8)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+}
+
+// MARK: - URL 행 컴포넌트
+
+struct URLRow: View {
+ let url: URLItem
+ let isSelected: Bool
+ let theme: AppTheme
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(url.title)
+ .font(.KoddiBold20)
+ .foregroundColor(Color.text(theme))
+
+ Text(url.link)
+ .font(.KoddiRegular16)
+ .foregroundColor(Color.secondaryText(theme))
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 24))
+ .foregroundColor(Color.primaryGreen)
+ } else {
+ Image(systemName: "circle")
+ .font(.system(size: 24))
+ .foregroundColor(Color.border(theme))
+ }
+ }
+ .padding(.vertical, 16)
+ .padding(.horizontal, 20)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .accessibilityValue(isSelected ? "선택됨" : "선택 안 됨")
+ .accessibilityHint("탭하여 이 URL을 선택합니다")
+ }
+}
diff --git a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift
index ac38051..5576577 100644
--- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift
+++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift
@@ -5,9 +5,18 @@ struct HomeView: View {
@ObservedObject private var speechService = SpeechService.shared
@EnvironmentObject var appTheme: AppThemeManager
+ private var currentCategoryA11yLabel: String {
+ if viewModel.isLoading {
+ "현재 카테고리, 새로운 글을 불러오는 중입니다"
+ } else if viewModel.currentCategoryName.isEmpty {
+ "현재 카테고리, 등록된 페이지가 없습니다. 구독을 추가해주세요."
+ } else {
+ "현재 카테고리, \(viewModel.currentCategoryName)"
+ }
+ }
+
var body: some View {
ZStack {
- // 앱 테마에 따라 배경색 변경
Color.background(appTheme.theme)
.ignoresSafeArea()
@@ -18,9 +27,9 @@ struct HomeView: View {
.foregroundStyle(Color.text(appTheme.theme))
.padding(.top, 120)
.padding(.bottom, 60)
- .accessibilityElement() // 이 텍스트를 독립 요소로
- .accessibilityLabel("오늘의 소리") // 👉 "오늘의 소리"라고 읽기
- .accessibilityAddTraits(.isHeader) // 머리말(헤더)로 인식
+ .accessibilityElement()
+ .accessibilityLabel("오늘의 소리")
+ .accessibilityAddTraits(.isHeader)
Button(
action: {
@@ -41,67 +50,42 @@ struct HomeView: View {
.padding(20)
}
)
- .accessibilityLabel(speechService.isSpeaking ? "재생 중단 버튼" : "재생 시작 버튼")
- .accessibilityHint(speechService.isSpeaking ? "이중탭하여 재생을 중단합니다" : "이중탭하여 알림을 재생합니다")
+ .accessibilityLabel(speechService.isSpeaking ? "재생 중단" : "재생 시작")
.padding(.bottom, 60)
Spacer()
VStack(spacing: 16) {
- // "현재 카테고리" 텍스트
Text("현재 카테고리")
.font(.KoddiBold28)
.foregroundColor(Color.text(appTheme.theme))
- .accessibilityElement()
- .accessibilityLabel("현재 카테고리")
+ .accessibilityHidden(true)
- if viewModel.isLoading {
- Text("불러오는 중...")
- .font(.KoddiExtraBold32)
- .foregroundColor(.white)
- .padding(.horizontal, 32)
- .padding(.vertical, 18)
- .frame(width: 360, height: 84)
- .background(
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.primaryGreen.opacity(0.6))
- )
- .foregroundColor(.white)
- .accessibilityElement()
- .accessibilityLabel("피드를 불러오는 중입니다")
- } else if viewModel.currentCategoryName.isEmpty {
- Text("등록된 페이지 없음")
- .font(.KoddiExtraBold32)
- .foregroundColor(.white)
- .padding(.horizontal, 32)
- .padding(.vertical, 18)
- .frame(width: 360, height: 84)
- .background(
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.primaryGreen.opacity(0.6))
- )
- .foregroundColor(.white)
- .accessibilityElement()
- .accessibilityLabel("재생할 피드가 없습니다")
- } else {
- // 현재 카테고리 이름 카드
- Text(viewModel.currentCategoryName)
- .font(.KoddiExtraBold32)
- .foregroundColor(.white)
- .padding(.horizontal, 32)
- .padding(.vertical, 18)
- .frame(width: 360, height: 84)
- .background(
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.primaryGreen)
- )
- .foregroundColor(.white)
- .accessibilityElement()
- .accessibilityLabel(viewModel.currentCategoryName) // 👉 카테고리명만 또렷하게
- .accessibilityHint("현재 재생 중인 카테고리입니다")
+ Group {
+ if viewModel.isLoading {
+ Text("불러오는 중...")
+ } else if viewModel.currentCategoryName.isEmpty {
+ Text("등록된 페이지 없음")
+ } else {
+ Text(viewModel.currentCategoryName)
+ }
}
+ .font(.KoddiExtraBold32)
+ .foregroundColor(.white)
+ .padding(.horizontal, 32)
+ .padding(.vertical, 18)
+ .frame(width: 360, height: 84)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(viewModel.isLoading || viewModel.currentCategoryName.isEmpty
+ ? Color.primaryGreen.opacity(0.6)
+ : Color.primaryGreen)
+ )
+ .accessibilityHidden(true)
}
.padding(.bottom, 16)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(currentCategoryA11yLabel)
}
}
.onAppear {
diff --git a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift
index 083ec17..552ac79 100644
--- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift
+++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift
@@ -8,113 +8,94 @@ import SwiftUI
struct AlertCardView: View {
let alarm: AlarmItem
let theme: AppTheme
- let isRead: Bool
- let onMarkAsRead: ((AlarmItem) -> Void)?
- @Environment(\.openURL) private var openURL
+ let onDelete: ((AlarmItem) -> Void)?
- init(alarm: AlarmItem, theme: AppTheme, isRead: Bool = false, onMarkAsRead: ((AlarmItem) -> Void)? = nil) {
+ init(alarm: AlarmItem, theme: AppTheme, onDelete: ((AlarmItem) -> Void)? = nil) {
self.alarm = alarm
self.theme = theme
- self.isRead = isRead
- self.onMarkAsRead = onMarkAsRead
+ self.onDelete = onDelete
}
private var cardColor: Color {
- if isRead {
- return Color.gray.opacity(0.6) // 읽음 상태: 회색
- }
- return alarm.isUrgent ? .urgentPink : .primaryGreen
+ alarm.isUrgent ? .urgentPink : .primaryGreen
}
- private var textColor: Color {
- .white
- }
+ private var textColor: Color { .white }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
- // 상단: 아이콘 + 제목 + 읽음 체크
+ // 상단: 아이콘 + 제목 + (오른쪽 상단 삭제 버튼)
HStack(alignment: .top, spacing: 12) {
Image(alarm.isUrgent ? "notice" : "mail")
.resizable()
.scaledToFit()
.frame(width: 48, height: 48)
- .opacity(isRead ? 0.6 : 1.0)
.accessibilityHidden(true)
Text(alarm.alias)
.font(.KoddiExtraBold32)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
- .accessibilityLabel("구독 페이지: \(alarm.alias)")
+ .accessibilityLabel(alarm.isUrgent ? "긴급 알림" + alarm.alias : alarm.alias)
+ .accessibilityAddTraits(.isHeader)
Spacer()
- // 읽음 체크 버튼
- if !isRead {
- Button {
- onMarkAsRead?(alarm)
- } label: {
- Image(systemName: "circle")
- .font(.system(size: 28, weight: .medium))
- .foregroundColor(textColor)
- }
- .buttonStyle(.borderless)
- .accessibilityLabel("읽음 표시")
- .accessibilityHint("탭하면 읽음으로 표시합니다")
- } else {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 28, weight: .medium))
+ // ✅ 삭제 버튼: 예전 읽음 체크 자리(오른쪽 상단), 아이콘만, 투명 배경
+ Button {
+ onDelete?(alarm)
+ } label: {
+ Image(systemName: "trash")
+ .font(.system(size: 14, weight: .bold))
.foregroundColor(textColor)
- .accessibilityLabel("읽음 표시됨")
+ .frame(width: 36, height: 36)
+ .background(
+ Circle().fill(Color.white.opacity(0.18))
+ )
+ .contentShape(Circle())
}
+ .buttonStyle(.borderless)
+ .frame(width: 44, height: 44)
+ .contentShape(Rectangle())
+ .accessibilityLabel("삭제")
+ .accessibilityHint("이 알림을 삭제합니다")
}
- // 중간: 요약 내용
+ // 본문
Text(alarm.summaryContent)
.font(.KoddiRegular20)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
- .accessibilityLabel("내용: \(alarm.summaryContent)")
- // 하단: 시간 + 원문 보기 버튼
+ // 하단: 시간 + 원문 보기(Link)
HStack {
- HStack(spacing: 8) {
- Text(alarm.timeAgo)
- .font(.KoddiRegular16)
- .foregroundColor(textColor)
-
- if isRead {
- Text("· 읽음")
- .font(.KoddiBold14)
- .foregroundColor(textColor.opacity(0.8))
- }
- }
- .accessibilityLabel("작성 시간: \(alarm.timeAgo)\(isRead ? ", 읽음" : "")")
+ Text(alarm.timeAgo)
+ .font(.KoddiRegular16)
+ .foregroundColor(textColor)
Spacer()
- // 원문 보기 버튼
- Button {
- guard let url = URL(string: alarm.postUrl) else { return }
- openURL(url)
- } label: {
- HStack(spacing: 4) {
- Text("원문 보기")
- .font(.KoddiBold14)
- Image(systemName: "arrow.up.right")
- .font(.system(size: 12, weight: .bold))
+ // ✅ 원문 보기: Link (VoiceOver가 “링크”로 읽음)
+ if let url = URL(string: alarm.postUrl) {
+ Link(destination: url) {
+ HStack(spacing: 4) {
+ Text("원문 보기")
+ .font(.KoddiBold14)
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 12, weight: .bold))
+ }
+ .foregroundColor(textColor)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ Capsule().fill(Color.white.opacity(0.2))
+ )
}
- .foregroundColor(textColor)
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- .background(
- Capsule()
- .fill(Color.white.opacity(0.2))
- )
+ .accessibilityLabel("원문 보기")
+ .accessibilityHint("탭하면 Safari에서 원문 페이지를 엽니다")
+ .accessibilityRemoveTraits(.isButton)
+ .frame(minHeight: 44)
}
- .buttonStyle(.borderless)
- .accessibilityLabel("원문 보기 버튼")
- .accessibilityHint("탭하면 Safari에서 원문 페이지를 엽니다")
}
}
.padding(16)
@@ -125,44 +106,3 @@ struct AlertCardView: View {
.accessibilityElement(children: .contain)
}
}
-
-struct AlertCardView_Previews: PreviewProvider {
- private static let sampleAlarms: [AlarmItem] = [
- AlarmItem(
- subscriptionId: 1,
- summaryId: 101,
- alias: "동국대 SW 융합교육원",
- summaryContent: "동국대학교 SW 융합교육원에서 새로운 교육 프로그램 공지가 등록되었습니다. 마감 기한을 꼭 확인해주세요.",
- postUrl: "https://www.dongguk.edu/article/GENERALNOTICES/list",
- timeAgo: "5분 전",
- isUrgent: true
- ),
- AlarmItem(
- subscriptionId: 2,
- summaryId: 102,
- alias: "오늘의 소리 팀 공지",
- summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 접근성 관련 보이스오버 개선과 버그 수정이 포함되어 있습니다.",
- postUrl: "https://techblog.woowahan.com/",
- timeAgo: "10분 전",
- isUrgent: false
- )
- ]
-
- static var previews: some View {
- Group {
- ForEach(sampleAlarms) { alarm in
- AlertCardView(alarm: alarm, theme: .normal)
- .padding()
- .previewDisplayName("Card Normal - \(alarm.alias)")
- }
-
- ForEach(sampleAlarms) { alarm in
- AlertCardView(alarm: alarm, theme: .highContrast)
- .padding()
- .background(Color.black)
- .previewDisplayName("Card High Contrast - \(alarm.alias)")
- }
- }
- .previewLayout(.sizeThatFits)
- }
-}
diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift
index f571b8b..54c8830 100644
--- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift
+++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UIKit
struct NotificationListView: View {
@StateObject private var viewModel: NotificationListViewModel
@@ -31,7 +32,6 @@ struct NotificationListView: View {
@ViewBuilder
private var content: some View {
- // 로딩
if viewModel.isLoading, viewModel.alarms.isEmpty {
Spacer()
ProgressView("불러오는 중...")
@@ -39,16 +39,15 @@ struct NotificationListView: View {
.accessibilityLabel("알림 목록을 불러오는 중입니다")
.accessibilityHint("잠시만 기다려주세요")
Spacer()
- }
- // 에러
- else if let errorMessage = viewModel.errorMessage, viewModel.alarms.isEmpty {
+ } else if let errorMessage = viewModel.errorMessage, viewModel.alarms.isEmpty {
Spacer()
VStack(spacing: 16) {
Text(errorMessage)
.font(.KoddiBold20)
.foregroundColor(Color.secondaryText(appTheme.theme))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 24)
.accessibilityLabel("오류: \(errorMessage)")
- .padding(.bottom)
Button("다시 시도") {
viewModel.refresh()
@@ -59,13 +58,11 @@ struct NotificationListView: View {
.foregroundColor(Color.white)
.background(Color.primaryGreen)
.cornerRadius(8)
- .accessibilityLabel("다시 시도 버튼")
- .accessibilityHint("탭하여 구독 목록을 다시 불러옵니다")
+ .accessibilityLabel("다시 시도")
+ .accessibilityHint("탭하여 알림 목록을 다시 불러옵니다")
}
Spacer()
- }
- // 알림 없음
- else if viewModel.alarms.isEmpty {
+ } else if viewModel.alarms.isEmpty {
Spacer()
VStack(spacing: 0) {
Text("새로운 알림이 없습니다")
@@ -75,38 +72,20 @@ struct NotificationListView: View {
}
.padding(.top, 28)
Spacer()
- }
- // 알림 목록
- else {
+ } else {
List {
ForEach(viewModel.alarms) { alarm in
- AlertCardView(
- alarm: alarm,
- theme: appTheme.theme,
- isRead: viewModel.isRead(alarm),
- onMarkAsRead: { viewModel.markAsRead($0) }
- )
- .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 12, trailing: 20))
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- .onAppear {
- viewModel.loadMoreIfNeeded(currentItem: alarm)
- }
- .swipeActions {
- Button(role: .destructive) {
- viewModel.delete(alarm: alarm)
- } label: {
- Label("삭제", systemImage: "trash")
+ row(for: alarm)
+ .onAppear {
+ viewModel.loadMoreIfNeeded(currentItem: alarm)
}
- .accessibilityLabel("알림 삭제")
- .accessibilityHint("이 알림을 목록에서 삭제합니다")
- }
}
if viewModel.isLoadingMore {
HStack {
Spacer()
ProgressView()
+ .accessibilityLabel("추가 알림을 불러오는 중입니다")
Spacer()
}
.listRowSeparator(.hidden)
@@ -120,27 +99,53 @@ struct NotificationListView: View {
}
}
}
+
+ @ViewBuilder
+ private func row(for alarm: AlarmItem) -> some View {
+ // 공통 카드 스타일
+ let card = AlertCardView(
+ alarm: alarm,
+ theme: appTheme.theme,
+ onDelete: { viewModel.delete(alarm: $0) } // ✅ VoiceOver 사용자 삭제 버튼 로직
+ )
+ .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 12, trailing: 20))
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
+
+ // ✅ VoiceOver가 켜져 있으면 swipeActions 제거 (불필요한 "추가 동작..." 안내 방지)
+ if UIAccessibility.isVoiceOverRunning {
+ card
+ } else {
+ card
+ .swipeActions {
+ Button(role: .destructive) {
+ viewModel.delete(alarm: alarm)
+ } label: {
+ Label("삭제", systemImage: "trash")
+ }
+ .tint(.red) // ✅ 스와이프 삭제 배경 빨간색 통일
+ .accessibilityLabel("삭제")
+ .accessibilityHint("이 알림을 목록에서 삭제합니다")
+ }
+ }
+ }
}
#if DEBUG
struct NotificationListView_Previews: PreviewProvider {
static var previews: some View {
Group {
- // 데이터 있는 상태 - 라이트 모드
- NotificationListView(viewModel: .previewData)
- .environmentObject(AppThemeManager()).previewDisplayName("알림 목록 - Light")
-
- // 데이터 있는 상태 - 다크 모드
NotificationListView(viewModel: .previewData)
- .environmentObject(AppThemeManager()).previewDisplayName("알림 목록 - Dark")
+ .environmentObject(AppThemeManager())
+ .previewDisplayName("알림 목록 - Normal")
- // 빈 상태
NotificationListView(viewModel: .previewEmpty)
- .environmentObject(AppThemeManager()).previewDisplayName("알림 없음")
+ .environmentObject(AppThemeManager())
+ .previewDisplayName("알림 없음")
- // 에러 상태
NotificationListView(viewModel: .previewError)
- .environmentObject(AppThemeManager()).previewDisplayName("에러 상태")
+ .environmentObject(AppThemeManager())
+ .previewDisplayName("에러 상태")
}
}
}
@@ -153,7 +158,7 @@ struct NotificationListView: View {
summaryId: 101,
alias: "동국대 SW 융합교육원",
summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.",
- postUrl: "exurl",
+ postUrl: "https://example.com/post/101",
timeAgo: "5분 전",
isUrgent: true
),
@@ -162,7 +167,7 @@ struct NotificationListView: View {
summaryId: 102,
alias: "오늘의 소리 팀 공지",
summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.",
- postUrl: "exurl",
+ postUrl: "https://example.com/post/102",
timeAgo: "12분 전",
isUrgent: false
),
@@ -170,8 +175,8 @@ struct NotificationListView: View {
subscriptionId: 3,
summaryId: 103,
alias: "장학 공지",
- summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신N청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.",
- postUrl: "exurl",
+ summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.",
+ postUrl: "https://example.com/post/103",
timeAgo: "30분 전",
isUrgent: true
),
@@ -180,22 +185,13 @@ struct NotificationListView: View {
summaryId: 104,
alias: "동국대 일정 안내",
summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.",
- postUrl: "url",
+ postUrl: "https://example.com/post/104",
timeAgo: "1시간 전",
isUrgent: false
)
]
}
- static var previewLoading: NotificationListViewModel {
- let vm = NotificationListViewModel(apiService: APIService())
- vm.isLoading = true
- vm.alarms = []
- vm.errorMessage = nil
- vm.disableAutoLoad = true
- return vm
- }
-
static var previewError: NotificationListViewModel {
let vm = NotificationListViewModel(apiService: APIService())
vm.errorMessage = "서버와 연결할 수 없습니다"
diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift
index ec8c91d..6e825a6 100644
--- a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift
+++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift
@@ -13,7 +13,6 @@ class NotificationListViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var isLoadingMore: Bool = false
@Published var errorMessage: String?
- @Published var readSummaryIds: Set = [] // 읽은 알림 ID (로컬 관리)
var disableAutoLoad: Bool = false
private let apiService: APIService
@@ -28,11 +27,6 @@ class NotificationListViewModel: ObservableObject {
self.apiService = apiService
}
- /// 알림이 읽음 상태인지 확인 (서버 값 + 로컬에서 추가로 읽음 표시한 값)
- func isRead(_ alarm: AlarmItem) -> Bool {
- alarm.isRead || readSummaryIds.contains(alarm.summaryId)
- }
-
/// 알림 목록 불러오기
func loadAlarms() {
guard !disableAutoLoad else { return }
@@ -63,7 +57,6 @@ class NotificationListViewModel: ObservableObject {
page: currentPage,
size: pageSize
)
- // getAlarms의 리턴 타입은 AnyPublisher<[AlarmItem], APIError> 라고 가정
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
@@ -110,42 +103,9 @@ class NotificationListViewModel: ObservableObject {
.store(in: &cancellables)
}
- /// 알림 읽음 표시 (사용자가 직접 체크)
- func markAsRead(_ alarm: AlarmItem) {
- // 이미 읽음 상태면 무시 (서버 값 또는 로컬 값)
- guard !isRead(alarm) else { return }
-
- // 로컬에서 읽음 상태 업데이트
- readSummaryIds.insert(alarm.summaryId)
-
- // 서버에 읽음 처리 요청
- guard let userId = Keychain.getString(for: KeychainKey.userId),
- let deviceSecret = Keychain.getString(for: KeychainKey.deviceSecret)
- else { return }
-
- apiService.markAlarmsAsRead(
- userId: userId,
- deviceSecret: deviceSecret,
- summaryIds: [alarm.summaryId]
- )
- .receive(on: DispatchQueue.main)
- .sink(
- receiveCompletion: { completion in
- if case let .failure(error) = completion {
- print("⚠️ 알림 읽음 처리 실패: \(error)")
- }
- },
- receiveValue: { _ in
- print("✅ 알림 읽음 처리 완료: summaryId=\(alarm.summaryId)")
- }
- )
- .store(in: &cancellables)
- }
-
/// 새로고침 (처음부터 다시 로드)
func refresh() {
alarms = []
- readSummaryIds = [] // 읽음 상태도 초기화
currentPage = 0
hasMoreData = true
errorMessage = nil
@@ -154,14 +114,13 @@ class NotificationListViewModel: ObservableObject {
/// 특정 아이템이 보일 때 호출 (무한 스크롤 트리거)
func loadMoreIfNeeded(currentItem item: AlarmItem) {
- // 마지막 아이템 근처에서만 더 불러오기
guard let last = alarms.last else { return }
if item.id == last.id {
loadAlarms()
}
}
- /// 스와이프 삭제 처리
+ /// 삭제 처리
func delete(alarm: AlarmItem) {
// 1) 로컬 리스트에서 삭제
alarms.removeAll { $0.id == alarm.id }
diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift
index 490ad3b..56c8a92 100644
--- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift
+++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift
@@ -43,25 +43,22 @@ struct OnBoardingView: View {
// 시작하기 버튼
if !isLoading {
- Button(action: {
+ MainButton(
+ title: "시작하기",
+ theme: appTheme.theme,
+ isEnabled: true
+ ) {
Task {
isLoading = true
defer { isLoading = false }
await session.registerIfNeeded()
}
- }) {
- Text("시작하기")
- .font(.KoddiExtraBold32)
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 16)
- .background(Color.primaryGreen)
- .cornerRadius(8)
}
- .padding(.horizontal, 32)
- .padding(.bottom, 40)
- .accessibilityLabel("시작하기 버튼")
- .accessibilityHint("탭하면 앱을 시작합니다")
+ .accessibilityLabel("시작하기")
+ .accessibilityHint("앱을 시작합니다")
+ .padding(.horizontal, 16)
+ .padding(.vertical, 16)
+ .padding(.bottom, 20)
}
// 에러 메시지
@@ -79,21 +76,3 @@ struct OnBoardingView: View {
.background(Color.background(appTheme.theme))
}
}
-
-// struct OnBoardingView_Previews: PreviewProvider {
-// static var previews: some View {
-// Group {
-// OnBoardingView()
-// .environmentObject(SessionStore.preview)
-// .environmentObject(AppThemeManager())
-//
-// OnBoardingView()
-// .environmentObject(SessionStore.preview)
-// .environmentObject({
-// let manager = AppThemeManager()
-// manager.theme = .highContrast
-// return manager
-// }())
-// }
-// }
-// }
diff --git a/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift b/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift
index 0fbd632..66b7b67 100644
--- a/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift
+++ b/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift
@@ -1,10 +1,13 @@
import SwiftUI
+import UIKit
struct ContactDeveloperView: View {
@EnvironmentObject var appTheme: AppThemeManager
@Environment(\.dismiss) var dismiss
- @State private var emailSubject = ""
- @State private var emailBody = ""
+
+ private let emailAddress = "todaysound.official@gmail.com"
+
+ @State private var showToast = false
var body: some View {
ZStack {
@@ -15,31 +18,28 @@ struct ContactDeveloperView: View {
Spacer()
VStack(spacing: 24) {
- // 문의 안내 텍스트
VStack(spacing: 12) {
Text("문의사항이 있으신가요?")
.font(.KoddiBold20)
.foregroundColor(Color.text(appTheme.theme))
- .accessibilityLabel("문의사항이 있으신가요?")
- Text("추가 키워드나 URL이 필요하시면\n아래 이메일로 문의해주세요.")
+ Text("웹사이트와 키워드 추가, 기타 요청사항은\n아래 이메일로 문의해주세요.")
.font(.KoddiRegular16)
.foregroundColor(Color.secondaryText(appTheme.theme))
.multilineTextAlignment(.center)
- .accessibilityLabel("추가 키워드나 URL이 필요하시면 아래 이메일로 문의해주세요.")
}
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("문의사항이 있으신가요? 웹사이트와 키워드 추가, 기타 요청사항은 아래 이메일로 문의해주세요.")
- // 이메일 주소 버튼
Button {
- if let url = URL(string: "mailto:todaysound.official@gmail.com?subject=문의사항") {
- UIApplication.shared.open(url)
- }
+ copyEmailToClipboard()
} label: {
HStack(spacing: 8) {
Image(systemName: "envelope")
.font(.KoddiBold20)
.foregroundColor(Color.primaryGreen)
- Text("todaysound.official@gmail.com")
+
+ Text(emailAddress)
.font(.KoddiBold20)
.foregroundColor(Color.primaryGreen)
}
@@ -52,37 +52,62 @@ struct ContactDeveloperView: View {
)
}
.buttonStyle(PlainButtonStyle())
- .accessibilityLabel("이메일 보내기 버튼")
- .accessibilityHint("탭하여 이메일 앱을 엽니다")
+ .accessibilityLabel(emailAddress)
+ .accessibilityHint("더블 탭하면 이메일 주소를 클립보드에 복사합니다. t,o,d,a,y,s,o,u,n,d,.,o,f,f,i,c,i,a,l,@,g,m,a,i,l.c,o,m")
}
.padding(.horizontal, 20)
Spacer()
}
- }
- .navigationBarTitleDisplayMode(.inline)
- .navigationBarBackButtonHidden(true)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button {
- dismiss()
- } label: {
- Image(systemName: "chevron.left")
- .font(.KoddiBold20)
- .foregroundColor(Color.text(appTheme.theme))
- }
- .accessibilityLabel("뒤로 가기")
- .accessibilityHint("관리 페이지로 돌아갑니다")
+
+ // 토스트(시각용). VoiceOver는 announcement로 안내하므로 중복 방지 위해 숨김
+ if showToast {
+ toastView
+ .transition(.opacity)
+ .accessibilityHidden(true)
}
}
}
-}
-struct ContactDeveloperView_Previews: PreviewProvider {
- static var previews: some View {
- NavigationView {
- ContactDeveloperView()
- .environmentObject(AppThemeManager())
+ private var toastView: some View {
+ Text("이메일 주소가 복사되었습니다")
+ .font(.KoddiRegular16)
+ .foregroundColor(Color.text(appTheme.theme))
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.secondaryBackground(appTheme.theme))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .stroke(Color.border(appTheme.theme), lineWidth: 1)
+ )
+ .padding(.horizontal, 20)
+ .padding(.bottom, 24)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ }
+
+ private func copyEmailToClipboard() {
+ // 클립보드 복사
+ UIPasteboard.general.string = emailAddress
+
+ // VoiceOver 사용자에게 즉시 피드백 (토스트와 중복 낭독 방지: 토스트는 accessibilityHidden 처리)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ UIAccessibility.post(
+ notification: .announcement,
+ argument: "이메일 주소가 클립보드에 복사되었습니다."
+ )
+ }
+ // 시각 토스트 표시
+ withAnimation(.easeInOut(duration: 0.15)) {
+ showToast = true
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ showToast = false
+ }
}
}
}
diff --git a/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift b/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift
index d2aef3b..026a418 100644
--- a/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift
+++ b/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift
@@ -18,7 +18,7 @@ struct PlaybackSettingsView: View {
.foregroundColor(Color.primaryGreen)
.padding(.horizontal, 20)
.padding(.top, 32)
- .accessibilityLabel("재생 속도 설정")
+ .accessibilityAddTraits(.isHeader)
HStack(spacing: 48) {
Button(
@@ -30,6 +30,7 @@ struct PlaybackSettingsView: View {
}
)
.accessibilityLabel("재생 속도 감소")
+ .accessibilityValue("현재 속도 \(String(format: "%.1f", viewModel.playbackRate))배속")
.accessibilityHint("탭하여 재생 속도를 느리게 합니다")
Text(String(format: "%.1f x", viewModel.playbackRate))
@@ -37,8 +38,7 @@ struct PlaybackSettingsView: View {
.foregroundColor(Color.text(appTheme.theme))
.monospacedDigit()
.frame(minWidth: 100)
- .accessibilityElement()
- .accessibilityLabel("현재 속도 \(String(format: "%.1f", viewModel.playbackRate))배속")
+ .accessibilityHidden(true)
Button(
action: { viewModel.increaseRate() },
@@ -49,6 +49,7 @@ struct PlaybackSettingsView: View {
}
)
.accessibilityLabel("재생 속도 증가")
+ .accessibilityValue("현재 속도 \(String(format: "%.1f", viewModel.playbackRate))배속")
.accessibilityHint("탭하여 재생 속도를 빠르게 합니다")
}
.frame(maxWidth: .infinity)
@@ -63,37 +64,18 @@ struct PlaybackSettingsView: View {
Spacer()
// 저장하기 버튼
- Button {
+ MainButton(
+ title: "저장하기",
+ theme: appTheme.theme,
+ isEnabled: true
+ ) {
viewModel.saveSettings()
dismiss()
- } label: {
- Text("저장하기")
- .font(.KoddiBold20)
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 16)
- .background(Color.primaryGreen)
- .cornerRadius(8)
- }
- .padding(.horizontal, 20)
- .padding(.bottom, 40)
- .accessibilityLabel("저장하기 버튼")
- .accessibilityHint("탭하여 설정을 저장합니다")
- }
- }
- .navigationBarTitleDisplayMode(.inline)
- .navigationBarBackButtonHidden(true)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button {
- dismiss()
- } label: {
- Image(systemName: "chevron.left")
- .font(.KoddiBold20)
- .foregroundColor(Color.text(appTheme.theme))
}
- .accessibilityLabel("뒤로 가기")
- .accessibilityHint("관리 페이지로 돌아갑니다")
+ .accessibilityLabel("저장하기")
+ .accessibilityHint("재생 설정을 저장하고 관리 화면으로 돌아갑니다")
+ .padding(.horizontal, 16)
+ .padding(.vertical, 16)
}
}
}
@@ -126,12 +108,3 @@ class PlaybackSettingsViewModel: ObservableObject {
NotificationCenter.default.post(name: NSNotification.Name("PlaybackRateChanged"), object: nil, userInfo: ["rate": playbackRate])
}
}
-
-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 b764d3d..ccc9d16 100644
--- a/today-s-sound/Presentation/Features/Settings/SettingsView.swift
+++ b/today-s-sound/Presentation/Features/Settings/SettingsView.swift
@@ -15,7 +15,6 @@ struct SettingsView: View {
ScreenMainTitle(text: "관리", theme: appTheme.theme)
.padding(.bottom, 16)
.accessibilityAddTraits(.isHeader)
- .accessibilityLabel("관리 화면")
VStack(spacing: 0) {
NavigationLink(
@@ -27,7 +26,7 @@ struct SettingsView: View {
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel("구독 페이지 관리 및 추가")
- .accessibilityHint("탭하면 구독 페이지 관리 및 추가 화면으로 이동합니다")
+ .accessibilityHint("구독할 웹사이트와 알림을 설정합니다")
Divider()
.background(Color.border(appTheme.theme))
@@ -43,7 +42,7 @@ struct SettingsView: View {
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel("재생 설정")
- .accessibilityHint("탭하면 재생 설정 화면으로 이동합니다")
+ .accessibilityHint("홈 화면에서 재생되는 오늘의 소리 속도를 조절합니다")
Divider()
.background(Color.border(appTheme.theme))
@@ -59,7 +58,7 @@ struct SettingsView: View {
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel("개발자에게 문의")
- .accessibilityHint("탭하면 개발자 문의 화면으로 이동합니다")
+ .accessibilityHint("등록하고 싶은 웹사이트나 기타 건의사항을 요청합니다")
Divider()
.background(Color.border(appTheme.theme))
@@ -83,7 +82,6 @@ struct SettingsView: View {
.accessibilityElement(children: .combine)
.accessibilityLabel("고대비 모드 설정")
.accessibilityValue(appTheme.isHighContrast ? "켜짐" : "꺼짐")
- .accessibilityHint("탭하여 고대비 모드 설정을 변경합니다")
}
.background(Color.background(appTheme.theme))
.cornerRadius(12)
@@ -103,10 +101,11 @@ struct SettingsView: View {
.background(Color.urgentPink)
.cornerRadius(8)
}
- .padding(.horizontal, 8)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 16)
.padding(.bottom, 40)
.accessibilityLabel("앱 초기화")
- .accessibilityHint("탭하면 앱 초기화 확인 창이 열립니다")
+ .accessibilityHint("앱의 모든 데이터를 삭제하고 앱을 종료합니다")
Spacer()
}
@@ -156,7 +155,6 @@ private struct BackHeaderContainer: View {
}
}
.navigationBarHidden(true)
- .accessibilityHint("이전 화면으로 돌아가려면 왼쪽 상단의 뒤로가기 버튼을 탭하세요. 보이스오버 사용 중에는 두 손가락으로 Z 모양으로 쓸면 뒤로 갈 수 있습니다.")
}
private var header: some View {
@@ -182,7 +180,7 @@ private struct BackHeaderContainer: View {
.frame(width: 44, height: 44)
}
.accessibilityLabel("뒤로가기")
- .accessibilityHint("탭하면 이전 화면으로 돌아갑니다")
+ .accessibilityHint("탭하면 관리 화면으로 돌아갑니다")
Spacer()
}
@@ -210,11 +208,3 @@ struct SettingsRow: View {
.contentShape(Rectangle())
}
}
-
-// 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/StatusBadge.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift
index b4f25b0..b38559e 100644
--- a/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift
+++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/StatusBadge.swift
@@ -22,16 +22,5 @@ struct StatusBadge: View {
RoundedRectangle(cornerRadius: 20)
.fill(Color.badgeGreenBackground)
)
- .accessibilityLabel("키워드: \(text)")
- }
-}
-
-struct StatusBadge_Previews: PreviewProvider {
- static var previews: some View {
- VStack(spacing: 16) {
- StatusBadge(text: "등록중", theme: .normal)
- StatusBadge(text: "일이삼사", theme: .highContrast)
- }
- .padding()
}
}
diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift
index e5a9658..aa082a9 100644
--- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift
+++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift
@@ -11,6 +11,7 @@ struct SubscriptionCardView: View {
let subscription: SubscriptionItem
let theme: AppTheme
var onToggleAlarm: ((SubscriptionItem) -> Void)?
+
private var resolvedTextColor: Color {
theme == .highContrast ? .white : Color.primaryGrey
}
@@ -23,53 +24,77 @@ struct SubscriptionCardView: View {
theme == .highContrast ? Color(white: 0.25) : Color.borderGrey
}
+ // 접근성용 문구: 구독 이름 + 키워드(최대 3개 + 나머지 개수)
+ private var keywordsA11yText: String {
+ if subscription.keywords.isEmpty { return "설정 키워드 없음" }
+
+ let firstThree = subscription.keywords.prefix(3).map(\.name)
+ var result = "설정 키워드: " + firstThree.joined(separator: ", ")
+
+ if subscription.keywords.count > 3 {
+ result += ", 그 외 \(subscription.keywords.count - 3)개"
+ }
+ return result
+ }
+
+ private var infoA11yLabel: String {
+ "\(subscription.alias). \(keywordsA11yText)"
+ }
+
var body: some View {
HStack(spacing: 12) {
+ // 구독 이름 + 키워드(한 번에 읽기)
VStack(alignment: .leading, spacing: 8) {
// 구독 이름 (alias)
Text(subscription.alias)
.font(.KoddiBold20)
.foregroundColor(resolvedTextColor)
- .accessibilityLabel("구독 페이지 이름: \(subscription.alias)")
- // URL
+ // URL (시각적으로는 유지, 접근성에서는 숨김)
Text(subscription.url)
.font(.KoddiRegular16)
.foregroundColor(resolvedTextColor)
.lineLimit(1)
- .accessibilityLabel("주소: \(subscription.url)")
+ .accessibilityHidden(true)
- // 키워드 배지들
+ // 키워드 배지들 (시각적으로는 유지, 접근성에서는 컨테이너가 대표)
if !subscription.keywords.isEmpty {
HStack(spacing: 8) {
ForEach(subscription.keywords.prefix(3)) { keyword in
StatusBadge(text: keyword.name, theme: theme)
- .accessibilityLabel("설정 키워드: \(keyword.name)")
+ .accessibilityHidden(true)
}
- // 더 많은 키워드가 있으면 "+" 표시
if subscription.keywords.count > 3 {
StatusBadge(
text: "+\(subscription.keywords.count - 3)",
theme: theme
)
- .accessibilityLabel("그외 \(subscription.keywords.count - 3)개")
+ .accessibilityHidden(true)
}
}
}
}
+ // 이 VStack 자체를 하나의 접근성 요소로 만들고(자식들은 읽지 않음)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(infoA11yLabel)
Spacer()
- // 긴급 알림 아이콘
- Button(action: {
+ // 알림 토글 버튼
+ Button {
onToggleAlarm?(subscription)
- }, label: {
+ } label: {
Image(subscription.isUrgent ? "Bell" : "Bell off")
- .frame(width: 40, height: 40)
- .accessibilityLabel(subscription.isUrgent ? "페이지 알림 설정됨" : "페이지 알림 해제됨")
- })
- .accessibilityHint("탭하여 이 페이지의 구독 알림 설정을 변경합니다")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 44, height: 44)
+ .frame(width: 44, height: 44)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("긴급 알림")
+ .accessibilityValue(subscription.isUrgent ? "켜짐" : "꺼짐")
}
.padding(16)
.background(
diff --git a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift
index 2638295..f2ada31 100644
--- a/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift
+++ b/today-s-sound/Presentation/Features/SubscriptionList/SubscriptionListView.swift
@@ -117,7 +117,7 @@ struct SubscriptionListView: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
- AddSubscriptionButton(
+ MainButton(
title: "새 웹페이지 추가",
theme: appTheme.theme,
isEnabled: true
@@ -162,21 +162,3 @@ struct SubscriptionListView: View {
}
}
}
-
-// struct SubscriptionListView_Previews: PreviewProvider {
-// static var previews: some View {
-// Group {
-// SubscriptionListView(viewModel: .previewLoading)
-// .previewDisplayName("Loading")
-//
-// SubscriptionListView(viewModel: .previewError)
-// .previewDisplayName("Error")
-//
-// SubscriptionListView(viewModel: .previewEmpty)
-// .previewDisplayName("Empty")
-//
-// SubscriptionListView(viewModel: .previewData)
-// .previewDisplayName("With Data")
-// }
-// }
-// }