diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index cb28725..0cf3280 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -10,16 +10,17 @@ import CoreData 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 - + @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \MoodEntry.forDate, ascending: false)], animation: .spring()) private var items: FetchedResults - + @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 @@ -27,25 +28,10 @@ struct YearView: View { @StateObject private var filteredDays = DaysFilterClass.shared @State private var trialWarningHidden = false @State private var showSubscriptionStore = false - //[ - // 2001: [0: [], 1: [], 2: []], - // 2002: [0: [], 1: [], 2: []] - // ] - let columns = [ - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - GridItem(.flexible(minimum: 5, maximum: 50)), - ] - + + // 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 { @@ -53,20 +39,32 @@ struct YearView: View { .padding() } else { ScrollView { - gridView - .background( - GeometryReader { proxy in - let offset = proxy.frame(in: .named("scroll")).minY - Color.clear.preference(key: ViewOffsetKey.self, value: offset) - } - ) + 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) - .padding(.bottom, 5) } if iapManager.shouldShowPaywall { - // Paywall overlay - tap to show subscription store Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { @@ -113,116 +111,261 @@ struct YearView: View { } .padding([.top]) } +} - private var monthsHeader: some View { - LazyVGrid(columns: columns, spacing: 0) { - ForEach(months, id: \.self.0) { item in - Text(item.1) - .textCase(.uppercase) - .foregroundColor(textColor) - } - }.padding([.leading, .trailing, .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: [MoodEntry] { + 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 PersistenceController.shared.getData(startDate: firstOfYear, endDate: lastOfYear, includedDays: filteredDays) } - - private var gridView: some View { - VStack { - VStack { - ForEach(Array(self.viewModel.data.keys.sorted(by: >)), id: \.self) { yearKey in - let yearData = self.viewModel.data[yearKey]! - - let firstOfYear = Calendar.current.date(from: DateComponents(year: Int(yearKey), month: 1, day: 1))! - let lastOfYear = Calendar.current.date(from: DateComponents(year: Int(yearKey)+1, month: 1, day: 1))! - - let yearEntries = PersistenceController.shared.getData(startDate: firstOfYear, - endDate: lastOfYear, - includedDays: filteredDays.currentFilters) - Text(String(yearKey)) - .font(.title) + + 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) - - ZStack { - theme.currentTheme.secondaryBGColor - - HStack { - Spacer() - ForEach(Mood.allValues, id: \.self) { mood in - VStack { - Text(String(Stats.getCountFor(moodType: mood, - inData: yearEntries))) - .font(.title) - .foregroundColor(textColor) - Text(mood.strValue) - .foregroundColor(moodTint.color(forMood: mood)) + + 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))) + } } - Spacer() + .frame(height: 8) + + Text("\(Int(metric.percent))%") + .font(.caption2.weight(.medium)) + .foregroundColor(textColor.opacity(0.7)) + .frame(width: 32, alignment: .trailing) } } } - .cornerRadius(10) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 90, maxHeight: 90) - .cornerRadius(10) - .padding() - - Text(String(localized: "filter_view_total") + ": \(yearEntries.count)") - .font(.title2) - .foregroundColor(textColor) - monthsHeader - .cornerRadius(10) - yearGridView(yearData: yearData, columns: columns) - .background( - theme.currentTheme.secondaryBGColor - ) - .cornerRadius(10) + .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 + ) } - .padding([.top, .leading, .trailing]) } } } - - private struct yearGridView: View { - let yearData: [Int: [DayChartView]] - let columns: [GridItem] - - var body: some View { - VStack { - LazyVGrid(columns: columns, spacing: 0) { - ForEach(Array(yearData.keys.sorted(by: <)), id: \.self) { monthKey in - let monthData = yearData[monthKey]! - VStack { - monthGridView(monthData: monthData) - } - } - } - .padding([.leading, .trailing, .top, .bottom]) +} + +// 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) + ) } - .cornerRadius(10) } } - - private struct monthGridView: View { - @StateObject private var filteredDays = DaysFilterClass.shared - - let monthData: [DayChartView] - - var body: some View { - VStack { - ForEach(monthData, id: \.self) { view in - if filteredDays.currentFilters.contains(view.weekDay) { - view - } else { - view.filteredDaysView - } +} + +// 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) }