Files
Reflect/Shared/Views/CustomizeView/CustomizeView.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

1261 lines
50 KiB
Swift

//
// CustomizeView.swift
// Reflect (iOS)
//
// Created by Trey Tartt on 2/19/22.
//
import SwiftUI
import StoreKit
// MARK: - Customize Content View (for use in SettingsTabView)
struct CustomizeContentView: View {
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State private var showThemePicker = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
// QUICK THEMES
SettingsSection(title: "Quick Start") {
Button(action: { showThemePicker = true }) {
HStack(spacing: 16) {
// Emoji preview
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
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 4) {
Text("Browse Themes")
.font(.headline)
.foregroundColor(.primary)
Text("12 curated combinations of colors, icons, and layouts")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(12)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.browseThemesButton)
}
.sheet(isPresented: $showThemePicker) {
AppThemePickerView()
}
// APPEARANCE
SettingsSection(title: "Appearance") {
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
}
// MOOD STYLE
SettingsSection(title: "Mood Style") {
VStack(spacing: 16) {
// Icon Style
SettingsRow(title: "Icons") {
ImagePackPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
}
Divider()
// Voting Layout
SettingsRow(title: "Voting Layout") {
VotingLayoutPickerCompact()
}
}
}
// VOTE ANIMATION
SettingsSection(title: "Vote Animation") {
CelebrationAnimationPickerCompact()
}
// WIDGETS
// SettingsSection(title: "Widgets") {
// CustomWidgetSection()
// }
// NOTIFICATIONS
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
}
.onAppear(perform: {
AnalyticsManager.shared.trackScreen(.customize)
})
.customizeLayoutTip()
}
}
// MARK: - Legacy CustomizeView (kept for backwards compatibility)
struct CustomizeView: View {
@State private var showSubscriptionStore = false
@EnvironmentObject var iapManager: IAPManager
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
headerView
// Subscription Banner
SubscriptionBannerView(showSubscriptionStore: $showSubscriptionStore)
.environmentObject(iapManager)
// APPEARANCE
SettingsSection(title: "Appearance") {
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
}
// MOOD STYLE
SettingsSection(title: "Mood Style") {
VStack(spacing: 16) {
// Icon Style
SettingsRow(title: "Icons") {
ImagePackPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
}
Divider()
// Voting Layout
SettingsRow(title: "Voting Layout") {
VotingLayoutPickerCompact()
}
}
}
// VOTE ANIMATION
SettingsSection(title: "Vote Animation") {
CelebrationAnimationPickerCompact()
}
// WIDGETS
// SettingsSection(title: "Widgets") {
// CustomWidgetSection()
// }
// NOTIFICATIONS
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
}
.onAppear(perform: {
AnalyticsManager.shared.trackScreen(.customize)
})
.sheet(isPresented: $showSubscriptionStore) {
ReflectSubscriptionStoreView(source: "customize")
}
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
)
}
private var headerView: some View {
HStack {
Text("Customize")
.font(.title.weight(.bold))
.foregroundColor(theme.currentTheme.labelColor)
Spacer()
}
.padding(.top, 8)
}
}
// MARK: - Settings Section
struct SettingsSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
@Environment(\.colorScheme) private var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title.uppercased())
.font(.caption.weight(.semibold))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
.tracking(0.5)
VStack(spacing: 0) {
content
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemGray6))
)
}
}
}
// MARK: - Settings Row
struct SettingsRow<Content: View>: View {
let title: String
@ViewBuilder let content: Content
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline.weight(.medium))
.foregroundColor(theme.currentTheme.labelColor.opacity(0.7))
content
}
}
}
// MARK: - Theme Picker
struct ThemePickerCompact: View {
@Environment(\.colorScheme) var colorScheme
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
var body: some View {
HStack(spacing: 20) {
ForEach(Theme.allCases, id: \.rawValue) { aTheme in
Button(action: {
theme = aTheme
AnalyticsManager.shared.track(.themeChanged(themeId: aTheme.rawValue))
}) {
VStack(spacing: 8) {
ZStack {
aTheme.currentTheme.preview
.overlay(
Circle()
.stroke(theme == aTheme ? Color.accentColor : Color(.systemGray4), lineWidth: 2)
)
if theme == aTheme {
Image(systemName: "checkmark.circle.fill")
.font(.headline)
.foregroundColor(.accentColor)
.background(Circle().fill(.white).padding(2))
.offset(x: 14, y: 14)
}
}
Text(aTheme.title)
.font(.caption.weight(.medium))
.foregroundColor(theme == aTheme ? .accentColor : theme.currentTheme.labelColor.opacity(0.6))
}
}
.buttonStyle(BorderlessButtonStyle())
.accessibilityIdentifier(AccessibilityID.Customize.themeButton(aTheme.title))
}
Spacer()
}
}
}
// MARK: - Image Pack Picker
struct ImagePackPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Text(String(customMoodTintUpdateNumber)).hidden().frame(height: 0)
VStack(spacing: 8) {
ForEach(MoodImages.allCases, id: \.rawValue) { images in
Button(action: {
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
imagePack = images
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
}) {
HStack {
HStack(spacing: 16) {
ForEach(Mood.allValues, id: \.self) { mood in
images.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(moodTint.color(forMood: mood))
.accessibilityLabel(mood.strValue)
}
}
Spacer()
if imagePack == images {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.accentColor)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(imagePack == images
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : .white))
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.iconPackButton("\(images)"))
}
}
}
}
// MARK: - Voting Layout Picker
struct VotingLayoutPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
private var currentLayout: VotingLayoutStyle {
VotingLayoutStyle(rawValue: votingLayoutStyle) ?? .horizontal
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(VotingLayoutStyle.allCases, id: \.rawValue) { layout in
Button(action: {
if UIAccessibility.isReduceMotionEnabled {
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 : theme.currentTheme.labelColor.opacity(0.4))
Text(layout.displayName)
.font(.caption2.weight(.medium))
.foregroundColor(currentLayout == layout ? .accentColor : theme.currentTheme.labelColor.opacity(0.5))
}
.frame(width: 70)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(currentLayout == layout
? Color.accentColor.opacity(0.1)
: (colorScheme == .dark ? Color(.systemGray5) : .white))
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.votingLayoutButton(layout.displayName))
}
}
.padding(.horizontal, 4)
}
}
@ViewBuilder
private func layoutIcon(for layout: VotingLayoutStyle) -> some View {
switch layout {
case .horizontal:
HStack(spacing: 4) {
ForEach(0..<5, id: \.self) { _ in Circle().frame(width: 7, height: 7) }
}
case .cards:
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {
ForEach(0..<6, id: \.self) { _ in RoundedRectangle(cornerRadius: 3).frame(width: 10, height: 12) }
}
case .stacked:
VStack(spacing: 4) {
ForEach(0..<4, id: \.self) { _ in RoundedRectangle(cornerRadius: 2).frame(width: 32, height: 7) }
}
case .aura:
// Glowing orbs in 2 rows
VStack(spacing: 4) {
HStack(spacing: 6) {
ForEach(0..<3, id: \.self) { _ in
ZStack {
Circle()
.fill(RadialGradient(colors: [.green.opacity(0.5), .clear], center: .center, startRadius: 0, endRadius: 8))
.frame(width: 14, height: 14)
Circle()
.fill(.green)
.frame(width: 8, height: 8)
}
}
}
HStack(spacing: 10) {
ForEach(0..<2, id: \.self) { _ in
ZStack {
Circle()
.fill(RadialGradient(colors: [.green.opacity(0.5), .clear], center: .center, startRadius: 0, endRadius: 8))
.frame(width: 14, height: 14)
Circle()
.fill(.green)
.frame(width: 8, height: 8)
}
}
}
}
case .orbit:
// Center core with orbiting planets
ZStack {
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
.frame(width: 32, height: 32)
Circle()
.fill(Color.primary.opacity(0.8))
.frame(width: 8, height: 8)
ForEach(0..<5, id: \.self) { index in
Circle()
.fill(Color.accentColor)
.frame(width: 6, height: 6)
.offset(orbitOffset(index: index, total: 5, radius: 16))
}
}
case .neon:
// Equalizer bars
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 36, height: 36)
HStack(spacing: 2) {
ForEach(0..<5, id: \.self) { index in
let heights: [CGFloat] = [24, 18, 14, 10, 8]
RoundedRectangle(cornerRadius: 1)
.fill(
LinearGradient(
colors: [Color(red: 0, green: 1, blue: 0.82), Color(red: 1, green: 0, blue: 0.8)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 4, height: heights[index])
}
}
}
}
}
private func orbitOffset(index: Int, total: Int, radius: CGFloat) -> CGSize {
let startAngle = -Double.pi / 2
let angleStep = (2 * Double.pi) / Double(total)
let angle = startAngle + angleStep * Double(index)
return CGSize(width: radius * CGFloat(cos(angle)), height: radius * CGFloat(sin(angle)))
}
}
// MARK: - Celebration Animation Picker
struct CelebrationAnimationPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
// Preview state
@State private var previewAnimation: CelebrationAnimationType?
@State private var previewMood: Mood = .great
@State private var showPreviewCelebration = false
@State private var previewScale: CGFloat = 1.0
@State private var previewOpacity: Double = 1.0
private var currentAnimation: CelebrationAnimationType {
CelebrationAnimationType.fromIndex(celebrationAnimationIndex)
}
var body: some View {
VStack(spacing: 0) {
// Animation style picker
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(CelebrationAnimationType.allCases) { animation in
Button(action: {
selectAnimation(animation)
}) {
VStack(spacing: 6) {
Image(systemName: animation.icon)
.font(.title2)
.frame(width: 44, height: 44)
.foregroundColor(currentAnimation == animation ? animation.accentColor : theme.currentTheme.labelColor.opacity(0.4))
Text(animation.rawValue)
.font(.caption2.weight(.medium))
.foregroundColor(currentAnimation == animation ? animation.accentColor : theme.currentTheme.labelColor.opacity(0.5))
}
.frame(width: 70)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(currentAnimation == animation
? animation.accentColor.opacity(0.1)
: (colorScheme == .dark ? Color(.systemGray5) : .white))
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 4)
}
// Inline animation preview
if let animation = previewAnimation {
AnimationPreviewView(
animation: animation,
mood: previewMood,
moodTint: moodTint,
showCelebration: showPreviewCelebration,
onCelebrationComplete: {
dismissPreview()
}
)
.scaleEffect(previewScale)
.opacity(previewOpacity)
.padding(.top, 16)
.transition(.scale(scale: 0.8).combined(with: .opacity))
}
}
}
private func selectAnimation(_ animation: CelebrationAnimationType) {
// Save preference
if UIAccessibility.isReduceMotionEnabled {
celebrationAnimationIndex = animation.index
} else {
withAnimation(.easeInOut(duration: 0.2)) {
celebrationAnimationIndex = animation.index
}
}
AnalyticsManager.shared.track(.celebrationAnimationChanged(animation: animation.rawValue))
// Reset and show preview
previewScale = 1.0
previewOpacity = 1.0
showPreviewCelebration = false
previewMood = Mood.allValues.randomElement() ?? .great
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
previewAnimation = animation
}
// Auto-trigger the celebration after a brief pause
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.5))
guard previewAnimation == animation else { return }
withAnimation(.easeInOut(duration: 0.3)) {
showPreviewCelebration = true
}
}
}
private func dismissPreview() {
withAnimation(.easeIn(duration: 0.3)) {
previewScale = 0.6
previewOpacity = 0
}
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.35))
withAnimation(.easeOut(duration: 0.15)) {
previewAnimation = nil
}
}
}
}
// MARK: - Animation Preview (inline in Customize)
private struct AnimationPreviewView: View {
let animation: CelebrationAnimationType
let mood: Mood
let moodTint: MoodTints
let showCelebration: Bool
let onCelebrationComplete: () -> Void
var body: some View {
ZStack {
// Mini voting row (fades out when celebration starts)
HStack(spacing: 12) {
ForEach(Mood.allValues, id: \.self) { m in
m.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.foregroundColor(moodTint.color(forMood: m))
.scaleEffect(showCelebration && m == mood ? 1.3 : 1.0)
.opacity(showCelebration && m != mood ? 0.3 : 1.0)
}
}
.opacity(showCelebration ? 0 : 1)
// Celebration overlay
if showCelebration {
CelebrationOverlayView(
animationType: animation,
mood: mood,
onComplete: onCelebrationComplete
)
}
}
.frame(height: 160)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemGray6).opacity(0.5))
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
// MARK: - Custom Widget Section
struct CustomWidgetSection: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@StateObject private var selectedWidget = CustomWidgetStateViewModel()
var body: some View {
VStack(spacing: 12) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(UserDefaultsStore.getCustomWidgets(), id: \.uuid) { widget in
CustomWidgetView(customWidgetModel: widget)
.frame(width: 60, height: 60)
.cornerRadius(12)
.onTapGesture {
AnalyticsManager.shared.track(.widgetViewed)
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
selectedWidget.showSheet = true
}
}
// Add button
Button(action: {
AnalyticsManager.shared.track(.widgetCreateTapped)
selectedWidget.selectedItem = CustomWidgetModel.randomWidget
selectedWidget.showSheet = true
}) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.frame(width: 60, height: 60)
Image(systemName: "plus")
.font(.title2.weight(.medium))
.foregroundColor(.secondary)
}
}
}
}
Link(destination: URL(string: "https://support.apple.com/guide/iphone/add-widgets-iphb8f1bf206/ios")!) {
HStack(spacing: 6) {
Image(systemName: "questionmark.circle")
.font(.subheadline)
Text("How to add widgets")
.font(.subheadline)
}
.foregroundColor(.accentColor)
}
}
.sheet(isPresented: $selectedWidget.showSheet) {
if let selectedItem = selectedWidget.selectedItem {
CreateWidgetView(customWidget: selectedItem)
}
}
}
}
// MARK: - Personality Pack Picker
struct PersonalityPackPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.personalityPack.rawValue, store: GroupUserDefaults.groupDefaults) private var personalityPack: PersonalityPack = .Default
@AppStorage(UserDefaultsStore.Keys.showNSFW.rawValue, store: GroupUserDefaults.groupDefaults) private var showNSFW: Bool = false
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@State private var showOver18Alert = false
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 8) {
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
Button(action: {
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
// showOver18Alert = true
// } else {
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
personalityPack = aPack
AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title()))
LocalNotification.rescheduleNotifiations()
// }
}) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(String(aPack.title()))
.font(.subheadline.weight(.semibold))
.foregroundColor(theme.currentTheme.labelColor)
let strings = aPack.randomPushNotificationStrings()
Text(strings.body)
.font(.caption)
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
.lineLimit(2)
}
Spacer()
if personalityPack == aPack {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.accentColor)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(personalityPack == aPack
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : .white))
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.personalityPackButton(aPack.title()))
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
}
}
.alert(isPresented: $showOver18Alert) {
Alert(
title: Text(String(localized: "customize_view_over18alert_title")),
message: Text(String(localized: "customize_view_over18alert_body")),
primaryButton: .default(Text(String(localized: "customize_view_over18alert_ok"))) {
showNSFW = true
},
secondaryButton: .cancel(Text(String(localized: "customize_view_over18alert_no"))) {
showNSFW = false
}
)
}
}
}
// MARK: - Subscription Banner
struct SubscriptionBannerView: View {
@Binding var showSubscriptionStore: Bool
@EnvironmentObject var iapManager: IAPManager
@Environment(\.colorScheme) private var colorScheme
var body: some View {
if iapManager.isSubscribed {
subscribedView
} else {
notSubscribedView
}
}
private var subscribedView: some View {
HStack(spacing: 12) {
Image(systemName: "checkmark.seal.fill")
.font(.title)
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Premium Active")
.font(.body.weight(.semibold))
Text("You have full access")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Manage") {
Task {
await openSubscriptionManagement()
}
}
.font(.subheadline.weight(.semibold))
.foregroundColor(.green)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Capsule().fill(Color.green.opacity(0.15)))
}
.padding(16)
}
private var notSubscribedView: some View {
Button(action: {
AnalyticsManager.shared.track(.paywallSubscribeTapped(source: "customize"))
showSubscriptionStore = true
}) {
HStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.title)
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Unlock Premium")
.font(.body.weight(.semibold))
.foregroundColor(colorScheme == .dark ? .white : .black)
Text("Month & Year views, Insights & more")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
.padding(16)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 16)
.fill(
LinearGradient(
colors: [Color.orange.opacity(0.12), Color.pink.opacity(0.08)],
startPoint: .leading,
endPoint: .trailing
)
)
)
}
.buttonStyle(.plain)
}
private func openSubscriptionManagement() async {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
print("Failed to open subscription management: \(error)")
}
}
}
}
// MARK: - Day View Style Picker
struct DayViewStylePickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(DayViewStyle.availableCases, id: \.rawValue) { style in
Button(action: {
if UIAccessibility.isReduceMotionEnabled {
dayViewStyle = style
} else {
withAnimation(.easeInOut(duration: 0.2)) {
dayViewStyle = style
}
}
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
AnalyticsManager.shared.track(.dayViewStyleChanged(style: style.displayName))
}) {
VStack(spacing: 6) {
styleIcon(for: style)
.frame(width: 44, height: 44)
.foregroundColor(dayViewStyle == style ? .accentColor : theme.currentTheme.labelColor.opacity(0.4))
Text(style.displayName)
.font(.caption2.weight(.medium))
.foregroundColor(dayViewStyle == style ? .accentColor : theme.currentTheme.labelColor.opacity(0.5))
}
.frame(width: 70)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(dayViewStyle == style
? Color.accentColor.opacity(0.1)
: (colorScheme == .dark ? Color(.systemGray5) : .white))
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Customize.dayViewStyleButton(style.displayName))
}
}
.padding(.horizontal, 4)
}
}
@ViewBuilder
private func styleIcon(for style: DayViewStyle) -> some View {
switch style {
case .classic:
// Card with gradient circle and text
HStack(spacing: 6) {
Circle()
.fill(LinearGradient(colors: [.green, .green.opacity(0.5)], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 16, height: 16)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 1).frame(width: 18, height: 4)
RoundedRectangle(cornerRadius: 1).frame(width: 12, height: 3).opacity(0.5)
}
}
case .minimal:
// Simple flat card
HStack(spacing: 6) {
Circle()
.strokeBorder(lineWidth: 1.5)
.frame(width: 14, height: 14)
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 1).frame(width: 18, height: 4)
RoundedRectangle(cornerRadius: 1).frame(width: 10, height: 3).opacity(0.5)
}
}
case .compact:
// Timeline dots with bars
HStack(spacing: 4) {
VStack(spacing: 3) {
Circle().frame(width: 6, height: 6)
Circle().frame(width: 6, height: 6)
Circle().frame(width: 6, height: 6)
}
VStack(spacing: 3) {
RoundedRectangle(cornerRadius: 2).frame(width: 24, height: 8)
RoundedRectangle(cornerRadius: 2).frame(width: 24, height: 8)
RoundedRectangle(cornerRadius: 2).frame(width: 24, height: 8)
}
}
case .bubble:
// Full-width colored bars
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 4).fill(.green).frame(width: 34, height: 10)
RoundedRectangle(cornerRadius: 4).fill(.yellow).frame(width: 34, height: 10)
RoundedRectangle(cornerRadius: 4).fill(.blue).frame(width: 34, height: 10)
}
case .grid:
// 3x3 grid of circles
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {
Circle().fill(.green).frame(width: 10, height: 10)
Circle().fill(.yellow).frame(width: 10, height: 10)
Circle().fill(.blue).frame(width: 10, height: 10)
Circle().fill(.orange).frame(width: 10, height: 10)
Circle().fill(.green).frame(width: 10, height: 10)
Circle().fill(.yellow).frame(width: 10, height: 10)
}
case .aura:
// Giant number with glowing orb
HStack(spacing: 4) {
Text("17")
.font(.title3.weight(.black))
.foregroundStyle(
LinearGradient(colors: [.green, .green.opacity(0.5)], startPoint: .top, endPoint: .bottom)
)
ZStack {
Circle()
.fill(
RadialGradient(colors: [.green.opacity(0.6), .clear], center: .center, startRadius: 0, endRadius: 12)
)
.frame(width: 24, height: 24)
Circle()
.fill(.green)
.frame(width: 12, height: 12)
}
}
case .chronicle:
// Editorial magazine style
VStack(alignment: .leading, spacing: 2) {
Rectangle().frame(width: 34, height: 2)
HStack(spacing: 4) {
Text("12")
.font(.headline.weight(.regular))
Rectangle().frame(width: 1, height: 20)
VStack(alignment: .leading, spacing: 1) {
RoundedRectangle(cornerRadius: 1).frame(width: 12, height: 3)
RoundedRectangle(cornerRadius: 1).frame(width: 8, height: 2).opacity(0.5)
}
}
}
case .neon:
// Cyberpunk neon style
ZStack {
RoundedRectangle(cornerRadius: 2)
.fill(Color.black)
.frame(width: 38, height: 28)
RoundedRectangle(cornerRadius: 4)
.stroke(Color.green, lineWidth: 1)
.frame(width: 16, height: 16)
.shadow(color: .green, radius: 4, x: 0, y: 0)
RoundedRectangle(cornerRadius: 2)
.stroke(Color.green.opacity(0.5), lineWidth: 0.5)
.frame(width: 38, height: 28)
}
case .ink:
// Japanese zen style
HStack(spacing: 6) {
ZStack {
Circle()
.trim(from: 0, to: 0.85)
.stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round))
.frame(width: 18, height: 18)
.rotationEffect(.degrees(20))
}
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 1).frame(width: 14, height: 2).opacity(0.3)
RoundedRectangle(cornerRadius: 1).frame(width: 10, height: 2).opacity(0.6)
}
}
case .prism:
// Glassmorphism with rainbow edge
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(
AngularGradient(colors: [.red, .orange, .yellow, .green, .blue, .purple, .red], center: .center)
)
.frame(width: 36, height: 26)
.blur(radius: 3)
.opacity(0.6)
RoundedRectangle(cornerRadius: 5)
.fill(.ultraThinMaterial)
.frame(width: 32, height: 22)
Circle()
.fill(.green.opacity(0.5))
.frame(width: 10, height: 10)
.offset(x: -6)
}
case .tape:
// Cassette tape reels
HStack(spacing: 8) {
ZStack {
Circle().stroke(lineWidth: 2).frame(width: 14, height: 14)
Circle().frame(width: 6, height: 6)
}
VStack(spacing: 2) {
RoundedRectangle(cornerRadius: 1).frame(width: 16, height: 3)
RoundedRectangle(cornerRadius: 1).frame(width: 16, height: 2).opacity(0.5)
}
ZStack {
Circle().stroke(lineWidth: 2).frame(width: 14, height: 14)
Circle().frame(width: 6, height: 6)
}
}
case .morph:
// Organic blob shapes
ZStack {
Ellipse()
.fill(.green.opacity(0.4))
.frame(width: 28, height: 22)
.blur(radius: 4)
Ellipse()
.fill(.green.opacity(0.6))
.frame(width: 18, height: 14)
.offset(x: 4, y: 2)
.blur(radius: 2)
Circle()
.fill(.green)
.frame(width: 12, height: 12)
}
case .stack:
// Layered paper notes
ZStack {
RoundedRectangle(cornerRadius: 3)
.frame(width: 28, height: 22)
.opacity(0.3)
.offset(x: 3, y: 3)
RoundedRectangle(cornerRadius: 3)
.frame(width: 28, height: 22)
.opacity(0.5)
.offset(x: 1.5, y: 1.5)
RoundedRectangle(cornerRadius: 3)
.frame(width: 28, height: 22)
VStack(spacing: 3) {
Rectangle().frame(width: 18, height: 2)
Rectangle().frame(width: 14, height: 2).opacity(0.5)
}
}
case .wave:
// Horizontal gradient wave
VStack(spacing: 3) {
Capsule().fill(.green).frame(width: 34, height: 8)
Capsule().fill(.green.opacity(0.6)).frame(width: 34, height: 8)
Capsule().fill(.green.opacity(0.3)).frame(width: 34, height: 8)
}
case .pattern:
// Repeating pattern of icons
ZStack {
VStack(spacing: 6) {
HStack(spacing: 8) {
Circle().frame(width: 6, height: 6).opacity(0.2)
Circle().frame(width: 6, height: 6).opacity(0.2)
Circle().frame(width: 6, height: 6).opacity(0.2)
}
HStack(spacing: 8) {
Circle().frame(width: 6, height: 6).opacity(0.2)
Circle().frame(width: 6, height: 6).opacity(0.2)
Circle().frame(width: 6, height: 6).opacity(0.2)
}
}
RoundedRectangle(cornerRadius: 4)
.fill(.green.opacity(0.3))
.frame(width: 28, height: 18)
Circle()
.fill(.green)
.frame(width: 12, height: 12)
.offset(x: -6)
}
case .leather:
// Skeuomorphic leather
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color(red: 0.4, green: 0.28, blue: 0.18))
.frame(width: 36, height: 26)
RoundedRectangle(cornerRadius: 3)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [2, 2]))
.foregroundColor(Color(red: 0.6, green: 0.5, blue: 0.35))
.frame(width: 30, height: 20)
Circle()
.fill(Color(red: 0.8, green: 0.7, blue: 0.5))
.frame(width: 10, height: 10)
}
case .glass:
// Liquid glass effect
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.frame(width: 36, height: 26)
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [.white.opacity(0.5), .white.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 36, height: 26)
Circle()
.fill(.green.opacity(0.5))
.frame(width: 12, height: 12)
.offset(x: -6)
.blur(radius: 2)
}
case .motion:
// Accelerometer motion effect
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [.blue.opacity(0.3), .purple.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 36, height: 26)
Circle()
.fill(.purple.opacity(0.4))
.frame(width: 16, height: 16)
.offset(x: 8, y: -4)
.blur(radius: 3)
Circle()
.fill(.blue.opacity(0.4))
.frame(width: 12, height: 12)
.offset(x: -6, y: 4)
.blur(radius: 2)
Image(systemName: "gyroscope")
.font(.caption.weight(.medium))
.foregroundColor(.white)
}
case .micro:
// Ultra compact micro style
VStack(spacing: 2) {
HStack(spacing: 3) {
Circle()
.fill(.green)
.frame(width: 4, height: 4)
RoundedRectangle(cornerRadius: 1)
.fill(Color.gray.opacity(0.3))
.frame(width: 20, height: 4)
}
HStack(spacing: 3) {
Circle()
.fill(.orange)
.frame(width: 4, height: 4)
RoundedRectangle(cornerRadius: 1)
.fill(Color.gray.opacity(0.3))
.frame(width: 20, height: 4)
}
HStack(spacing: 3) {
Circle()
.fill(.blue)
.frame(width: 4, height: 4)
RoundedRectangle(cornerRadius: 1)
.fill(Color.gray.opacity(0.3))
.frame(width: 20, height: 4)
}
}
case .orbit:
// Celestial orbit style
ZStack {
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
.frame(width: 28, height: 28)
Circle()
.fill(Color.primary.opacity(0.8))
.frame(width: 8, height: 8)
Circle()
.fill(Color.accentColor)
.frame(width: 10, height: 10)
.offset(x: 14, y: 0)
}
}
}
}
struct CustomizeView_Previews: PreviewProvider {
static var previews: some View {
Group {
CustomizeView()
.environmentObject(IAPManager())
CustomizeView()
.preferredColorScheme(.dark)
.environmentObject(IAPManager())
}
}
}