// // FilterView.swift // Feels // // Created by Trey Tartt on 1/12/22. // import SwiftUI import SwiftData struct YearView: View { let months = [(0, "J"), (1, "F"), (2,"M"), (3,"A"), (4,"M"), (5, "J"), (6,"J"), (7,"A"), (8,"S"), (9,"O"), (10, "N"), (11,"D")] @State private var toggle = true @Query(sort: \MoodEntryModel.forDate, order: .reverse) private var items: [MoodEntryModel] @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @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.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @EnvironmentObject var iapManager: IAPManager @StateObject public var viewModel: YearViewModel @StateObject private var filteredDays = DaysFilterClass.shared @State private var trialWarningHidden = false @State private var showSubscriptionStore = false // Heatmap-style grid: 12 columns for months private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) var body: some View { ZStack { if self.viewModel.data.keys.isEmpty { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollView { VStack(spacing: 16) { ForEach(Array(self.viewModel.data.keys.sorted(by: >)), id: \.self) { yearKey in YearCard( year: yearKey, yearData: self.viewModel.data[yearKey]!, moodTint: moodTint, imagePack: imagePack, textColor: textColor, theme: theme, filteredDays: filteredDays.currentFilters ) } } .padding(.horizontal) .padding(.bottom, 100) .background( GeometryReader { proxy in let offset = proxy.frame(in: .named("scroll")).minY Color.clear.preference(key: ViewOffsetKey.self, value: offset) } ) } .disabled(iapManager.shouldShowPaywall) } if iapManager.shouldShowPaywall { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { showSubscriptionStore = true } VStack { Spacer() Button { showSubscriptionStore = true } label: { Text(String(localized: "subscription_required_button")) .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink)) } .padding() } } else if iapManager.shouldShowTrialWarning { VStack { Spacer() if !trialWarningHidden { IAPWarningView(iapManager: iapManager) } } } } .sheet(isPresented: $showSubscriptionStore) { FeelsSubscriptionStoreView() } .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) }) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { trialWarningHidden = value < 0 } } .padding([.top]) } } // MARK: - Year Card Component struct YearCard: View { let year: Int let yearData: [Int: [DayChartView]] let moodTint: MoodTints let imagePack: MoodImages let textColor: Color let theme: Theme let filteredDays: [Int] @State private var showStats = true private let months = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) private var yearEntries: [MoodEntryModel] { let firstOfYear = Calendar.current.date(from: DateComponents(year: year, month: 1, day: 1))! let lastOfYear = Calendar.current.date(from: DateComponents(year: year + 1, month: 1, day: 1))! return DataController.shared.getData(startDate: firstOfYear, endDate: lastOfYear, includedDays: filteredDays) } private var metrics: [MoodMetrics] { return Random.createTotalPerc(fromEntries: yearEntries) } private var totalEntries: Int { yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count } var body: some View { VStack(spacing: 0) { // Year Header Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } }) { HStack { Text(String(year)) .font(.title2.bold()) .foregroundColor(textColor) Spacer() Text("\(totalEntries) days") .font(.subheadline) .foregroundColor(textColor.opacity(0.6)) Image(systemName: showStats ? "chevron.up" : "chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(textColor.opacity(0.5)) .padding(.leading, 4) } .padding(.horizontal, 16) .padding(.vertical, 12) } .buttonStyle(.plain) // Stats Section (collapsible) if showStats { HStack(spacing: 16) { // Donut Chart MoodDonutChart(metrics: metrics, moodTint: moodTint) .frame(width: 100, height: 100) // Bar Chart VStack(spacing: 6) { ForEach(metrics.filter { $0.total > 0 }) { metric in HStack(spacing: 8) { imagePack.icon(forMood: metric.mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .foregroundColor(moodTint.color(forMood: metric.mood)) GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3) .fill(Color.gray.opacity(0.15)) RoundedRectangle(cornerRadius: 3) .fill(moodTint.color(forMood: metric.mood)) .frame(width: max(4, geo.size.width * CGFloat(metric.percent / 100))) } } .frame(height: 8) Text("\(Int(metric.percent))%") .font(.caption2.weight(.medium)) .foregroundColor(textColor.opacity(0.7)) .frame(width: 32, alignment: .trailing) } } } .frame(maxWidth: .infinity) } .padding(.horizontal, 16) .padding(.bottom, 12) .transition(.opacity.combined(with: .move(edge: .top))) } Divider() .padding(.horizontal, 16) // Month Labels HStack(spacing: 2) { ForEach(months, id: \.self) { month in Text(month) .font(.system(size: 9, weight: .medium)) .foregroundColor(textColor.opacity(0.5)) .frame(maxWidth: .infinity) } } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 4) // Heatmap Grid YearHeatmapGrid( yearData: yearData, moodTint: moodTint, filteredDays: filteredDays ) .padding(.horizontal, 16) .padding(.bottom, 16) } .background( RoundedRectangle(cornerRadius: 16) .fill(theme.currentTheme.secondaryBGColor) ) } } // MARK: - Year Heatmap Grid struct YearHeatmapGrid: View { let yearData: [Int: [DayChartView]] let moodTint: MoodTints let filteredDays: [Int] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) var body: some View { LazyVGrid(columns: heatmapColumns, spacing: 2) { ForEach(Array(yearData.keys.sorted(by: <)), id: \.self) { monthKey in if let monthData = yearData[monthKey] { MonthColumn( monthData: monthData, moodTint: moodTint, filteredDays: filteredDays ) } } } } } // MARK: - Month Column (Vertical stack of days) struct MonthColumn: View { let monthData: [DayChartView] let moodTint: MoodTints let filteredDays: [Int] var body: some View { VStack(spacing: 2) { ForEach(monthData, id: \.self) { dayView in YearHeatmapCell( color: dayView.color, weekDay: dayView.weekDay, isFiltered: filteredDays.contains(dayView.weekDay) ) } } } } // MARK: - Year Heatmap Cell struct YearHeatmapCell: View { let color: Color let weekDay: Int let isFiltered: Bool var body: some View { RoundedRectangle(cornerRadius: 2) .fill(cellColor) .aspectRatio(1, contentMode: .fit) } private var cellColor: Color { if !isFiltered { return Color.gray.opacity(0.1) } else if color == Mood.placeholder.color || color == Mood.missing.color { return Color.gray.opacity(0.2) } else { return color } } } // MARK: - Donut Chart struct MoodDonutChart: View { let metrics: [MoodMetrics] let moodTint: MoodTints private var filteredMetrics: [MoodMetrics] { metrics.filter { $0.total > 0 } } private var total: Int { metrics.reduce(0) { $0 + $1.total } } var body: some View { GeometryReader { geo in let size = min(geo.size.width, geo.size.height) let lineWidth = size * 0.2 ZStack { // Background ring Circle() .stroke(Color.gray.opacity(0.15), lineWidth: lineWidth) // Mood segments ForEach(Array(filteredMetrics.enumerated()), id: \.element.id) { index, metric in Circle() .trim(from: startAngle(for: index), to: endAngle(for: index)) .stroke(moodTint.color(forMood: metric.mood), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) .rotationEffect(.degrees(-90)) } // Center text VStack(spacing: 0) { Text("\(total)") .font(.system(size: size * 0.22, weight: .bold, design: .rounded)) Text("days") .font(.system(size: size * 0.12, weight: .medium)) .foregroundColor(.secondary) } } .frame(width: size, height: size) } } private func startAngle(for index: Int) -> CGFloat { let precedingTotal = filteredMetrics.prefix(index).reduce(0) { $0 + $1.total } return CGFloat(precedingTotal) / CGFloat(max(1, total)) } private func endAngle(for index: Int) -> CGFloat { let includingCurrent = filteredMetrics.prefix(index + 1).reduce(0) { $0 + $1.total } return CGFloat(includingCurrent) / CGFloat(max(1, total)) } } struct YearView_Previews: PreviewProvider { static var previews: some View { Group { YearView(viewModel: YearViewModel()) YearView(viewModel: YearViewModel()) .preferredColorScheme(.dark) } } }