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>
457 lines
14 KiB
Swift
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()
|
|
}
|
|
}
|