Files
Reflect/Shared/Views/CustomizeView/SubViews/AppThemePickerView.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

457 lines
14 KiB
Swift

//
// AppThemePickerView.swift
// Reflect (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()
}
}