Files
Reflect/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift
Trey t 5895b387be 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>
2026-02-20 09:17:52 -06:00

457 lines
14 KiB
Swift

//
// AppThemePickerView.swift
// Feels (iOS)
//
// Created by Claude Code on 12/26/24.
//
import SwiftUI
struct AppThemePickerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults)
private var moodTint: Int = 0
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults)
private var moodImages: Int = 0
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults)
private var dayViewStyle: Int = 0
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults)
private var votingLayoutStyle: Int = 0
@State private var selectedTheme: AppTheme?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 32) {
// Header
headerSection
// Theme Grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 16),
GridItem(.flexible(), spacing: 16)
], spacing: 20) {
ForEach(AppTheme.allCases) { theme in
AppThemeCard(
theme: theme,
isSelected: isThemeActive(theme),
onTap: { selectTheme(theme) }
)
}
}
.padding(.horizontal, 20)
// Footer note
footerNote
.padding(.bottom, 40)
}
}
.background(colorScheme == .dark ? Color.black : Color(.systemGroupedBackground))
.navigationTitle("Themes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Customize.appThemePickerDoneButton)
}
}
.sheet(item: $selectedTheme) { theme in
AppThemePreviewSheet(theme: theme) {
applyTheme(theme)
}
}
}
}
// MARK: - Subviews
private var headerSection: some View {
VStack(spacing: 12) {
Text("Choose Your Vibe")
.font(.system(.title, design: .rounded, weight: .bold))
.foregroundColor(.primary)
Text("Each theme combines colors, icons, layouts, and styles into a cohesive experience.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.top, 20)
}
private var footerNote: some View {
VStack(spacing: 8) {
Text("Themes set all four options at once")
.font(.caption)
.foregroundColor(.secondary)
Text("You can still customize individual settings after applying a theme")
.font(.caption2)
.foregroundColor(.secondary.opacity(0.7))
}
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// MARK: - Logic
private func isThemeActive(_ theme: AppTheme) -> Bool {
return moodTint == theme.colorTint.rawValue &&
moodImages == theme.iconPack.rawValue &&
dayViewStyle == theme.entryStyle.rawValue &&
votingLayoutStyle == theme.votingLayout.rawValue
}
private func selectTheme(_ theme: AppTheme) {
selectedTheme = theme
}
private func applyTheme(_ theme: AppTheme) {
withAnimation(.easeInOut(duration: 0.3)) {
theme.apply()
selectedTheme = nil
}
}
}
// MARK: - Theme Card
struct AppThemeCard: View {
let theme: AppTheme
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
VStack(spacing: 0) {
// Preview area
ZStack {
// Background gradient
LinearGradient(
colors: theme.previewColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Theme emoji
Text(theme.emoji)
.font(.system(size: 44))
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
// Selected checkmark
if isSelected {
VStack {
HStack {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.white)
.background(
Circle()
.fill(.green)
.frame(width: 28, height: 28)
)
.padding(8)
}
Spacer()
}
}
}
.frame(height: 100)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 16,
bottomLeadingRadius: 0,
bottomTrailingRadius: 0,
topTrailingRadius: 16
)
)
// Info area
VStack(alignment: .leading, spacing: 4) {
Text(theme.name)
.font(.headline)
.foregroundColor(.primary)
.lineLimit(1)
Text(theme.tagline)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 0,
bottomLeadingRadius: 16,
bottomTrailingRadius: 16,
topTrailingRadius: 0
)
)
}
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 3)
)
.shadow(
color: colorScheme == .dark ? .clear : .black.opacity(0.08),
radius: 8,
x: 0,
y: 4
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.appThemeCard(theme.name))
}
}
// MARK: - Theme Preview Sheet
struct AppThemePreviewSheet: View {
let theme: AppTheme
let onApply: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Hero
heroSection
// What's included
componentsSection
// Apply button
applyButton
.padding(.bottom, 40)
}
}
.background(colorScheme == .dark ? Color.black : Color(.systemGroupedBackground))
.navigationTitle(theme.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.Customize.appThemePreviewCancelButton)
}
}
}
.presentationDetents([.large])
}
private var heroSection: some View {
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
)
)
.clipShape(RoundedRectangle(cornerRadius: 20))
.padding(.horizontal, 20)
.padding(.top, 16)
}
private var componentsSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("This theme includes")
.font(.headline)
.padding(.horizontal, 20)
VStack(spacing: 12) {
ThemeColorRow(
icon: "paintpalette.fill",
title: "Colors",
moodTint: theme.colorTint,
color: .orange
)
ThemeComponentRow(
icon: "face.smiling.fill",
title: "Icons",
value: iconName(for: theme.iconPack),
color: .purple
)
ThemeComponentRow(
icon: "rectangle.stack.fill",
title: "Entry Style",
value: theme.entryStyle.displayName,
color: .blue
)
ThemeComponentRow(
icon: "hand.tap.fill",
title: "Voting Layout",
value: theme.votingLayout.displayName,
color: .green
)
}
.padding(.horizontal, 20)
// Description
Text(theme.description)
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.horizontal, 20)
.padding(.top, 8)
}
}
private var applyButton: some View {
Button(action: {
onApply()
dismiss()
}) {
HStack {
Image(systemName: "paintbrush.fill")
Text("Apply \(theme.name) Theme")
}
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [theme.previewColors[0], theme.previewColors[1]],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: theme.previewColors[0].opacity(0.4), radius: 8, x: 0, y: 4)
}
.padding(.horizontal, 20)
.accessibilityIdentifier(AccessibilityID.Customize.appThemePreviewApplyButton)
}
private func iconName(for pack: MoodImages) -> String {
switch pack {
case .FontAwesome: return "Classic Faces"
case .Emoji: return "Emoji"
case .HandEmjoi: return "Hand Gestures"
case .Weather: return "Weather"
case .Garden: return "Garden"
case .Hearts: return "Hearts"
case .Cosmic: return "Cosmic"
}
}
}
// MARK: - Component Row
struct ThemeComponentRow: View {
let icon: String
let title: String
let value: String
let color: Color
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(color)
.frame(width: 36, height: 36)
.background(color.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.subheadline.weight(.medium))
.foregroundColor(.primary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Color Row with Circles
struct ThemeColorRow: View {
let icon: String
let title: String
let moodTint: MoodTints
let color: Color
@Environment(\.colorScheme) private var colorScheme
private let moods: [Mood] = [.great, .good, .average, .bad, .horrible]
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(color)
.frame(width: 36, height: 36)
.background(color.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 6) {
ForEach(moods, id: \.rawValue) { mood in
Circle()
.fill(moodTint.color(forMood: mood))
.frame(width: 20, height: 20)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(colorScheme == .dark ? Color(.systemGray6) : .white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Preview
struct AppThemePickerView_Previews: PreviewProvider {
static var previews: some View {
AppThemePickerView()
}
}