- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1278 lines
51 KiB
Swift
1278 lines
51 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 {
|
|
private enum AnimationConstants {
|
|
static let previewTriggerDelay: TimeInterval = 0.5
|
|
static let dismissTransitionDelay: TimeInterval = 0.35
|
|
}
|
|
|
|
@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
|
|
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
|
@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)
|
|
.accessibilityIdentifier(AccessibilityID.Customize.celebrationAnimationButton(animation.rawValue))
|
|
}
|
|
}
|
|
.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(AnimationConstants.previewTriggerDelay))
|
|
guard previewAnimation == animation else { return }
|
|
if hapticFeedbackEnabled {
|
|
HapticFeedbackManager.shared.play(for: animation)
|
|
}
|
|
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(AnimationConstants.dismissTransitionDelay))
|
|
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)
|
|
.accessibilityIdentifier(AccessibilityID.Customize.customWidget(UserDefaultsStore.getCustomWidgets().firstIndex(where: { $0.uuid == widget.uuid }) ?? 0))
|
|
.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)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.Customize.customWidgetAdd)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.Customize.widgetHowToLink)
|
|
}
|
|
.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)))
|
|
.accessibilityIdentifier(AccessibilityID.Customize.manageSubscriptionButton)
|
|
}
|
|
.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)
|
|
.accessibilityIdentifier(AccessibilityID.Customize.unlockPremiumButton)
|
|
}
|
|
|
|
private func openSubscriptionManagement() async {
|
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
|
do {
|
|
try await AppStore.showManageSubscriptions(in: windowScene)
|
|
} catch {
|
|
#if DEBUG
|
|
print("Failed to open subscription management: \(error)")
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
}
|