From 728ff63306110d8877d53e2bc9638118a2195783 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Wed, 31 Dec 2025 16:06:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=ED=99=88=EB=B7=B0=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20-=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=AA=85=EA=B3=BC=20=EB=8B=A8=EC=9D=BC=20element=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=ED=99=94=20-=20=EC=9E=AC=EC=83=9D=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=9D=BC=EB=B2=A8=EC=97=90=EC=84=9C=20'?= =?UTF-8?q?=EB=B2=84=ED=8A=BC'=20=EB=8B=A8=EC=96=B4=EC=99=80=20hint?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Main/Home/HomeView.swift | 90 ++++++++----------- 1 file changed, 37 insertions(+), 53 deletions(-) diff --git a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift index ac38051..296e298 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -4,10 +4,19 @@ struct HomeView: View { @StateObject private var viewModel = MainViewModel() @ObservedObject private var speechService = SpeechService.shared @EnvironmentObject var appTheme: AppThemeManager - + + private var currentCategoryA11yLabel: String { + if viewModel.isLoading { + return "현재 카테고리, 새로운 글을 불러오는 중입니다" + } else if viewModel.currentCategoryName.isEmpty { + return "현재 카테고리, 등록된 페이지가 없습니다. 구독을 추가해주세요." + } else { + return "현재 카테고리, \(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,51 +50,26 @@ 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("현재 카테고리") - - 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) + VStack(spacing: 16) { + Text("현재 카테고리") + .font(.KoddiBold28) + .foregroundColor(Color.text(appTheme.theme)) + .accessibilityHidden(true) + + Group { + if viewModel.isLoading { + Text("불러오는 중...") + } else if viewModel.currentCategoryName.isEmpty { + Text("등록된 페이지 없음") + } else { + Text(viewModel.currentCategoryName) + } + } .font(.KoddiExtraBold32) .foregroundColor(.white) .padding(.horizontal, 32) @@ -93,15 +77,15 @@ struct HomeView: View { .frame(width: 360, height: 84) .background( RoundedRectangle(cornerRadius: 10) - .fill(Color.primaryGreen) + .fill(viewModel.isLoading || viewModel.currentCategoryName.isEmpty + ? Color.primaryGreen.opacity(0.6) + : Color.primaryGreen) ) - .foregroundColor(.white) - .accessibilityElement() - .accessibilityLabel(viewModel.currentCategoryName) // 👉 카테고리명만 또렷하게 - .accessibilityHint("현재 재생 중인 카테고리입니다") + .accessibilityHidden(true) } - } - .padding(.bottom, 16) + .padding(.bottom, 16) + .accessibilityElement(children: .ignore) + .accessibilityLabel(currentCategoryA11yLabel) } } .onAppear { From 04f4925df61cf6d9041b85e32a52dacae6842df4 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Fri, 2 Jan 2026 20:18:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=20=20=20=20fix:=20=EC=83=88=20=EC=9B=B9?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=20=20=20=20-=20=EC=84=B9=EC=85=98=EC=9D=98=20=EC=86=8C?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9,=20=EC=84=A4=EB=AA=85=EC=9D=84=20=ED=95=9C?= =?UTF-8?q?=20=EC=B4=88=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=20=20=20=20-=20URL=20=EC=84=A0=ED=83=9D=20=EB=B1=83=EC=A7=80?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=82=AD=EC=A0=9C=20=20=20=20=20-=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=95=84=EB=93=9C=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=20=20=20=20-=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20label=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20hint=20=EA=B0=9C=EC=84=A0=20=20=20=20?= =?UTF-8?q?=20-=20=ED=95=B8=EB=93=A4=20=EB=B0=94=20=ED=83=AD=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Component/AddSubscriptionButton.swift | 32 - .../AddSubscription/AddSubscriptionView.swift | 666 ++++-------------- .../AddSubscriptionViewModel.swift | 4 - .../Component/FlowLayout.swift | 60 ++ .../Component/FormFieldSection.swift | 116 +++ .../Component/InputFieldSection.swift | 133 ---- .../Component/KeywordBadgeWithDelete.swift | 44 ++ .../Component/KeywordCheckboxRow.swift | 24 - .../Component/KeywordSelectorSheet.swift | 119 ++++ .../Component/SheetHandler.swift | 28 +- .../Component/URLSelectorSheet.swift | 152 ++++ 11 files changed, 667 insertions(+), 711 deletions(-) create mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift create mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift delete mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/InputFieldSection.swift create mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift create mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift create mode 100644 today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift diff --git a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift index 4f115ad..a802935 100644 --- a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift +++ b/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift @@ -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..309a9c4 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,361 +90,180 @@ 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 (파일 내부용) -/// 삭제 버튼이 있는 키워드 배지 -struct KeywordBadgeWithDelete: View { - let text: String - let theme: AppTheme - let onDelete: () -> Void - // theme 파라미터는 현재 사용되지 않지만, 향후 테마 적용을 위해 유지 + // MARK: - 긴급 알림 토글 섹션 - var body: some View { - HStack(spacing: 6) { - Text(text) - .font(.KoddiBold14) - .foregroundColor(.primaryGreen) + private var urgentToggleSection: some View { + HStack(alignment: .top, spacing: 16) { + // 타이틀과 설명을 왼쪽에 배치 (보이스오버 순서: 타이틀 → 설명) + VStack(alignment: .leading, spacing: 12) { + Text("긴급 알림으로 설정") + .font(.KoddiBold20) + .foregroundColor(Color.text(appTheme.theme)) - 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)") - } -} + .accessibilityElement(children: .combine) -/// 여러 배지를 자동으로 줄바꿈해 배치해주는 레이아웃 -struct FlowLayout: Layout { - var spacing: CGFloat = 8 + Spacer() - 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 - ) + // 토글을 오른쪽에 배치 (보이스오버에서는 마지막에 읽힘) + 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 { + AddSubscriptionButton( + 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) } + .accessibilityLabel(viewModel.isLoading ? "등록 중" : "구독 목록에 추가") + .accessibilityHint( + viewModel.isLoading + ? "등록 중입니다" + : viewModel.isSubmitEnabled + ? "이 웹사이트를 구독 목록에 추가합니다" + : "웹사이트 URL을 선택해야 활성화됩니다" + ) + .padding(.horizontal, 16) + .padding(.vertical, 16) } } +// MARK: - Preview + struct AddSubscriptionView_Previews: PreviewProvider { static var previews: some View { AddSubscriptionView() diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index 06fde88..f10a15c 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -130,10 +130,6 @@ final class AddSubscriptionViewModel: ObservableObject { urlText = url.link } - func clearURL() { - selectedURL = nil - urlText = "" - } // MARK: - 키워드 선택 로직 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..426b706 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift @@ -0,0 +1,60 @@ +// +// 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..11e2bf0 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift @@ -0,0 +1,116 @@ +// +// 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 생성 + self.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..3f0a961 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift @@ -0,0 +1,44 @@ +// +// 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..444aeb8 --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift @@ -0,0 +1,119 @@ +// +// 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) + + // 하단 고정 "저장하기" 버튼 + AddSubscriptionButton( + 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..9dbb2ec --- /dev/null +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift @@ -0,0 +1,152 @@ +// +// 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을 선택합니다") + } +} + From 329405d3f984a02691825b4c04c5a0347fbbdf70 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sun, 4 Jan 2026 21:33:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=20=ED=83=AD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A8=EB=B3=B4=EB=94=A9=EB=B7=B0=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20-=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B4=88=EC=A0=90=20=EB=B3=91=ED=95=A9=20-=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20label=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20hint=20=EA=B0=9C=EC=84=A0=20-=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EA=B0=9C=EC=84=A0=20-=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EB=B7=B0=20=EB=B2=84=ED=8A=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20-=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=20=EC=84=A4=EC=A0=95=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20=EC=9B=B9=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EB=B3=84=EB=AA=85=20=EB=AF=B8=EC=9E=91=EC=84=B1=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20-=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=EC=97=90=EA=B2=8C=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B3=B5=EC=82=AC=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...scriptionButton.swift => MainButton.swift} | 2 +- .../AddSubscription/AddSubscriptionView.swift | 10 +- .../AddSubscriptionViewModel.swift | 2 +- .../Component/KeywordSelectorSheet.swift | 2 +- .../Features/OnBoarding/OnBoardingView.swift | 51 +++-------- .../Settings/ContactDeveloperView.swift | 91 ++++++++++++------- .../Settings/PlaybackSettingsView.swift | 59 ++++-------- .../Features/Settings/SettingsView.swift | 24 ++--- .../Component/StatusBadge.swift | 11 --- .../Component/SubscriptionCardView.swift | 53 ++++++++--- .../SubscriptionListView.swift | 20 +--- 11 files changed, 140 insertions(+), 185 deletions(-) rename today-s-sound/Presentation/Base/Component/{AddSubscriptionButton.swift => MainButton.swift} (96%) diff --git a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift b/today-s-sound/Presentation/Base/Component/MainButton.swift similarity index 96% rename from today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift rename to today-s-sound/Presentation/Base/Component/MainButton.swift index a802935..ce7be51 100644 --- a/today-s-sound/Presentation/Base/Component/AddSubscriptionButton.swift +++ b/today-s-sound/Presentation/Base/Component/MainButton.swift @@ -7,7 +7,7 @@ import SwiftUI -struct AddSubscriptionButton: View { +struct MainButton: View { /// 버튼에 표시할 텍스트 (예: "등록 승인 요청", "저장하기") let title: String diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index 309a9c4..fdc67be 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -238,7 +238,7 @@ struct AddSubscriptionView: View { // MARK: - 제출 버튼 섹션 private var submitButtonSection: some View { - AddSubscriptionButton( + MainButton( title: viewModel.isLoading ? "등록 중..." : "구독 목록에 추가", theme: appTheme.theme, isEnabled: viewModel.isSubmitEnabled && !viewModel.isLoading @@ -262,11 +262,3 @@ struct AddSubscriptionView: View { } } -// MARK: - Preview - -struct AddSubscriptionView_Previews: PreviewProvider { - static var previews: some View { - AddSubscriptionView() - .environmentObject(AppThemeManager()) - } -} diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index f10a15c..94f64c2 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 ) diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift index 444aeb8..00f5034 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift @@ -101,7 +101,7 @@ struct KeywordSelectorSheet: View { .frame(maxWidth: .infinity, maxHeight: .infinity) // 하단 고정 "저장하기" 버튼 - AddSubscriptionButton( + MainButton( title: "저장하기", theme: theme, isEnabled: true diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index 490ad3b..0341dec 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: { - Task { - isLoading = true - defer { isLoading = false } - await session.registerIfNeeded() + 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..4a56f46 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..d8ff49a 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 { - 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("관리 페이지로 돌아갑니다") + MainButton( + title: "저장하기", + theme: appTheme.theme, + isEnabled: true + ) { + viewModel.saveSettings() + dismiss() + } + .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..d240273 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 { $0.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") -// } -// } -// } From 6c81115914c14eb5a3ade8fc19dc7006e84667f0 Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sun, 4 Jan 2026 23:19:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?-=20=ED=83=AD=EC=9C=BC=EB=A1=9C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84=20=EC=88=A8?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20-=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EB=B0=8F=20=ED=9E=8C=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=9D=BD=EC=9D=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../NotificationList/AlertCardView.swift | 158 ++++------- .../NotificationListView.swift | 248 +++++++++--------- .../NotificationListViewModel.swift | 43 +-- 4 files changed, 177 insertions(+), 277 deletions(-) create mode 100644 today-s-sound.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings 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/Features/NotificationList/AlertCardView.swift b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift index 083ec17..67a7bd4 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)) - } - .foregroundColor(textColor) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule() - .fill(Color.white.opacity(0.2)) - ) + // ✅ 원문 보기: 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)) + ) + } + .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..d791226 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,107 +99,124 @@ struct NotificationListView: View { } } } -} -#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") - - // 빈 상태 - NotificationListView(viewModel: .previewEmpty) - .environmentObject(AppThemeManager()).previewDisplayName("알림 없음") - - // 에러 상태 - NotificationListView(viewModel: .previewError) - .environmentObject(AppThemeManager()).previewDisplayName("에러 상태") - } + @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("이 알림을 목록에서 삭제합니다") + } } } +} - extension NotificationListViewModel { - private static func sampleAlarms() -> [AlarmItem] { - [ - AlarmItem( - subscriptionId: 1, - summaryId: 101, - alias: "동국대 SW 융합교육원", - summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", - postUrl: "exurl", - timeAgo: "5분 전", - isUrgent: true - ), - AlarmItem( - subscriptionId: 2, - summaryId: 102, - alias: "오늘의 소리 팀 공지", - summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", - postUrl: "exurl", - timeAgo: "12분 전", - isUrgent: false - ), - AlarmItem( - subscriptionId: 3, - summaryId: 103, - alias: "장학 공지", - summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신N청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", - postUrl: "exurl", - timeAgo: "30분 전", - isUrgent: true - ), - AlarmItem( - subscriptionId: 4, - summaryId: 104, - alias: "동국대 일정 안내", - summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", - postUrl: "url", - timeAgo: "1시간 전", - isUrgent: false - ) - ] +#if DEBUG +struct NotificationListView_Previews: PreviewProvider { + static var previews: some View { + Group { + NotificationListView(viewModel: .previewData) + .environmentObject(AppThemeManager()) + .previewDisplayName("알림 목록 - Normal") + + NotificationListView(viewModel: .previewEmpty) + .environmentObject(AppThemeManager()) + .previewDisplayName("알림 없음") + + NotificationListView(viewModel: .previewError) + .environmentObject(AppThemeManager()) + .previewDisplayName("에러 상태") } + } +} - static var previewLoading: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.isLoading = true - vm.alarms = [] - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } +extension NotificationListViewModel { + private static func sampleAlarms() -> [AlarmItem] { + [ + AlarmItem( + subscriptionId: 1, + summaryId: 101, + alias: "동국대 SW 융합교육원", + summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", + postUrl: "https://example.com/post/101", + timeAgo: "5분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 2, + summaryId: 102, + alias: "오늘의 소리 팀 공지", + summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", + postUrl: "https://example.com/post/102", + timeAgo: "12분 전", + isUrgent: false + ), + AlarmItem( + subscriptionId: 3, + summaryId: 103, + alias: "장학 공지", + summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", + postUrl: "https://example.com/post/103", + timeAgo: "30분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 4, + summaryId: 104, + alias: "동국대 일정 안내", + summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", + postUrl: "https://example.com/post/104", + timeAgo: "1시간 전", + isUrgent: false + ) + ] + } - static var previewError: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.errorMessage = "서버와 연결할 수 없습니다" - vm.alarms = [] - vm.isLoading = false - vm.disableAutoLoad = true - return vm - } + static var previewError: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.errorMessage = "서버와 연결할 수 없습니다" + vm.alarms = [] + vm.isLoading = false + vm.disableAutoLoad = true + return vm + } - static var previewEmpty: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = [] - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewEmpty: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = [] + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } - static var previewData: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = sampleAlarms() - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewData: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = sampleAlarms() + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm } +} #endif diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListViewModel.swift index 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 } From aeef7f3494cff963f30110fd1e5b466524b2faad Mon Sep 17 00:00:00 2001 From: yeonthusiast <0727ha@naver.com> Date: Sun, 4 Jan 2026 23:31:48 +0900 Subject: [PATCH 5/5] fix: lint check --- .../Base/Component/MainButton.swift | 2 +- .../AddSubscription/AddSubscriptionView.swift | 1 - .../AddSubscriptionViewModel.swift | 1 - .../Component/FlowLayout.swift | 1 - .../Component/FormFieldSection.swift | 46 +++-- .../Component/KeywordBadgeWithDelete.swift | 1 - .../Component/KeywordSelectorSheet.swift | 1 - .../Component/URLSelectorSheet.swift | 1 - .../Features/Main/Home/HomeView.swift | 80 ++++----- .../NotificationList/AlertCardView.swift | 36 ++-- .../NotificationListView.swift | 162 +++++++++--------- .../Features/OnBoarding/OnBoardingView.swift | 30 ++-- .../Settings/ContactDeveloperView.swift | 10 +- .../Settings/PlaybackSettingsView.swift | 24 +-- .../Component/SubscriptionCardView.swift | 2 +- 15 files changed, 195 insertions(+), 203 deletions(-) diff --git a/today-s-sound/Presentation/Base/Component/MainButton.swift b/today-s-sound/Presentation/Base/Component/MainButton.swift index ce7be51..5fdd6fa 100644 --- a/today-s-sound/Presentation/Base/Component/MainButton.swift +++ b/today-s-sound/Presentation/Base/Component/MainButton.swift @@ -1,5 +1,5 @@ // -// AddSubscriptionButton.swift +// MainButton.swift // today-s-sound // // 공통 액션 버튼 컴포넌트 diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift index fdc67be..fe86c3f 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionView.swift @@ -261,4 +261,3 @@ struct AddSubscriptionView: View { .padding(.vertical, 16) } } - diff --git a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift index 94f64c2..072e3eb 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/AddSubscriptionViewModel.swift @@ -130,7 +130,6 @@ final class AddSubscriptionViewModel: ObservableObject { urlText = url.link } - // 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 index 426b706..47c8f97 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/FlowLayout.swift @@ -57,4 +57,3 @@ struct FlowLayout: Layout { } } } - diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift index 11e2bf0..bdd5318 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/FormFieldSection.swift @@ -32,41 +32,40 @@ struct FormFieldSection: View { self.additionalContent = additionalContent } - var body: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { // 소제목 + 필수(*) 표시 HStack(spacing: 4) { - Text(title) + Text(title) .font(.KoddiBold20) .foregroundColor(Color.text(theme)) - if isRequired { + if isRequired { Text("*") - .font(.KoddiBold20) - .foregroundColor(.red) - .accessibilityHidden(true) - } + .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)") + .font(.KoddiRegular16) + .foregroundColor(Color.secondaryText(theme)) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(isRequired ? "\(title), 필수 항목, \(description)" : "\(title), \(description)") - // 필드 (TextField 또는 Button) - fieldContent() + // 필드 (TextField 또는 Button) + fieldContent() - // 추가 컨텐츠 - if let additionalContent { + // 추가 컨텐츠 + if let additionalContent { additionalContent() - } - } + } } - + } } // MARK: - TextField 전용 편의 이니셜라이저 @@ -87,9 +86,9 @@ extension FormFieldSection { self.isRequired = isRequired self.theme = theme self.additionalContent = additionalContent - + // TextField 생성 - self.fieldContent = { + fieldContent = { AnyView( TextField("", text: text) .textInputAutocapitalization(.never) @@ -113,4 +112,3 @@ extension FormFieldSection { } } } - diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift index 3f0a961..c86d3c3 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordBadgeWithDelete.swift @@ -41,4 +41,3 @@ struct KeywordBadgeWithDelete: View { .accessibilityHint("탭하여 이 키워드 추가를 취소합니다") } } - diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift index 00f5034..6e4bfb4 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/KeywordSelectorSheet.swift @@ -116,4 +116,3 @@ struct KeywordSelectorSheet: View { } } } - diff --git a/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift b/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift index 9dbb2ec..a039300 100644 --- a/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift +++ b/today-s-sound/Presentation/Features/AddSubscription/Component/URLSelectorSheet.swift @@ -149,4 +149,3 @@ struct URLRow: View { .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 296e298..5576577 100644 --- a/today-s-sound/Presentation/Features/Main/Home/HomeView.swift +++ b/today-s-sound/Presentation/Features/Main/Home/HomeView.swift @@ -4,17 +4,17 @@ struct HomeView: View { @StateObject private var viewModel = MainViewModel() @ObservedObject private var speechService = SpeechService.shared @EnvironmentObject var appTheme: AppThemeManager - - private var currentCategoryA11yLabel: String { - if viewModel.isLoading { - return "현재 카테고리, 새로운 글을 불러오는 중입니다" - } else if viewModel.currentCategoryName.isEmpty { - return "현재 카테고리, 등록된 페이지가 없습니다. 구독을 추가해주세요." - } else { - return "현재 카테고리, \(viewModel.currentCategoryName)" - } + + private var currentCategoryA11yLabel: String { + if viewModel.isLoading { + "현재 카테고리, 새로운 글을 불러오는 중입니다" + } else if viewModel.currentCategoryName.isEmpty { + "현재 카테고리, 등록된 페이지가 없습니다. 구독을 추가해주세요." + } else { + "현재 카테고리, \(viewModel.currentCategoryName)" } - + } + var body: some View { ZStack { Color.background(appTheme.theme) @@ -55,37 +55,37 @@ struct HomeView: View { Spacer() - VStack(spacing: 16) { - Text("현재 카테고리") - .font(.KoddiBold28) - .foregroundColor(Color.text(appTheme.theme)) - .accessibilityHidden(true) - - 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) + VStack(spacing: 16) { + Text("현재 카테고리") + .font(.KoddiBold28) + .foregroundColor(Color.text(appTheme.theme)) + .accessibilityHidden(true) + + Group { + if viewModel.isLoading { + Text("불러오는 중...") + } else if viewModel.currentCategoryName.isEmpty { + Text("등록된 페이지 없음") + } else { + Text(viewModel.currentCategoryName) + } } - .padding(.bottom, 16) - .accessibilityElement(children: .ignore) - .accessibilityLabel(currentCategoryA11yLabel) + .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 67a7bd4..552ac79 100644 --- a/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/AlertCardView.swift @@ -76,25 +76,25 @@ struct AlertCardView: View { Spacer() // ✅ 원문 보기: 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)) - ) + 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)) } - .accessibilityLabel("원문 보기") - .accessibilityHint("탭하면 Safari에서 원문 페이지를 엽니다") - .accessibilityRemoveTraits(.isButton) - .frame(minHeight: 44) + .foregroundColor(textColor) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule().fill(Color.white.opacity(0.2)) + ) + } + .accessibilityLabel("원문 보기") + .accessibilityHint("탭하면 Safari에서 원문 페이지를 엽니다") + .accessibilityRemoveTraits(.isButton) + .frame(minHeight: 44) } } } diff --git a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift index d791226..54c8830 100644 --- a/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift +++ b/today-s-sound/Presentation/Features/NotificationList/NotificationListView.swift @@ -106,7 +106,7 @@ struct NotificationListView: View { let card = AlertCardView( alarm: alarm, theme: appTheme.theme, - onDelete: { viewModel.delete(alarm: $0) } // ✅ VoiceOver 사용자 삭제 버튼 로직 + onDelete: { viewModel.delete(alarm: $0) } // ✅ VoiceOver 사용자 삭제 버튼 로직 ) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 12, trailing: 20)) .listRowSeparator(.hidden) @@ -132,91 +132,91 @@ struct NotificationListView: View { } #if DEBUG -struct NotificationListView_Previews: PreviewProvider { - static var previews: some View { - Group { - NotificationListView(viewModel: .previewData) - .environmentObject(AppThemeManager()) - .previewDisplayName("알림 목록 - Normal") - - NotificationListView(viewModel: .previewEmpty) - .environmentObject(AppThemeManager()) - .previewDisplayName("알림 없음") - - NotificationListView(viewModel: .previewError) - .environmentObject(AppThemeManager()) - .previewDisplayName("에러 상태") + struct NotificationListView_Previews: PreviewProvider { + static var previews: some View { + Group { + NotificationListView(viewModel: .previewData) + .environmentObject(AppThemeManager()) + .previewDisplayName("알림 목록 - Normal") + + NotificationListView(viewModel: .previewEmpty) + .environmentObject(AppThemeManager()) + .previewDisplayName("알림 없음") + + NotificationListView(viewModel: .previewError) + .environmentObject(AppThemeManager()) + .previewDisplayName("에러 상태") + } } } -} -extension NotificationListViewModel { - private static func sampleAlarms() -> [AlarmItem] { - [ - AlarmItem( - subscriptionId: 1, - summaryId: 101, - alias: "동국대 SW 융합교육원", - summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", - postUrl: "https://example.com/post/101", - timeAgo: "5분 전", - isUrgent: true - ), - AlarmItem( - subscriptionId: 2, - summaryId: 102, - alias: "오늘의 소리 팀 공지", - summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", - postUrl: "https://example.com/post/102", - timeAgo: "12분 전", - isUrgent: false - ), - AlarmItem( - subscriptionId: 3, - summaryId: 103, - alias: "장학 공지", - summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", - postUrl: "https://example.com/post/103", - timeAgo: "30분 전", - isUrgent: true - ), - AlarmItem( - subscriptionId: 4, - summaryId: 104, - alias: "동국대 일정 안내", - summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", - postUrl: "https://example.com/post/104", - timeAgo: "1시간 전", - isUrgent: false - ) - ] - } + extension NotificationListViewModel { + private static func sampleAlarms() -> [AlarmItem] { + [ + AlarmItem( + subscriptionId: 1, + summaryId: 101, + alias: "동국대 SW 융합교육원", + summaryContent: "동국대학교 SW 융합교육원에서 신입생 및 재학생을 위한 SW 교육 프로그램 공지가 등록되었습니다. 신청 마감 기한을 꼭 확인해주세요.", + postUrl: "https://example.com/post/101", + timeAgo: "5분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 2, + summaryId: 102, + alias: "오늘의 소리 팀 공지", + summaryContent: "오늘의 소리 앱이 업데이트되었습니다. 보이스오버 지원이 개선되고, 일부 버그가 수정되었습니다.", + postUrl: "https://example.com/post/102", + timeAgo: "12분 전", + isUrgent: false + ), + AlarmItem( + subscriptionId: 3, + summaryId: 103, + alias: "장학 공지", + summaryContent: "2025학년도 1학기 장학금 신청 안내입니다. 신청 자격과 필요 서류를 꼭 확인한 뒤 기한 내 제출해주세요.", + postUrl: "https://example.com/post/103", + timeAgo: "30분 전", + isUrgent: true + ), + AlarmItem( + subscriptionId: 4, + summaryId: 104, + alias: "동국대 일정 안내", + summaryContent: "이번 주 캠퍼스 주요 일정과 행사를 정리하여 안내드립니다. 관심 있는 프로그램에 미리 신청해보세요.", + postUrl: "https://example.com/post/104", + timeAgo: "1시간 전", + isUrgent: false + ) + ] + } - static var previewError: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.errorMessage = "서버와 연결할 수 없습니다" - vm.alarms = [] - vm.isLoading = false - vm.disableAutoLoad = true - return vm - } + static var previewError: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.errorMessage = "서버와 연결할 수 없습니다" + vm.alarms = [] + vm.isLoading = false + vm.disableAutoLoad = true + return vm + } - static var previewEmpty: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = [] - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm - } + static var previewEmpty: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = [] + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } - static var previewData: NotificationListViewModel { - let vm = NotificationListViewModel(apiService: APIService()) - vm.alarms = sampleAlarms() - vm.isLoading = false - vm.errorMessage = nil - vm.disableAutoLoad = true - return vm + static var previewData: NotificationListViewModel { + let vm = NotificationListViewModel(apiService: APIService()) + vm.alarms = sampleAlarms() + vm.isLoading = false + vm.errorMessage = nil + vm.disableAutoLoad = true + return vm + } } -} #endif diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift index 0341dec..56c8a92 100644 --- a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -43,22 +43,22 @@ struct OnBoardingView: View { // 시작하기 버튼 if !isLoading { - MainButton( - title: "시작하기", - theme: appTheme.theme, - isEnabled: true - ) { - Task { - isLoading = true - defer { isLoading = false } - await session.registerIfNeeded() - } + MainButton( + title: "시작하기", + theme: appTheme.theme, + isEnabled: true + ) { + Task { + isLoading = true + defer { isLoading = false } + await session.registerIfNeeded() } - .accessibilityLabel("시작하기") - .accessibilityHint("앱을 시작합니다") - .padding(.horizontal, 16) - .padding(.vertical, 16) - .padding(.bottom, 20) + } + .accessibilityLabel("시작하기") + .accessibilityHint("앱을 시작합니다") + .padding(.horizontal, 16) + .padding(.vertical, 16) + .padding(.bottom, 20) } // 에러 메시지 diff --git a/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift b/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift index 4a56f46..66b7b67 100644 --- a/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift +++ b/today-s-sound/Presentation/Features/Settings/ContactDeveloperView.swift @@ -94,11 +94,11 @@ struct ContactDeveloperView: View { // VoiceOver 사용자에게 즉시 피드백 (토스트와 중복 낭독 방지: 토스트는 accessibilityHidden 처리) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - UIAccessibility.post( - notification: .announcement, - argument: "이메일 주소가 클립보드에 복사되었습니다." - ) - } + UIAccessibility.post( + notification: .announcement, + argument: "이메일 주소가 클립보드에 복사되었습니다." + ) + } // 시각 토스트 표시 withAnimation(.easeInOut(duration: 0.15)) { showToast = true diff --git a/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift b/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift index d8ff49a..026a418 100644 --- a/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift +++ b/today-s-sound/Presentation/Features/Settings/PlaybackSettingsView.swift @@ -64,18 +64,18 @@ struct PlaybackSettingsView: View { Spacer() // 저장하기 버튼 - MainButton( - title: "저장하기", - theme: appTheme.theme, - isEnabled: true - ) { - viewModel.saveSettings() - dismiss() - } - .accessibilityLabel("저장하기") - .accessibilityHint("재생 설정을 저장하고 관리 화면으로 돌아갑니다") - .padding(.horizontal, 16) - .padding(.vertical, 16) + MainButton( + title: "저장하기", + theme: appTheme.theme, + isEnabled: true + ) { + viewModel.saveSettings() + dismiss() + } + .accessibilityLabel("저장하기") + .accessibilityHint("재생 설정을 저장하고 관리 화면으로 돌아갑니다") + .padding(.horizontal, 16) + .padding(.vertical, 16) } } } diff --git a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift index d240273..aa082a9 100644 --- a/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift +++ b/today-s-sound/Presentation/Features/SubscriptionList/Component/SubscriptionCardView.swift @@ -28,7 +28,7 @@ struct SubscriptionCardView: View { private var keywordsA11yText: String { if subscription.keywords.isEmpty { return "설정 키워드 없음" } - let firstThree = subscription.keywords.prefix(3).map { $0.name } + let firstThree = subscription.keywords.prefix(3).map(\.name) var result = "설정 키워드: " + firstThree.joined(separator: ", ") if subscription.keywords.count > 3 {