// // HomeViewTwo.swift // Reflect (iOS) // // Created by Trey Tartt on 2/18/22. // import SwiftUI struct MonthView: View { @AppStorage(UserDefaultsStore.Keys.needsOnboarding.rawValue, store: GroupUserDefaults.groupDefaults) private var needsOnboarding = true @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system private var labelColor: Color { theme.currentTheme.labelColor } @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.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle @StateObject private var shareImage = ShareImageStateViewModel() @State private var sharePickerData: SharePickerData? = nil @EnvironmentObject var iapManager: IAPManager @StateObject private var selectedDetail = DetailViewStateViewModel() @State private var showingSheet = false @StateObject private var onboardingData = OnboardingDataDataManager.shared @StateObject private var filteredDays = DaysFilterClass.shared class DetailViewStateViewModel: ObservableObject { @Published var selectedItem: MonthDetailView? = nil @Published var showSheet = false } // Heatmap-style grid with tight spacing private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] @ObservedObject var viewModel: DayViewViewModel @State private var trialWarningHidden = false @State private var showSubscriptionStore = false /// Cached sorted year/month data to avoid recalculating in ForEach @State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] @State private var cachedDemoSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] // MARK: - Demo Animation @StateObject private var demoManager = DemoAnimationManager.shared /// Generate fake demo data for the past 12 months private func computeDemoSortedData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { var result: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = [] let calendar = Calendar.current let now = Date() // Group months by year var yearDict: [Int: [(month: Int, entries: [MoodEntryModel])]] = [:] for monthOffset in 0..<12 { guard let monthDate = calendar.date(byAdding: .month, value: -monthOffset, to: now) else { continue } let year = calendar.component(.year, from: monthDate) let month = calendar.component(.month, from: monthDate) let entries = generateDemoMonthEntries(year: year, month: month) if yearDict[year] == nil { yearDict[year] = [] } yearDict[year]?.append((month: month, entries: entries)) } // Sort years descending, months descending within each year for year in yearDict.keys.sorted(by: >) { if let months = yearDict[year] { result.append((year: year, months: months.sorted { $0.month > $1.month })) } } return result } /// Generate fake entries for a demo month private func generateDemoMonthEntries(year: Int, month: Int) -> [MoodEntryModel] { let calendar = Calendar.current var entries: [MoodEntryModel] = [] // Get first day of month var components = DateComponents() components.year = year components.month = month components.day = 1 guard let firstOfMonth = calendar.date(from: components) else { return entries } // Get the weekday of first day (1 = Sunday, 7 = Saturday) let firstWeekday = calendar.component(.weekday, from: firstOfMonth) // Add placeholder entries for days before the first for i in 1.. [Int: [Int: [MoodEntryModel]]] { guard iapManager.shouldShowPaywall else { return viewModel.grouped } // Only show current month when paywall should show let currentMonth = Calendar.current.component(.month, from: Date()) let currentYear = Calendar.current.component(.year, from: Date()) var filtered: [Int: [Int: [MoodEntryModel]]] = [:] if let yearData = viewModel.grouped[currentYear], let monthData = yearData[currentMonth] { filtered[currentYear] = [currentMonth: monthData] } return filtered } /// Sorts the filtered month data - called only when source data changes private func computeSortedYearMonthData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { computeFilteredMonthData() .sorted { $0.key > $1.key } .map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) } } /// Data to display - uses demo data when in demo mode, otherwise cached real data private var displayData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] { demoManager.isDemoMode ? cachedDemoSortedData : cachedSortedData } var body: some View { ZStack { if viewModel.hasNoData && !demoManager.isDemoMode { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollViewReader { scrollProxy in ScrollView { VStack(spacing: 16) { let allMonths = displayData.flatMap { yearData in yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) } } ForEach(Array(allMonths.enumerated()), id: \.element.month) { monthIndex, monthData in MonthCard( month: monthData.month, year: monthData.year, entries: monthData.entries, moodTint: moodTint, imagePack: imagePack, theme: theme, filteredDays: filteredDays.currentFilters, monthIndex: monthIndex, onTap: { let detailView = MonthDetailView( monthInt: monthData.month, yearInt: monthData.year, entries: monthData.entries, parentViewModel: viewModel ) selectedDetail.selectedItem = detailView selectedDetail.showSheet = true }, onShare: { metrics, entries, month in sharePickerData = SharePickerData( title: Random.monthName(fromMonthInt: month), designs: [ SharingDesign( name: "Clean Calendar", shareView: AnyView(MonthTotalV1(moodMetrics: metrics, moodEntries: entries, month: month)), image: { MonthTotalV1(moodMetrics: metrics, moodEntries: entries, month: month).image } ), SharingDesign( name: "Stacked Bars", shareView: AnyView(MonthTotalV5(moodMetrics: metrics, moodEntries: entries, month: month)), image: { MonthTotalV5(moodMetrics: metrics, moodEntries: entries, month: month).image } ), ] ) } ) .id("month-\(monthIndex)") } // Scroll anchor at the very bottom Color.clear.frame(height: 1).id("scroll-end") } .padding(.horizontal) .padding(.bottom, 100) .id(moodTint) // Force complete refresh when mood tint changes .background( GeometryReader { proxy in let offset = proxy.frame(in: .named("scroll")).minY Color.clear.preference(key: ViewOffsetKey.self, value: offset) } ) } .accessibilityIdentifier(AccessibilityID.MonthView.grid) .onChange(of: demoManager.animationProgress) { _, progress in guard demoManager.isDemoMode && demoManager.animationStarted else { return } // Start slow scroll once first month is 50% done let halfwayPoint = demoManager.monthAnimationDuration * 0.5 if progress >= halfwayPoint && progress < halfwayPoint + 0.1 { // Trigger once: scroll to bottom with long duration for smooth constant speed let totalMonths = displayData.flatMap { $0.months }.count let totalDuration = Double(totalMonths) * demoManager.monthAnimationDuration withAnimation(.linear(duration: totalDuration)) { scrollProxy.scrollTo("scroll-end", anchor: .bottom) } } } } .scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode) .mask( // Fade effect when paywall should show: 100% at top, 0% halfway down (disabled in demo mode) (iapManager.shouldShowPaywall && !demoManager.isDemoMode) ? AnyView( LinearGradient( gradient: Gradient(stops: [ .init(color: .black, location: 0), .init(color: .black, location: 0.3), .init(color: .clear, location: 0.5) ]), startPoint: .top, endPoint: .bottom ) ) : AnyView(Color.black) ) } if iapManager.shouldShowPaywall && !demoManager.isDemoMode { // Premium month history prompt - bottom half (hidden in demo mode) VStack(spacing: 20) { // Icon ZStack { Circle() .fill( LinearGradient( colors: [.purple.opacity(0.2), .pink.opacity(0.2)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 80, height: 80) Image(systemName: "calendar.badge.clock") .font(.title) .foregroundStyle( LinearGradient( colors: [.purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing ) ) } // Text VStack(spacing: 10) { Text("Explore Your Mood History") .font(.title3.weight(.bold)) .foregroundColor(labelColor) .multilineTextAlignment(.center) Text("See your complete monthly journey. Track patterns and understand what shapes your days.") .font(.subheadline) .foregroundColor(labelColor.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 24) } // Subscribe button Button { showSubscriptionStore = true } label: { HStack { Image(systemName: "calendar") Text("Unlock Full History") } .font(.headline.weight(.bold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( LinearGradient( colors: [.purple, .pink], startPoint: .leading, endPoint: .trailing ) ) .clipShape(RoundedRectangle(cornerRadius: 14)) } .padding(.horizontal, 24) } .padding(.vertical, 24) .frame(maxWidth: .infinity) .background(theme.currentTheme.bg) .frame(maxHeight: .infinity, alignment: .bottom) .accessibilityIdentifier(AccessibilityID.Paywall.monthOverlay) } else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode { VStack { Spacer() if !trialWarningHidden { IAPWarningView(iapManager: iapManager) } } } } .sheet(isPresented: $showSubscriptionStore) { ReflectSubscriptionStoreView(source: "month_gate") } .onAppear(perform: { AnalyticsManager.shared.trackScreen(.month) }) .padding([.top]) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .sheet(isPresented: $selectedDetail.showSheet, onDismiss: didDismiss) { selectedDetail.selectedItem } .sheet(item: $sharePickerData) { data in SharingStylePickerView(title: data.title, designs: data.designs) } .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { trialWarningHidden = value < 0 } } .onAppear { cachedSortedData = computeSortedYearMonthData() cachedDemoSortedData = computeDemoSortedData() } .onChange(of: viewModel.numberOfItems) { _, _ in // Use numberOfItems as a lightweight proxy for data changes // instead of comparing the entire grouped dictionary cachedSortedData = computeSortedYearMonthData() } .onChange(of: iapManager.shouldShowPaywall) { _, _ in cachedSortedData = computeSortedYearMonthData() } .preferredColorScheme(theme.preferredColorScheme) #if DEBUG // Triple-tap to toggle demo mode for video recording .onTapGesture(count: 3) { if demoManager.isDemoMode { demoManager.stopDemoMode() } else { demoManager.startDemoMode() } } #endif } func didDismiss() { selectedDetail.showSheet = false selectedDetail.selectedItem = nil } } // MARK: - Month Card Component struct MonthCard: View, Equatable { let month: Int let year: Int let entries: [MoodEntryModel] let moodTint: MoodTints let imagePack: MoodImages let theme: Theme let filteredDays: [Int] let monthIndex: Int // Index for demo animation sequencing let onTap: () -> Void let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void private var labelColor: Color { theme.currentTheme.labelColor } // Demo animation support @ObservedObject private var demoManager = DemoAnimationManager.shared // Equatable conformance to prevent unnecessary re-renders static func == (lhs: MonthCard, rhs: MonthCard) -> Bool { lhs.month == rhs.month && lhs.year == rhs.year && lhs.entries.count == rhs.entries.count && lhs.moodTint == rhs.moodTint && lhs.imagePack == rhs.imagePack && lhs.filteredDays == rhs.filteredDays && lhs.theme == rhs.theme && lhs.monthIndex == rhs.monthIndex } @State private var showStats = true @State private var cachedMetrics: [MoodMetrics] = [] private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) // Animated metrics for demo mode - scales based on visible percentage private var animatedMetrics: [MoodMetrics] { guard demoManager.isDemoMode else { return cachedMetrics } let totalCells = entries.filter { $0.mood != .placeholder }.count let visiblePercentage = demoManager.visiblePercentageForMonth(totalCells: totalCells, monthIndex: monthIndex) // Scale metrics by visible percentage return cachedMetrics.map { metric in let animatedTotal = Int(Double(metric.total) * visiblePercentage) let animatedPercent = metric.percent * Float(visiblePercentage) return MoodMetrics(mood: metric.mood, total: animatedTotal, percent: animatedPercent) } } // Cached filtered/sorted metrics to avoid recalculating in ForEach private var displayMetrics: [MoodMetrics] { animatedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue } } // All 5 moods for share view (shows 0% for moods with no entries) private var allMoodMetrics: [MoodMetrics] { animatedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue } } private var topMood: Mood? { displayMetrics.max(by: { $0.total < $1.total })?.mood } private var totalTrackedDays: Int { entries.filter { ![.missing, .placeholder].contains($0.mood) }.count } private var shareableView: some View { VStack(spacing: 0) { // Header with month/year Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))") .font(.title.weight(.heavy)) .foregroundColor(labelColor) .padding(.top, 40) .padding(.bottom, 8) Text("Monthly Mood Wrap") .font(.body.weight(.medium)) .foregroundColor(labelColor.opacity(0.6)) .padding(.bottom, 30) // Top mood highlight if let topMood = topMood { VStack(spacing: 12) { Circle() .fill(moodTint.color(forMood: topMood)) .frame(width: 100, height: 100) .overlay( imagePack.icon(forMood: topMood) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(24) .accessibilityLabel(topMood.strValue) ) .shadow(color: moodTint.color(forMood: topMood).opacity(0.5), radius: 20, x: 0, y: 10) Text("Top Mood") .font(.subheadline.weight(.medium)) .foregroundColor(labelColor.opacity(0.5)) Text(topMood.strValue.uppercased()) .font(.title3.weight(.bold)) .foregroundColor(moodTint.color(forMood: topMood)) } .padding(.bottom, 30) } // Stats row HStack(spacing: 0) { VStack(spacing: 4) { Text("\(totalTrackedDays)") .font(.largeTitle.weight(.bold)) .foregroundColor(labelColor) Text("Days Tracked") .font(.caption.weight(.medium)) .foregroundColor(labelColor.opacity(0.5)) } .frame(maxWidth: .infinity) } .padding(.bottom, 30) // Mood breakdown with bars (all 5 moods) VStack(spacing: 12) { ForEach(allMoodMetrics) { metric in HStack(spacing: 12) { Circle() .fill(moodTint.color(forMood: metric.mood)) .frame(width: 32, height: 32) .overlay( imagePack.icon(forMood: metric.mood) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(7) .accessibilityLabel(metric.mood.strValue) ) GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 6) .fill(Color.gray.opacity(0.2)) if metric.percent > 0 { RoundedRectangle(cornerRadius: 6) .fill(moodTint.color(forMood: metric.mood)) .frame(width: max(8, geo.size.width * CGFloat(metric.percent / 100))) } } } .frame(height: 12) Text("\(Int(metric.percent))%") .font(.subheadline.weight(.semibold)) .foregroundColor(labelColor) .frame(width: 40, alignment: .trailing) } } } .padding(.horizontal, 32) .padding(.bottom, 40) // App branding Text("ifeel") .font(.subheadline.weight(.medium)) .foregroundColor(labelColor.opacity(0.3)) .padding(.bottom, 20) } .frame(width: 400) .background(theme.currentTheme.secondaryBGColor) } var body: some View { VStack(spacing: 0) { // Month Header HStack { Button(action: { if UIAccessibility.isReduceMotionEnabled { showStats.toggle() } else { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } } }) { HStack { Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") .font(.title3.bold()) .foregroundColor(labelColor) Image(systemName: showStats ? "chevron.up" : "chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(labelColor.opacity(0.5)) } } .buttonStyle(.plain) .accessibilityLabel("\(Random.monthName(fromMonthInt: month)) \(String(year)), \(showStats ? "expanded" : "collapsed")") .accessibilityHint("Double tap to toggle statistics") Spacer() Button(action: { onShare(cachedMetrics, entries, month) }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium)) .foregroundColor(labelColor.opacity(0.6)) } .buttonStyle(.plain) .accessibilityLabel("Share \(Random.monthName(fromMonthInt: month)) \(String(year)) mood data") .accessibilityIdentifier(AccessibilityID.MonthView.shareButton) } .padding(.horizontal, 16) .padding(.vertical, 12) // Weekday Labels HStack(spacing: 2) { ForEach(weekdayLabels.indices, id: \.self) { index in Text(weekdayLabels[index]) .font(.caption2.weight(.medium)) .foregroundColor(labelColor.opacity(0.5)) .frame(maxWidth: .infinity) } } .padding(.horizontal, 16) .padding(.bottom, 6) // Heatmap Grid LazyVGrid(columns: heatmapColumns, spacing: 2) { ForEach(Array(entries.enumerated()), id: \.element) { index, entry in let row = index / 7 let column = index % 7 let totalRows = (entries.count + 6) / 7 DemoHeatmapCell( entry: entry, moodTint: moodTint, isFiltered: filteredDays.contains(Int(entry.weekDay)), row: row, column: column, totalRows: totalRows, totalColumns: 7, monthIndex: monthIndex, demoManager: demoManager ) } } .padding(.horizontal, 16) .padding(.bottom, 12) // Bar Chart Stats (collapsible) if showStats { Divider() .padding(.horizontal, 16) MoodBarChart(metrics: animatedMetrics, moodTint: moodTint, imagePack: imagePack) .padding(.horizontal, 16) .padding(.vertical, 12) .transition(.opacity.combined(with: .move(edge: .top))) } } .background( RoundedRectangle(cornerRadius: 16) .fill(theme.currentTheme.secondaryBGColor) ) .contentShape(Rectangle()) .onTapGesture { onTap() } .onAppear { // Cache metrics calculation on first appearance if cachedMetrics.isEmpty { cachedMetrics = Random.createTotalPerc(fromEntries: entries) } } } } // MARK: - Heatmap Cell struct HeatmapCell: View { let entry: MoodEntryModel let moodTint: MoodTints let isFiltered: Bool var body: some View { RoundedRectangle(cornerRadius: 4) .fill(cellColor) .aspectRatio(1, contentMode: .fit) .accessibilityLabel(accessibilityDescription) .accessibilityHint(entry.mood != .placeholder && entry.mood != .missing ? "Double tap to edit" : "") } private var accessibilityDescription: String { if entry.mood == .placeholder { return "Empty day" } else if entry.mood == .missing { return "No mood logged for \(formattedDate)" } else if !isFiltered { return "\(formattedDate): \(entry.mood.strValue) (filtered out)" } else { return "\(formattedDate): \(entry.mood.strValue)" } } private var formattedDate: String { DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium) } private var cellColor: Color { if entry.mood == .placeholder { return Color.gray.opacity(0.1) } else if entry.mood == .missing { return Color.gray.opacity(0.25) } else if !isFiltered { return Color.gray.opacity(0.1) } else { return moodTint.color(forMood: entry.mood) } } } // MARK: - Demo Heatmap Cell (with animation support) struct DemoHeatmapCell: View { let entry: MoodEntryModel let moodTint: MoodTints let isFiltered: Bool let row: Int let column: Int let totalRows: Int let totalColumns: Int let monthIndex: Int // Which month this is (0 = first/most recent) @ObservedObject var demoManager: DemoAnimationManager /// Random mood for this cell (computed once and cached) @State private var randomMood: Mood = .great var body: some View { ZStack { // Background: Gray grid cell always visible at 1x scale RoundedRectangle(cornerRadius: 4) .fill(Color.gray.opacity(0.3)) .aspectRatio(1, contentMode: .fit) // Foreground: Animated mood color that scales 2x -> 1x if demoManager.isDemoMode { // Skip placeholder cells (first row offset cells) if entry.mood != .placeholder { RoundedRectangle(cornerRadius: 4) .fill(moodTint.color(forMood: randomMood)) .aspectRatio(1, contentMode: .fit) .scaleEffect(isAnimated ? 1.0 : 2.0) .opacity(isAnimated ? 1.0 : 0.0) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isAnimated) } } else { // Normal mode - just show the actual color RoundedRectangle(cornerRadius: 4) .fill(normalCellColor) .aspectRatio(1, contentMode: .fit) } } .onAppear { // Generate random mood once when cell appears randomMood = DemoAnimationManager.randomPositiveMood() } .accessibilityLabel(accessibilityDescription) } /// Whether this cell has been animated (filled with color) private var isAnimated: Bool { if !demoManager.isDemoMode { return true // Normal mode - always show } if !demoManager.animationStarted { return false // Demo mode but animation hasn't started } return demoManager.isCellVisible( row: row, column: column, totalRows: totalRows, totalColumns: totalColumns, monthIndex: monthIndex ) } /// Color for normal (non-demo) mode private var normalCellColor: Color { if entry.mood == .placeholder { return Color.gray.opacity(0.1) } else if entry.mood == .missing { return Color.gray.opacity(0.25) } else if !isFiltered { return Color.gray.opacity(0.1) } else { return moodTint.color(forMood: entry.mood) } } private var accessibilityDescription: String { if entry.mood == .placeholder { return "Empty day" } else if entry.mood == .missing { return "No mood logged for \(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium))" } else if !isFiltered { return "\(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)): \(entry.mood.strValue) (filtered out)" } else { return "\(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)): \(entry.mood.strValue)" } } } // MARK: - Mini Bar Chart struct MoodBarChart: View { let metrics: [MoodMetrics] let moodTint: MoodTints let imagePack: MoodImages var body: some View { VStack(spacing: 8) { ForEach(metrics) { metric in HStack(spacing: 10) { // Mood icon imagePack.icon(forMood: metric.mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .foregroundColor(moodTint.color(forMood: metric.mood)) .accessibilityLabel(metric.mood.strValue) // Bar GeometryReader { geo in ZStack(alignment: .leading) { // Background track RoundedRectangle(cornerRadius: 4) .fill(Color.gray.opacity(0.15)) // Filled bar RoundedRectangle(cornerRadius: 4) .fill(moodTint.color(forMood: metric.mood)) .frame(width: max(4, geo.size.width * CGFloat(metric.percent / 100))) } } .frame(height: 10) // Count and percentage Text("\(metric.total)") .font(.caption.weight(.semibold)) .foregroundColor(moodTint.color(forMood: metric.mood)) .frame(width: 28, alignment: .trailing) } } } } } // MARK: - Legacy support for settings button extension MonthView { private var settingsButtonView: some View { HStack { Spacer() VStack { Button(action: { showingSheet.toggle() }, label: { Image(systemName: "gear") .foregroundColor(Color(UIColor.darkGray)) .font(.title3) }).sheet(isPresented: $showingSheet) { SettingsView() } .padding(.top, 60) .padding(.trailing) Spacer() } } } } struct MonthView_Previews: PreviewProvider { static var previews: some View { MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)) } }