// // HomeViewTwo.swift // Feels (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() @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])])] = [] /// Filters month data to only current month when subscription/trial expired private func computeFilteredMonthData() -> [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) }) } } var body: some View { ZStack { if viewModel.hasNoData { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollView { VStack(spacing: 16) { ForEach(cachedSortedData, id: \.year) { yearData in // for each month ForEach(yearData.months, id: \.month) { monthData in MonthCard( month: monthData.month, year: yearData.year, entries: monthData.entries, moodTint: moodTint, imagePack: imagePack, theme: theme, filteredDays: filteredDays.currentFilters, onTap: { let detailView = MonthDetailView( monthInt: monthData.month, yearInt: yearData.year, entries: monthData.entries, parentViewModel: viewModel ) selectedDetail.selectedItem = detailView selectedDetail.showSheet = true }, onShare: { image in shareImage.selectedShareImage = image shareImage.showSheet = true } ) } } } .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) } ) } .scrollDisabled(iapManager.shouldShowPaywall) .mask( // Fade effect when paywall should show: 100% at top, 0% halfway down iapManager.shouldShowPaywall ? 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 { // Premium month history prompt - bottom half 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) } else if iapManager.shouldShowTrialWarning { VStack { Spacer() if !trialWarningHidden { IAPWarningView(iapManager: iapManager) } } } } .sheet(isPresented: $showSubscriptionStore) { FeelsSubscriptionStoreView() } .onAppear(perform: { EventLogger.log(event: "show_month_view") }) .padding([.top]) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .sheet(isPresented: $selectedDetail.showSheet, onDismiss: didDismiss) { selectedDetail.selectedItem } .sheet(isPresented: self.$shareImage.showSheet) { if let uiImage = self.shareImage.selectedShareImage { ImageOnlyShareSheet(photo: uiImage) } } .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { trialWarningHidden = value < 0 } } .onAppear { cachedSortedData = computeSortedYearMonthData() } .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) } 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 onTap: () -> Void let onShare: (UIImage) -> Void private var labelColor: Color { theme.currentTheme.labelColor } // 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 } @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) // Cached filtered/sorted metrics to avoid recalculating in ForEach private var displayMetrics: [MoodMetrics] { cachedMetrics.filter { $0.total > 0 }.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 VStack(spacing: 12) { ForEach(displayMetrics) { 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)) 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) Spacer() Button(action: { let image = shareableView.asImage(size: CGSize(width: 400, height: 700)) onShare(image) }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium)) .foregroundColor(labelColor.opacity(0.6)) } .buttonStyle(.plain) } .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(entries, id: \.self) { entry in HeatmapCell( entry: entry, moodTint: moodTint, isFiltered: filteredDays.contains(Int(entry.weekDay)) ) } } .padding(.horizontal, 16) .padding(.bottom, 12) // Bar Chart Stats (collapsible) if showStats { Divider() .padding(.horizontal, 16) MoodBarChart(metrics: cachedMetrics, 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: - 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)) } }