Files
Reflect/Shared/Views/CustomizeView/CustomizeView.swift
Trey t 440b04159e Add Apple platform features and UX improvements
- Add HealthKit State of Mind sync for mood entries
- Add Live Activity with streak display and rating time window
- Add App Shortcuts/Siri integration for voice mood logging
- Add TipKit hints for feature discovery
- Add centralized MoodLogger for consistent side effects
- Add reminder time setting in Settings with time picker
- Fix duplicate notifications when changing reminder time
- Fix Live Activity streak showing 0 when not yet rated today
- Fix slow tap response in entry detail mood selection
- Update widget timeline to refresh at rating time
- Sync widgets when reminder time changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:21:55 -06:00

1172 lines
47 KiB
Swift

//
// CustomizeView.swift
// Feels (iOS)
//
// Created by Trey Tartt on 2/19/22.
//
import SwiftUI
import StoreKit
import TipKit
// 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
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Customize tip
TipView(CustomizeLayoutTip())
.tipBackground(Color(.secondarySystemBackground))
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
// Theme
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
}
}
// MOOD STYLE
SettingsSection(title: "Mood Style") {
VStack(spacing: 16) {
// Icon Style
SettingsRow(title: "Icons") {
ImagePackPickerCompact()
}
Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
}
Divider()
// Voting Layout
SettingsRow(title: "Voting Layout") {
VotingLayoutPickerCompact()
}
}
}
// WIDGETS
SettingsSection(title: "Widgets") {
CustomWidgetSection()
}
// NOTIFICATIONS
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
// FILTERS
SettingsSection(title: "Day Filter") {
DayFilterPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
}
.onAppear(perform: {
EventLogger.log(event: "show_customize_view")
})
}
}
// 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
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
headerView
// Subscription Banner
SubscriptionBannerView(showSubscriptionStore: $showSubscriptionStore)
.environmentObject(iapManager)
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
// Theme
SettingsRow(title: "Theme") {
ThemePickerCompact()
}
Divider()
// Text Color
SettingsRow(title: "Text Color") {
TextColorPickerCompact()
}
}
}
// MOOD STYLE
SettingsSection(title: "Mood Style") {
VStack(spacing: 16) {
// Icon Style
SettingsRow(title: "Icons") {
ImagePackPickerCompact()
}
Divider()
// Mood Colors
SettingsRow(title: "Colors") {
TintPickerCompact()
}
Divider()
// Day View Style
SettingsRow(title: "Entry Style") {
DayViewStylePickerCompact()
}
Divider()
// Voting Layout
SettingsRow(title: "Voting Layout") {
VotingLayoutPickerCompact()
}
}
}
// WIDGETS
SettingsSection(title: "Widgets") {
CustomWidgetSection()
}
// NOTIFICATIONS
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
// FILTERS
SettingsSection(title: "Day Filter") {
DayFilterPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
}
.onAppear(perform: {
EventLogger.log(event: "show_customize_view")
})
.sheet(isPresented: $showSubscriptionStore) {
FeelsSubscriptionStoreView()
}
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
)
}
private var headerView: some View {
HStack {
Text("Customize")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(textColor)
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.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title.uppercased())
.font(.system(size: 13, weight: .semibold))
.foregroundColor(textColor.opacity(0.4))
.tracking(0.5)
VStack(spacing: 0) {
content
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
}
}
}
// MARK: - Settings Row
struct SettingsRow<Content: View>: View {
let title: String
@ViewBuilder let content: Content
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(textColor.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
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
HStack(spacing: 20) {
ForEach(Theme.allCases, id: \.rawValue) { aTheme in
Button(action: {
theme = aTheme
changeTextColor(forTheme: aTheme)
EventLogger.log(event: "change_theme_id", withData: ["id": 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(.system(size: 18))
.foregroundColor(.accentColor)
.background(Circle().fill(.white).padding(2))
.offset(x: 14, y: 14)
}
}
Text(aTheme.title)
.font(.system(size: 12, weight: .medium))
.foregroundColor(theme == aTheme ? .accentColor : textColor.opacity(0.6))
}
}
.buttonStyle(.plain)
}
Spacer()
}
}
private func changeTextColor(forTheme theme: Theme) {
if [Theme.iFeel, Theme.system].contains(theme) {
let currentSystemScheme = UITraitCollection.current.userInterfaceStyle
textColor = currentSystemScheme == .dark ? .white : .black
}
if theme == Theme.dark { textColor = .white }
if theme == Theme.light { textColor = .black }
}
}
// MARK: - Text Color Picker
struct TextColorPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
var body: some View {
HStack(spacing: 16) {
ColorPicker("", selection: $textColor)
.labelsHidden()
Text("Sample Text")
.font(.system(size: 16, weight: .medium))
.foregroundColor(textColor)
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
EventLogger.log(event: "change_image_pack_id", withData: ["id": 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))
}
}
Spacer()
if imagePack == images {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundColor(.accentColor)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(imagePack == images
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - Tint Picker
struct TintPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
@StateObject private var customMoodTint = UserDefaultsStore.getCustomMoodTint()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 8) {
ForEach(MoodTints.defaultOptions, id: \.rawValue) { tint in
Button(action: {
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
moodTint = tint
EventLogger.log(event: "change_mood_tint_id", withData: ["id": tint.rawValue])
}) {
HStack {
HStack(spacing: 12) {
ForEach(Mood.allValues, id: \.self) { mood in
Circle()
.fill(tint.color(forMood: mood))
.frame(width: 28, height: 28)
}
}
Spacer()
if moodTint == tint {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundColor(.accentColor)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint == tint
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
// Custom colors
Button(action: {
moodTint = .Custom
}) {
HStack {
HStack(spacing: 12) {
ForEach(0..<5, id: \.self) { index in
ColorPicker("", selection: colorBinding(for: index))
.labelsHidden()
.onChange(of: colorBinding(for: index).wrappedValue) {
saveCustomMoodTint()
}
}
}
Spacer()
if moodTint == .Custom {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundColor(.accentColor)
} else {
Text("Custom")
.font(.system(size: 13))
.foregroundColor(.secondary)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint == .Custom
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
}
private func colorBinding(for index: Int) -> Binding<Color> {
switch index {
case 0: return $customMoodTint.colorOne
case 1: return $customMoodTint.colorTwo
case 2: return $customMoodTint.colorThree
case 3: return $customMoodTint.colorFour
default: return $customMoodTint.colorFive
}
}
private func saveCustomMoodTint() {
UserDefaultsStore.saveCustomMoodTint(customTint: customMoodTint)
moodTint = .Custom
EventLogger.log(event: "change_mood_tint_id", withData: ["id": MoodTints.Custom.rawValue])
customMoodTintUpdateNumber += 1
}
}
// MARK: - Voting Layout Picker
struct VotingLayoutPickerCompact: View {
@AppStorage(UserDefaultsStore.Keys.votingLayoutStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var votingLayoutStyle: Int = 0
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@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: {
withAnimation(.easeInOut(duration: 0.2)) {
votingLayoutStyle = layout.rawValue
}
EventLogger.log(event: "change_voting_layout", withData: ["layout": layout.displayName])
}) {
VStack(spacing: 6) {
layoutIcon(for: layout)
.frame(width: 44, height: 44)
.foregroundColor(currentLayout == layout ? .accentColor : textColor.opacity(0.4))
Text(layout.displayName)
.font(.system(size: 11, weight: .medium))
.foregroundColor(currentLayout == layout ? .accentColor : textColor.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) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
}
.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 .radial:
ZStack {
ForEach(0..<5, id: \.self) { index in
Circle()
.frame(width: 7, height: 7)
.offset(radialOffset(index: index, total: 5, radius: 15))
}
}
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)
}
}
}
}
}
}
private func radialOffset(index: Int, total: Int, radius: CGFloat) -> CGSize {
let angle = Double.pi - (Double.pi * Double(index) / Double(total - 1))
return CGSize(width: radius * CGFloat(cos(angle)), height: -radius * CGFloat(sin(angle)) + 4)
}
}
// MARK: - Custom Widget Section
struct CustomWidgetSection: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@StateObject private var selectedWidget = StupidAssCustomWidgetObservableObject()
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 {
EventLogger.log(event: "show_widget")
selectedWidget.fuckingWrapped = widget.copy() as? CustomWidgetModel
selectedWidget.showFuckingSheet = true
}
}
// Add button
Button(action: {
EventLogger.log(event: "tap_create_new_widget")
selectedWidget.fuckingWrapped = CustomWidgetModel.randomWidget
selectedWidget.showFuckingSheet = true
}) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.frame(width: 60, height: 60)
Image(systemName: "plus")
.font(.system(size: 24, 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(.system(size: 14))
Text("How to add widgets")
.font(.system(size: 14))
}
.foregroundColor(.accentColor)
}
}
.sheet(isPresented: $selectedWidget.showFuckingSheet) {
if let fuckingWrapped = selectedWidget.fuckingWrapped {
CreateWidgetView(customWidget: fuckingWrapped)
}
}
}
}
// 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.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@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
// EventLogger.log(event: "show_over_18_alert")
// } else {
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
personalityPack = aPack
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
LocalNotification.rescheduleNotifiations()
// }
}) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(String(aPack.title()))
.font(.system(size: 15, weight: .semibold))
.foregroundColor(textColor)
let strings = aPack.randomPushNotificationStrings()
Text(strings.body)
.font(.system(size: 13))
.foregroundColor(textColor.opacity(0.5))
.lineLimit(2)
}
Spacer()
if personalityPack == aPack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundColor(.accentColor)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(personalityPack == aPack
? Color.accentColor.opacity(0.08)
: (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
// .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: - Day Filter Picker
struct DayFilterPickerCompact: View {
@StateObject private var filteredDays = DaysFilterClass.shared
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@Environment(\.colorScheme) private var colorScheme
let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1),
(Calendar.current.shortWeekdaySymbols[1], 2),
(Calendar.current.shortWeekdaySymbols[2], 3),
(Calendar.current.shortWeekdaySymbols[3], 4),
(Calendar.current.shortWeekdaySymbols[4], 5),
(Calendar.current.shortWeekdaySymbols[5], 6),
(Calendar.current.shortWeekdaySymbols[6], 7)]
var body: some View {
VStack(spacing: 14) {
HStack(spacing: 8) {
ForEach(weekdays.indices, id: \.self) { dayIdx in
let day = String(weekdays[dayIdx].0)
let value = weekdays[dayIdx].1
let isActive = filteredDays.currentFilters.contains(value)
Button(action: {
if isActive {
filteredDays.removeFilter(filter: value)
} else {
filteredDays.addFilter(newFilter: value)
}
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
}) {
Text(day.prefix(2).uppercased())
.font(.system(size: 13, weight: .semibold))
.foregroundColor(isActive ? .white : textColor.opacity(0.5))
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isActive ? Color.accentColor : (colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
}
Text(String(localized: "day_picker_view_text"))
.font(.system(size: 13))
.foregroundColor(textColor.opacity(0.5))
.multilineTextAlignment(.center)
}
}
}
// 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(.system(size: 28))
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Premium Active")
.font(.system(size: 16, weight: .semibold))
Text("You have full access")
.font(.system(size: 13))
.foregroundColor(.secondary)
}
Spacer()
Button("Manage") {
Task {
await openSubscriptionManagement()
}
}
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.green)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Capsule().fill(Color.green.opacity(0.15)))
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
}
private var notSubscribedView: some View {
Button(action: {
EventLogger.log(event: "customize_subscribe_tapped")
showSubscriptionStore = true
}) {
HStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.system(size: 28))
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Unlock Premium")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(colorScheme == .dark ? .white : .black)
Text("Month & Year views, Insights & more")
.font(.system(size: 13))
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, 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.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(DayViewStyle.allCases, id: \.rawValue) { style in
Button(action: {
withAnimation(.easeInOut(duration: 0.2)) {
dayViewStyle = style
}
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
EventLogger.log(event: "change_day_view_style", withData: ["style": style.displayName])
}) {
VStack(spacing: 6) {
styleIcon(for: style)
.frame(width: 44, height: 44)
.foregroundColor(dayViewStyle == style ? .accentColor : textColor.opacity(0.4))
Text(style.displayName)
.font(.system(size: 11, weight: .medium))
.foregroundColor(dayViewStyle == style ? .accentColor : textColor.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) : Color(.systemGray6)))
)
}
.buttonStyle(.plain)
}
}
.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(.system(size: 20, weight: .black, design: .rounded))
.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(.system(size: 18, weight: .regular, design: .serif))
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)
}
}
}
}
struct CustomizeView_Previews: PreviewProvider {
static var previews: some View {
Group {
CustomizeView()
.environmentObject(IAPManager())
CustomizeView()
.preferredColorScheme(.dark)
.environmentObject(IAPManager())
}
}
}