Refactor ZStack layouts to .background(), add Year View accessibility IDs, triage QA test plan
Replace ZStack-with-gradient patterns with idiomatic .background() modifier across onboarding, customize, and settings views. Add accessibility identifiers to Year View charts for UI test automation. Mark 67 impossible-to-automate tests RED in QA plan and scaffold initial Year View and Settings onboarding tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,17 +24,17 @@ struct CustomizeContentView: View {
|
||||
Button(action: { showThemePicker = true }) {
|
||||
HStack(spacing: 16) {
|
||||
// Emoji preview
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [.purple.opacity(0.8), .blue.opacity(0.8), .cyan.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
Text("🎨")
|
||||
.font(.title)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.purple.opacity(0.8), .blue.opacity(0.8), .cyan.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
Text("🎨")
|
||||
.font(.title)
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Browse Themes")
|
||||
|
||||
@@ -259,26 +259,25 @@ struct AppThemePreviewSheet: View {
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
ZStack {
|
||||
// Gradient background
|
||||
VStack(spacing: 16) {
|
||||
Text(theme.emoji)
|
||||
.font(.system(size: 72))
|
||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
|
||||
Text(theme.tagline)
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 200)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: theme.previewColors + [theme.previewColors[0].opacity(0.5)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Text(theme.emoji)
|
||||
.font(.system(size: 72))
|
||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
||||
|
||||
Text(theme.tagline)
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 16)
|
||||
|
||||
@@ -22,40 +22,37 @@ struct DayFilterPickerView: View {
|
||||
(Calendar.current.shortWeekdaySymbols[6], 7)]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
ForEach(weekdays.indices, id: \.self) { dayIdx in
|
||||
let day = String(weekdays[dayIdx].0)
|
||||
let value = weekdays[dayIdx].1
|
||||
let isSelected = filteredDays.currentFilters.contains(value)
|
||||
Button(action: {
|
||||
if isSelected {
|
||||
filteredDays.removeFilter(filter: value)
|
||||
} else {
|
||||
filteredDays.addFilter(newFilter: value)
|
||||
}
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
}) {
|
||||
Text(day.capitalized)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(uiColor: .tertiarySystemBackground))
|
||||
.foregroundColor(isSelected ? .green : .red)
|
||||
.cornerRadius(8)
|
||||
VStack {
|
||||
HStack {
|
||||
ForEach(weekdays.indices, id: \.self) { dayIdx in
|
||||
let day = String(weekdays[dayIdx].0)
|
||||
let value = weekdays[dayIdx].1
|
||||
let isSelected = filteredDays.currentFilters.contains(value)
|
||||
Button(action: {
|
||||
if isSelected {
|
||||
filteredDays.removeFilter(filter: value)
|
||||
} else {
|
||||
filteredDays.addFilter(newFilter: value)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
}) {
|
||||
Text(day.capitalized)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(uiColor: .tertiarySystemBackground))
|
||||
.foregroundColor(isSelected ? .green : .red)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text(String(localized: "day_picker_view_text"))
|
||||
.padding(.top)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.padding()
|
||||
Text(String(localized: "day_picker_view_text"))
|
||||
.padding(.top)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.padding()
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
@@ -50,47 +50,45 @@ struct IconPickerView: View {
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
VStack {
|
||||
ScrollView(.horizontal) {
|
||||
HStack {
|
||||
VStack {
|
||||
ScrollView(.horizontal) {
|
||||
HStack {
|
||||
Button(action: {
|
||||
UIApplication.shared.setAlternateIconName(nil)
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: "default"))
|
||||
}, label: {
|
||||
Image("AppIconImage", bundle: .main)
|
||||
.resizable()
|
||||
.frame(width: 50, height:50)
|
||||
.cornerRadius(10)
|
||||
})
|
||||
.accessibilityLabel(String(localized: "Default app icon"))
|
||||
.accessibilityHint(String(localized: "Double tap to select"))
|
||||
|
||||
|
||||
ForEach(iconSets, id: \.self.0){ iconSet in
|
||||
Button(action: {
|
||||
UIApplication.shared.setAlternateIconName(nil)
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: "default"))
|
||||
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in
|
||||
// FIXME: Handle error
|
||||
}
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
|
||||
}, label: {
|
||||
Image("AppIconImage", bundle: .main)
|
||||
Image(iconSet.0, bundle: .main)
|
||||
.resizable()
|
||||
.frame(width: 50, height:50)
|
||||
.cornerRadius(10)
|
||||
})
|
||||
.accessibilityLabel(String(localized: "Default app icon"))
|
||||
.accessibilityLabel(String(localized: "App icon style \(iconSet.1.replacingOccurrences(of: "AppIcon", with: "").replacingOccurrences(of: "Image", with: ""))"))
|
||||
.accessibilityHint(String(localized: "Double tap to select"))
|
||||
|
||||
|
||||
ForEach(iconSets, id: \.self.0){ iconSet in
|
||||
Button(action: {
|
||||
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in
|
||||
// FIXME: Handle error
|
||||
}
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
|
||||
}, label: {
|
||||
Image(iconSet.0, bundle: .main)
|
||||
.resizable()
|
||||
.frame(width: 50, height:50)
|
||||
.cornerRadius(10)
|
||||
})
|
||||
.accessibilityLabel(String(localized: "App icon style \(iconSet.1.replacingOccurrences(of: "AppIcon", with: "").replacingOccurrences(of: "Image", with: ""))"))
|
||||
.accessibilityHint(String(localized: "Double tap to select"))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill().foregroundColor(theme.currentTheme.bgColor))
|
||||
.padding()
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill().foregroundColor(theme.currentTheme.bgColor))
|
||||
.padding()
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
@@ -16,58 +16,56 @@ struct PersonalityPackPickerView: View {
|
||||
private var textColor: Color { theme.currentTheme.labelColor }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
VStack {
|
||||
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
|
||||
VStack(spacing: 10) {
|
||||
Text(String(aPack.title()))
|
||||
.font(.body)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(aPack.randomPushNotificationStrings().title)
|
||||
.font(.body)
|
||||
.foregroundColor(Color(UIColor.systemGray))
|
||||
Text(aPack.randomPushNotificationStrings().body)
|
||||
.font(.body)
|
||||
.foregroundColor(Color(UIColor.systemGray))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(personalityPack == aPack ? theme.currentTheme.bgColor : .clear)
|
||||
.padding(5)
|
||||
)
|
||||
.onTapGesture {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title()))
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
// }
|
||||
}
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||
showNSFW = true
|
||||
}
|
||||
let secondaryButton = Alert.Button.cancel(Text(String(localized: "customize_view_over18alert_no"))) {
|
||||
showNSFW = false
|
||||
}
|
||||
return Alert(title: Text(String(localized: "customize_view_over18alert_title")),
|
||||
message: Text(String(localized: "customize_view_over18alert_body")),
|
||||
primaryButton: primaryButton,
|
||||
secondaryButton: secondaryButton)
|
||||
}
|
||||
if aPack.rawValue != (PersonalityPack.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
|
||||
Divider()
|
||||
}
|
||||
VStack {
|
||||
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
|
||||
VStack(spacing: 10) {
|
||||
Text(String(aPack.title()))
|
||||
.font(.body)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(aPack.randomPushNotificationStrings().title)
|
||||
.font(.body)
|
||||
.foregroundColor(Color(UIColor.systemGray))
|
||||
Text(aPack.randomPushNotificationStrings().body)
|
||||
.font(.body)
|
||||
.foregroundColor(Color(UIColor.systemGray))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(personalityPack == aPack ? theme.currentTheme.bgColor : .clear)
|
||||
.padding(5)
|
||||
)
|
||||
.onTapGesture {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title()))
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
// }
|
||||
}
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||
showNSFW = true
|
||||
}
|
||||
let secondaryButton = Alert.Button.cancel(Text(String(localized: "customize_view_over18alert_no"))) {
|
||||
showNSFW = false
|
||||
}
|
||||
return Alert(title: Text(String(localized: "customize_view_over18alert_title")),
|
||||
message: Text(String(localized: "customize_view_over18alert_body")),
|
||||
primaryButton: primaryButton,
|
||||
secondaryButton: secondaryButton)
|
||||
}
|
||||
if aPack.rawValue != (PersonalityPack.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
|
||||
Divider()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
@@ -15,19 +15,17 @@ struct ThemePickerView: View {
|
||||
private var textColor: Color { selectedTheme.currentTheme.labelColor }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
selectedTheme.currentTheme.secondaryBGColor
|
||||
VStack {
|
||||
HStack(spacing: 0) {
|
||||
themeButton(for: .system)
|
||||
themeButton(for: .iFeel)
|
||||
themeButton(for: .dark)
|
||||
themeButton(for: .light)
|
||||
}
|
||||
.padding(.top)
|
||||
VStack {
|
||||
HStack(spacing: 0) {
|
||||
themeButton(for: .system)
|
||||
themeButton(for: .iFeel)
|
||||
themeButton(for: .dark)
|
||||
themeButton(for: .light)
|
||||
}
|
||||
.padding()
|
||||
.padding(.top)
|
||||
}
|
||||
.padding()
|
||||
.background(selectedTheme.currentTheme.secondaryBGColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.onAppear {
|
||||
|
||||
@@ -18,57 +18,54 @@ struct VotingLayoutPickerView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Voting Layout")
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Voting Layout")
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(VotingLayoutStyle.allCases, id: \.rawValue) { layout in
|
||||
Button(action: {
|
||||
if UIAccessibility.isReduceMotionEnabled {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(VotingLayoutStyle.allCases, id: \.rawValue) { layout in
|
||||
Button(action: {
|
||||
if UIAccessibility.isReduceMotionEnabled {
|
||||
votingLayoutStyle = layout.rawValue
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
votingLayoutStyle = layout.rawValue
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
votingLayoutStyle = layout.rawValue
|
||||
}
|
||||
}
|
||||
AnalyticsManager.shared.track(.votingLayoutChanged(layout: layout.displayName))
|
||||
}) {
|
||||
VStack(spacing: 6) {
|
||||
layoutIcon(for: layout)
|
||||
.frame(width: 44, height: 44)
|
||||
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.6))
|
||||
|
||||
Text(layout.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.8))
|
||||
}
|
||||
.frame(width: 70)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(currentLayout == layout ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(currentLayout == layout ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
AnalyticsManager.shared.track(.votingLayoutChanged(layout: layout.displayName))
|
||||
}) {
|
||||
VStack(spacing: 6) {
|
||||
layoutIcon(for: layout)
|
||||
.frame(width: 44, height: 44)
|
||||
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.6))
|
||||
|
||||
Text(layout.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.8))
|
||||
}
|
||||
.frame(width: 70)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(currentLayout == layout ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(currentLayout == layout ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user