Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public struct AddWidgetView: View {
VStack(spacing: 0) {
widgetPreview(for: widget)
.padding(.top, 24)
.accessibilityHidden(true)

VStack(spacing: 4) {
Text(widget.kind.title)
Expand All @@ -81,6 +82,8 @@ public struct AddWidgetView: View {
.fill(Color.cardBackground)
}
.padding(.horizontal, 16)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(widget.kind.title), \(widget.family.title)")
}
}

Expand All @@ -103,6 +106,7 @@ private struct WidgetGuideView: View {

@State private var elapsedTime: Double = 0
@State private var timerActive = true
@AccessibilityFocusState private var isGuideFocused: Bool
private let cycleTime: Double = 10.0

var currentGuideText: String {
Expand Down Expand Up @@ -139,6 +143,8 @@ private struct WidgetGuideView: View {
.frame(height: 100, alignment: .top)
.padding(.horizontal, 16)
.animation(.easeInOut, value: currentGuideText)
.accessibilityFocused($isGuideFocused)
.accessibilityLabel(currentGuideText)
.onReceive(
Timer.publish(
every: 0.5,
Expand Down Expand Up @@ -170,6 +176,9 @@ private struct WidgetGuideView: View {
.onAppear {
timerActive = true
elapsedTime = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isGuideFocused = true
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,53 @@ public struct AllergySettingView: View {
}

public var body: some View {
VStack {
ScrollView {
Spacer()
.frame(height: 20)
let scrollView = ScrollView {
Spacer()
.frame(height: 20)

LazyVGrid(columns: columns, spacing: 8) {
ForEach(AllergyType.allCases.indices, id: \.self) { index in
let allergy = AllergyType.allCases[safe: index] ?? .turbulence

allergyColumnView(index: index, allergy: allergy)
.onTapGesture {
viewStore.send(.allergyDidSelect(allergy), animation: .default)
}
}
}
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

if viewStore.allergyDidTap {
TWButton(title: "저장") {
viewStore.send(.saveButtonDidTap)
LazyVGrid(columns: columns, spacing: 8) {
ForEach(AllergyType.allCases.indices, id: \.self) { index in
let allergy = AllergyType.allCases[safe: index] ?? .turbulence

allergyColumnView(index: index, allergy: allergy)
.onTapGesture {
viewStore.send(.allergyDidSelect(allergy), animation: .default)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
.padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.backgroundSecondary.ignoresSafeArea())
.navigationTitle("알레르기")
.onAppear {
viewStore.send(.onAppear)
}
.twBackButton(dismiss: dismiss)

if #available(iOS 26.0, *) {
scrollView
.safeAreaBar(edge: .bottom) {
if viewStore.allergyDidTap {
TWButton(title: "저장") {
viewStore.send(.saveButtonDidTap)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
} else {
scrollView
.safeAreaInset(edge: .bottom) {
if viewStore.allergyDidTap {
TWButton(title: "저장") {
viewStore.send(.saveButtonDidTap)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
}
}

@ViewBuilder
Expand Down Expand Up @@ -91,5 +105,8 @@ public struct AllergySettingView: View {
.padding(.top, 8)
.padding(.trailing, 16)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(allergy.rawValue)
.accessibilityAddTraits(isAllergyContains ? [.isButton, .isSelected] : .isButton)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import SwiftUI

struct ReviewToast: View {
@Dependency(\.featureFlagClient) private var featureFlagClient
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
let onTap: () -> Void
let onDismiss: () -> Void

var body: some View {
let baseButton = Button {
Expand All @@ -14,6 +16,7 @@ struct ReviewToast: View {
HStack(spacing: 8) {
Image(systemName: "star.bubble.fill")
.foregroundColor(.yellow)
.accessibilityHidden(true)

Text(featureFlagClient.getString(.reviewText) ?? "오늘뭐임을 더 발전시킬 수 있게 리뷰 부탁드려요!")
.font(.system(size: 14, weight: .medium))
Expand All @@ -22,8 +25,11 @@ struct ReviewToast: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.accessibilityLabel("앱 리뷰 남기기")
.accessibilityHint("탭하면 앱 스토어 리뷰 페이지로 이동합니다")
.accessibilityAction(.escape) { onDismiss() }

if #available(iOS 26.0, *) {
if #available(iOS 26.0, *), !reduceTransparency {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

#available(iOS 26.0, *) 확인은 존재하지 않는 iOS 버전을 대상으로 하고 있어 혼란을 줄 수 있습니다. glassEffect는 visionOS에서 사용 가능하므로, #if os(visionOS)와 같은 컴파일러 지시문을 사용하거나, 특정 iOS 기능을 확인하는 것이라면 실제 버전을 사용해야 합니다. 예를 들어 iOS 17.0 이나 visionOS 1.0을 확인하는 것이 더 명확할 것 같습니다. 이 코드가 의도한 바가 무엇인지 확인하고 수정하는 것을 권장합니다.

baseButton
.glassEffect(.regular.interactive(), in: .capsule)
} else {
Expand Down
43 changes: 34 additions & 9 deletions Projects/Feature/MainFeature/Sources/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import NoticeFeature
import SettingsFeature
import SwiftUI
import TimeTableFeature
import UIKit
import TipKit
import TWLog

Expand All @@ -14,6 +15,7 @@ public struct MainView: View {
@ObservedObject var viewStore: ViewStoreOf<MainCore>
@Environment(\.openURL) var openURL
@Environment(\.calendar) var calendar
@Environment(\.accessibilityReduceMotion) var reduceMotion
@Dependency(\.userDefaultsClient) var userDefaultsClient

public init(store: StoreOf<MainCore>) {
Expand All @@ -28,6 +30,7 @@ public struct MainView: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isStaticText)
.accessibilityLabel({
let school: String = viewStore.school
let grade: String = viewStore.grade
Expand All @@ -47,8 +50,6 @@ public struct MainView: View {
items: ["급식", "시간표"]
)
.padding(.top, 32)
.accessibilityLabel("메뉴 탭")
.accessibilityHint("급식과 시간표 중 원하는 메뉴를 선택할 수 있습니다.")

ZStack(alignment: .bottomTrailing) {
TabView(
Expand Down Expand Up @@ -86,18 +87,42 @@ public struct MainView: View {
Color.backgroundSecondary
.ignoresSafeArea()
}
.onChange(of: viewStore.currentTab) { newTab in
let tabName = newTab == 0 ? "급식" : "시간표"
UIAccessibility.post(
notification: .announcement,
argument: "\(tabName) 탭"
)
}

if viewStore.isShowingReviewToast {
ReviewToast {
viewStore.send(.requestReview)
TWLog.event(ClickReviewEventLog())
}
ReviewToast(
onTap: {
viewStore.send(.requestReview)
TWLog.event(ClickReviewEventLog())
},
onDismiss: {
viewStore.send(.hideReviewToast, animation: .default)
}
)
Comment on lines +99 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

리뷰 토스트 dismiss 경로가 reduceMotion 설정을 완전히 따르지 않습니다.

Line 105(그리고 동일한 Line 127)에서 animation: .default를 고정 사용하면, reduceMotion 활성 시에도 dismiss 애니메이션이 발생할 수 있습니다.

🔧 제안 수정
 ReviewToast(
     onTap: {
         viewStore.send(.requestReview)
         TWLog.event(ClickReviewEventLog())
     },
     onDismiss: {
-        viewStore.send(.hideReviewToast, animation: .default)
+        viewStore.send(.hideReviewToast, animation: reduceMotion ? .none : .default)
     }
 )
 ...
 DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) {
-    viewStore.send(.hideReviewToast, animation: .default)
+    viewStore.send(.hideReviewToast, animation: reduceMotion ? .none : .default)
 }

Also applies to: 111-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/MainFeature/Sources/MainView.swift` around lines 99 - 107,
The dismiss path currently passes a fixed animation (.default) when calling
viewStore.send(.hideReviewToast, animation: .default) from the ReviewToast
usage, which ignores the user's reduce motion setting; change the view to read
the accessibilityReduceMotion environment (e.g.
`@Environment`(\.accessibilityReduceMotion) var reduceMotion) and conditionally
pass animation: reduceMotion ? nil : .default (or use withAnimation only when
reduceMotion is false) for all places where you call
viewStore.send(.hideReviewToast, animation: .default) and similar dismiss/send
calls inside ReviewToast usage so dismiss respects reduce-motion.

.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.bottom, 24)
.animation(.default, value: viewStore.isShowingReviewToast)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(
reduceMotion ? .none : .default,
value: viewStore.isShowingReviewToast
)
.transition(
reduceMotion
? .opacity
: .move(edge: .bottom).combined(with: .opacity)
)
.onAppear {
UIAccessibility.post(
notification: .announcement,
argument: "앱 리뷰 요청이 표시되었습니다"
)
guard !UIAccessibility.isVoiceOverRunning else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) {
viewStore.send(.hideReviewToast, animation: .default)
}
Expand Down Expand Up @@ -173,7 +198,7 @@ public struct MainView: View {
}
}
.accessibilityLabel("날짜 선택")
.accessibilityHint("클릭하여 날짜를 선택할 수 있습니다")
.accessibilityRemoveTraits(.isButton)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

메뉴 트리거에서 버튼 트레이트 제거는 접근성 인지성을 떨어뜨릴 수 있습니다.

Line 201의 .accessibilityRemoveTraits(.isButton)로 인해 상호작용 가능한 컨트롤임을 VoiceOver가 덜 명확히 전달할 수 있습니다. 이 modifier는 제거하는 편이 안전합니다.

🔧 제안 수정
- .accessibilityRemoveTraits(.isButton)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.accessibilityRemoveTraits(.isButton)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/MainFeature/Sources/MainView.swift` at line 201, Remove the
.accessibilityRemoveTraits(.isButton) modifier from the menu trigger in MainView
so VoiceOver can correctly announce the control as a button; locate the
occurrence inside the MainView (the menu trigger in the view body) and delete
that modifier (do not replace it with another trait removal), ensuring the
element retains its default .isButton accessibility trait.

}

ToolbarItemGroup(placement: .topBarTrailing) {
Expand Down
16 changes: 15 additions & 1 deletion Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ public struct WeeklyMealView: View {
let calText: String = String(format: "%.1f", subMeal.cal)
let calLabel: String = "\(calText) Kcal"
let titleText: String = relativeTitle(for: dayMeal.date, mealType: type)
let accessibilityText: String = "\(titleText) \(calText) 칼로리"
let mealTexts: [String] = subMeal.meals.map { mealDisplay(meal: $0) }
let accessibilityText: String = "\(titleText) \(calText) 칼로리. \(mealTexts.joined(separator: ", "))"
let dateText: String = "\(dayMeal.date.formatted(.dateTime.month().day().weekday(.wide))) \(type.display)"
let joinedMeals: String = mealTexts.joined(separator: "\n")
let shareText: String = "\(dateText)\n\(joinedMeals)"
Expand Down Expand Up @@ -213,6 +213,20 @@ public struct WeeklyMealView: View {
mealCardView
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityText)
.accessibilityAction(named: "텍스트로 복사") {
UIPasteboard.general.string = shareText
TWLog.event(ShareMealEventLog())
}
.accessibilityAction(named: "이미지로 복사") {
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: mealCardView)
renderer.scale = displayScale
if let image = renderer.uiImage {
UIPasteboard.general.image = image
TWLog.event(ShareMealImageEventLog())
}
}
}
Comment on lines +220 to +229
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 접근성 액션의 가용성 처리 위치 확인
nl -ba Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift | sed -n '214,232p'

# 기대 결과:
# - accessibilityAction(named: "이미지로 복사") modifier가 조건 없이 붙어 있음
# - `#available`(iOS 16.0, *) 체크가 액션 클로저 내부에만 존재
# 이 경우 iOS 16 미만에서 no-op 액션 노출 가능성 있음

Repository: todaywhat/TodayWhat-iOS

Length of output: 107


🏁 Script executed:

cat -n Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift | sed -n '214,232p'

Repository: todaywhat/TodayWhat-iOS

Length of output: 1037


iOS 16 미만에서 "이미지로 복사" 접근성 액션이 노출되지만 실제로 동작하지 않습니다.

Line 220의 .accessibilityAction(named: "이미지로 복사") 수정자는 조건 없이 항상 등록되고, iOS 16 가용성 체크는 액션 클로저 내부(line 221)에만 있습니다. 이로 인해 iOS 16 미만 사용자의 접근성 트리에 no-op 액션이 노출되어 혼란을 초래할 수 있습니다. 액션 등록 자체를 조건부로 분기하여 iOS 16 미만에서는 액션을 노출하지 않는 것이 안전합니다.

제안 수정안
-        mealCardView
-            .accessibilityElement(children: .combine)
-            .accessibilityLabel(accessibilityText)
-            .accessibilityAction(named: "텍스트로 복사") {
-                UIPasteboard.general.string = shareText
-                TWLog.event(ShareMealEventLog())
-            }
-            .accessibilityAction(named: "이미지로 복사") {
-                if `#available`(iOS 16.0, *) {
-                    let renderer = ImageRenderer(content: mealCardView)
-                    renderer.scale = displayScale
-                    if let image = renderer.uiImage {
-                        UIPasteboard.general.image = image
-                        TWLog.event(ShareMealImageEventLog())
-                    }
-                }
-            }
+        let baseCard = mealCardView
+            .accessibilityElement(children: .combine)
+            .accessibilityLabel(accessibilityText)
+            .accessibilityAction(named: "텍스트로 복사") {
+                UIPasteboard.general.string = shareText
+                TWLog.event(ShareMealEventLog())
+            }
+
+        Group {
+            if `#available`(iOS 16.0, *) {
+                baseCard
+                    .accessibilityAction(named: "이미지로 복사") {
+                        let renderer = ImageRenderer(content: mealCardView)
+                        renderer.scale = displayScale
+                        if let image = renderer.uiImage {
+                            UIPasteboard.general.image = image
+                            TWLog.event(ShareMealImageEventLog())
+                        }
+                    }
+            } else {
+                baseCard
+            }
+        }
             .contextMenu {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift` around
lines 220 - 229, The accessibility action ".accessibilityAction(named: \"이미지로
복사\")" is always registered but only works on iOS 16+; move the availability
check outwards so the modifier is only applied on iOS 16 and above. Concretely,
wrap the view/modifier application that adds the accessibilityAction in an if
`#available`(iOS 16.0, *) branch (or apply the modifier conditionally using a
Group) so that ImageRenderer(content: mealCardView), renderer.scale =
displayScale, UIPasteboard.general.image assignment and
TWLog.event(ShareMealImageEventLog()) are only reachable when the action is
actually registered. Ensure you reference the existing symbols
accessibilityAction, ImageRenderer, mealCardView, displayScale,
UIPasteboard.general, TWLog and ShareMealImageEventLog when updating the view
modifiers.

.contextMenu {
Button {
UIPasteboard.general.string = shareText
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public struct SchoolSettingView: View {
private let isNavigationPushed: Bool
@FocusState private var focusField: FocusField?
@Environment(\.dismiss) var dismiss
@Environment(\.accessibilityReduceMotion) private var reduceMotion

public init(
store: StoreOf<SchoolSettingCore>,
Expand Down Expand Up @@ -46,9 +47,9 @@ public struct SchoolSettingView: View {
schoolSearchResults(viewStore: viewStore)
}
}
.animation(.default, value: viewStore.grade)
.animation(.default, value: viewStore.class)
.animation(.default, value: viewStore.school)
.animation(reduceMotion ? .none : .default, value: viewStore.grade)
.animation(reduceMotion ? .none : .default, value: viewStore.class)
.animation(reduceMotion ? .none : .default, value: viewStore.school)
Comment on lines +50 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

감소된 모션 처리가 일부 경로에서만 적용됩니다.

Line 50~52는 잘 반영됐지만, Line 56/71/131/222/245의 animation: .defaultwithAnimation은 여전히 모션을 강제합니다. 접근성 설정 일관성을 위해 동일하게 reduceMotion 분기 적용이 필요합니다.

🔧 제안 수정
 .onChange(of: focusField) { newValue in
-    viewStore.send(.schoolFocusedChanged(newValue == .school), animation: .default)
+    viewStore.send(.schoolFocusedChanged(newValue == .school), animation: reduceMotion ? .none : .default)
 }
 ...
 .onAppear {
     viewStore.send(.onAppear)
-    withAnimation {
+    withAnimation(reduceMotion ? .none : .default) {
         focusField = .school
     }
 }
 ...
 .onTapGesture {
-    viewStore.send(.majorTextFieldDidTap, animation: .default)
+    viewStore.send(.majorTextFieldDidTap, animation: reduceMotion ? .none : .default)
     focusField = nil
 }
 ...
 .onTapGesture {
-    viewStore.send(.schoolRowDidSelect(school), animation: .default)
+    viewStore.send(.schoolRowDidSelect(school), animation: reduceMotion ? .none : .default)
     focusField = .grade
 }
 ...
 TWButton(title: viewStore.nextButtonTitle, style: .wide) {
-    viewStore.send(.nextButtonDidTap, animation: .default)
+    viewStore.send(.nextButtonDidTap, animation: reduceMotion ? .none : .default)
     focusField = nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift` around
lines 50 - 52, Several animation calls in SchoolSettingView still force motion;
update every hardcoded .animation(.default, ...) and every withAnimation { ... }
to respect the reduceMotion flag. Replace occurrences of .animation(.default,
value: ...) with .animation(reduceMotion ? .none : .default, value: ...) and
change withAnimation { ... } to withAnimation(reduceMotion ? nil : .default) {
... } (or withAnimation(reduceMotion ? nil : .default) around the exact closure)
for the remaining spots (the other .animation usages and all withAnimation
blocks in SchoolSettingView) so all paths consistently honor reduceMotion.

.padding(.horizontal, 16)
.padding(.top, 24)
.onChange(of: focusField) { newValue in
Expand Down Expand Up @@ -124,14 +125,16 @@ public struct SchoolSettingView: View {
)
)
.disabled(true)
.accessibilityLabel("학과 선택")
.accessibilityHint("학과를 선택하려면 두 번 탭하세요")
}
.padding(.bottom, 16)
.onTapGesture {
viewStore.send(.majorTextFieldDidTap, animation: .default)
focusField = nil
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(viewStore.major.isEmpty ? "학과 선택" : "학과: \(viewStore.major)")
.accessibilityHint("탭하면 학과 선택 시트가 열립니다")
.accessibilityAddTraits(.isButton)
}
}

Expand Down Expand Up @@ -205,21 +208,19 @@ public struct SchoolSettingView: View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(viewStore.schoolList, id: \.schoolCode) { school in
HStack {
schoolRowView(school: school)

Spacer()
}
.frame(maxWidth: .infinity)
.background {
Color.backgroundMain
}
.contentShape(.rect)
.onTapGesture {
Button {
viewStore.send(.schoolRowDidSelect(school), animation: .default)
focusField = .grade
} label: {
HStack {
schoolRowView(school: school)

Spacer()
}
.frame(maxWidth: .infinity, minHeight: 44)
.contentShape(.rect)
}
.accessibilityElement(children: .combine)
.buttonStyle(.plain)
.accessibilityLabel("\(school.name) \(school.location)")
.accessibilityHint("이 학교를 선택하려면 두 번 탭하세요")
}
Expand Down
15 changes: 15 additions & 0 deletions Projects/Feature/SettingsFeature/Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,21 @@ public struct SettingsView: View {
.labelsHidden()
.tint(.textPrimary)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
.accessibilityValue(isOn.wrappedValue ? "활성화됨" : "비활성화됨")
.accessibilityAddTraits(isToggleTraits)
.accessibilityAction {
isOn.wrappedValue.toggle()
}
}

private var isToggleTraits: AccessibilityTraits {
if #available(iOS 17.0, *) {
return .isToggle
} else {
return .isButton
}
}

@ViewBuilder
Expand Down
Loading
Loading