// // FilterView.swift // Reflect // // Created by Trey Tartt on 1/12/22. // import SwiftUI 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 @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 @EnvironmentObject var iapManager: IAPManager @StateObject public var viewModel: YearViewModel @StateObject private var filteredDays = DaysFilterClass.shared @StateObject private var shareImage = ShareImageStateViewModel() @State private var trialWarningHidden = false @State private var showSubscriptionStore = false @State private var sharePickerData: SharePickerData? = nil /// Cached sorted year keys to avoid re-sorting in ForEach on every render @State private var cachedSortedYearKeys: [Int] = [] @State private var cachedDemoYearData: [Int: [Int: [DayChartView]]] = [:] // MARK: - Demo Animation @StateObject private var demoManager = DemoAnimationManager.shared // Heatmap-style grid: 12 columns for months private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) /// Generate demo year data for the past 3 years (full 12 months each) private func computeDemoYearData() -> [Int: [Int: [DayChartView]]] { var result: [Int: [Int: [DayChartView]]] = [:] let calendar = Calendar.current let currentYear = calendar.component(.year, from: Date()) for yearOffset in 0..<3 { let year = currentYear - yearOffset var yearDict: [Int: [DayChartView]] = [:] // Generate all 12 months for demo (including future months) for month in 1...12 { var components = DateComponents() components.year = year components.month = month components.day = 1 guard let firstOfMonth = calendar.date(from: components), let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { continue } var monthDays: [DayChartView] = [] for day in 1...range.count { components.day = day if let date = calendar.date(from: components) { let weekDay = calendar.component(.weekday, from: date) // Use average mood color as placeholder (demo cell will assign random colors) let dayView = DayChartView( color: moodTint.color(forMood: .average), weekDay: weekDay, shape: .circle ) monthDays.append(dayView) } } yearDict[month] = monthDays } result[year] = yearDict } return result } /// Generate demo entries for metrics calculation (all 12 months) private func demoEntriesForYear(_ year: Int) -> [MoodEntryModel] { var entries: [MoodEntryModel] = [] let calendar = Calendar.current // Generate all 12 months for demo for month in 1...12 { var components = DateComponents() components.year = year components.month = month components.day = 1 guard let firstOfMonth = calendar.date(from: components), let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { continue } for day in 1...range.count { components.day = day if let date = calendar.date(from: components) { let entry = MoodEntryModel( forDate: date, mood: DemoAnimationManager.randomPositiveMood(), entryType: .listView, canEdit: false, canDelete: false ) entries.append(entry) } } } return entries } /// Year keys to display - demo data or real data private var displayYearKeys: [Int] { if demoManager.isDemoMode { return Array(cachedDemoYearData.keys.sorted(by: >)) } return cachedSortedYearKeys } /// Year data for a specific year - demo or real private func yearDataFor(_ year: Int) -> [Int: [DayChartView]] { if demoManager.isDemoMode { return cachedDemoYearData[year] ?? [:] } return viewModel.data[year] ?? [:] } /// Entries for a specific year - demo or real private func entriesFor(_ year: Int) -> [MoodEntryModel] { if demoManager.isDemoMode { return demoEntriesForYear(year) } return viewModel.entriesByYear[year] ?? [] } var body: some View { ZStack { if self.viewModel.data.keys.isEmpty && !demoManager.isDemoMode { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollView { VStack(spacing: 16) { ForEach(Array(displayYearKeys.enumerated()), id: \.element) { yearIndex, yearKey in YearCard( year: yearKey, yearData: yearDataFor(yearKey), yearEntries: entriesFor(yearKey), moodTint: moodTint, imagePack: imagePack, theme: theme, filteredDays: filteredDays.currentFilters, yearIndex: yearIndex, demoManager: demoManager, onShare: { metrics, entries, year in let totalCount = entries.filter { ![.missing, .placeholder].contains($0.mood) }.count sharePickerData = SharePickerData( title: String(year), designs: [ SharingDesign( name: "Gradient", shareView: AnyView(AllMoodsV2(metrics: metrics, totalCount: totalCount)), image: { AllMoodsV2(metrics: metrics, totalCount: totalCount).image } ), SharingDesign( name: "Color Block", shareView: AnyView(AllMoodsV5(metrics: metrics, totalCount: totalCount)), image: { AllMoodsV5(metrics: metrics, totalCount: totalCount).image } ), ] ) } ) } } .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.YearView.heatmap) .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 year overview prompt - bottom half (hidden in demo mode) VStack(spacing: 20) { // Icon ZStack { Circle() .fill( LinearGradient( colors: [.orange.opacity(0.2), .pink.opacity(0.2)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 80, height: 80) Image(systemName: "chart.bar.xaxis") .font(.title) .foregroundStyle( LinearGradient( colors: [.orange, .pink], startPoint: .topLeading, endPoint: .bottomTrailing ) ) } // Text VStack(spacing: 10) { Text("See Your Year at a Glance") .font(.title3.weight(.bold)) .foregroundColor(theme.currentTheme.labelColor) .multilineTextAlignment(.center) Text("Discover your emotional rhythm across the year. Spot trends and celebrate how far you've come.") .font(.subheadline) .foregroundColor(theme.currentTheme.labelColor.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 24) } // Subscribe button Button { showSubscriptionStore = true } label: { HStack { Image(systemName: "chart.bar.fill") Text("Unlock Year Overview") } .font(.headline.weight(.bold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( LinearGradient( colors: [.orange, .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.yearOverlay) } else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode { VStack { Spacer() if !trialWarningHidden { IAPWarningView(iapManager: iapManager) } } } } .sheet(isPresented: $showSubscriptionStore) { ReflectSubscriptionStoreView(source: "year_gate") } .sheet(item: $sharePickerData) { data in SharingStylePickerView(title: data.title, designs: data.designs) } .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) cachedDemoYearData = computeDemoYearData() }) .onChange(of: viewModel.data.keys.count) { _, _ in cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) } .onChange(of: moodTint) { _, _ in // Rebuild chart data when mood tint changes to update colors self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) } .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { trialWarningHidden = value < 0 } } .padding([.top]) .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 } } // MARK: - Year Card Component struct YearCard: View, Equatable { let year: Int let yearData: [Int: [DayChartView]] let yearEntries: [MoodEntryModel] let moodTint: MoodTints let imagePack: MoodImages let theme: Theme let filteredDays: [Int] let yearIndex: Int // Which year this is (0 = most recent) @ObservedObject var demoManager: DemoAnimationManager let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void private var textColor: Color { theme.currentTheme.labelColor } // Equatable conformance to prevent unnecessary re-renders static func == (lhs: YearCard, rhs: YearCard) -> Bool { lhs.year == rhs.year && lhs.yearEntries.count == rhs.yearEntries.count && lhs.moodTint == rhs.moodTint && lhs.imagePack == rhs.imagePack && lhs.filteredDays == rhs.filteredDays && lhs.theme == rhs.theme && lhs.yearIndex == rhs.yearIndex } @State private var showStats = true @State private var cachedMetrics: [MoodMetrics] = [] 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) // Animated metrics for demo mode - scales based on visible percentage private var animatedMetrics: [MoodMetrics] { guard demoManager.isDemoMode else { return cachedMetrics } let totalCells = yearEntries.filter { $0.mood != .placeholder && $0.mood != .missing }.count let visiblePercentage = demoManager.visiblePercentageForYear(totalCells: totalCells, yearIndex: yearIndex) // 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 } } // Animated total entries for demo mode private var animatedTotalEntries: Int { let total = yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count guard demoManager.isDemoMode else { return total } let visiblePercentage = demoManager.visiblePercentageForYear(totalCells: total, yearIndex: yearIndex) return Int(Double(total) * visiblePercentage) } private var totalEntries: Int { animatedTotalEntries } private var topMood: Mood? { displayMetrics.max(by: { $0.total < $1.total })?.mood } private var shareableView: some View { VStack(spacing: 0) { // Header with year Text(String(year)) .font(.largeTitle.weight(.heavy)) .foregroundColor(textColor) .padding(.top, 40) .padding(.bottom, 8) Text("Year in Review") .font(.headline.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) .padding(.bottom, 30) // Top mood highlight if let topMood = topMood { VStack(spacing: 12) { Circle() .fill(moodTint.color(forMood: topMood)) .frame(width: 120, height: 120) .overlay( imagePack.icon(forMood: topMood) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(28) .accessibilityLabel(topMood.strValue) ) .shadow(color: moodTint.color(forMood: topMood).opacity(0.5), radius: 25, x: 0, y: 12) Text("Top Mood") .font(.subheadline.weight(.medium)) .foregroundColor(textColor.opacity(0.5)) Text(topMood.strValue.uppercased()) .font(.title2.weight(.bold)) .foregroundColor(moodTint.color(forMood: topMood)) } .padding(.bottom, 30) } // Stats row HStack(spacing: 0) { VStack(spacing: 4) { Text("\(totalEntries)") .font(.largeTitle.weight(.bold)) .foregroundColor(textColor) Text("Days Tracked") .font(.caption.weight(.medium)) .foregroundColor(textColor.opacity(0.5)) } .frame(maxWidth: .infinity) } .padding(.bottom, 30) // Mood breakdown with bars VStack(spacing: 14) { ForEach(allMoodMetrics) { metric in HStack(spacing: 14) { Circle() .fill(moodTint.color(forMood: metric.mood)) .frame(width: 36, height: 36) .overlay( imagePack.icon(forMood: metric.mood) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(8) .accessibilityLabel(metric.mood.strValue) ) GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) if metric.percent > 0 { RoundedRectangle(cornerRadius: 8) .fill(moodTint.color(forMood: metric.mood)) .frame(width: max(8, geo.size.width * CGFloat(metric.percent / 100))) } } } .frame(height: 16) Text("\(Int(metric.percent))%") .font(.body.weight(.semibold)) .foregroundColor(textColor) .frame(width: 45, alignment: .trailing) } } } .padding(.horizontal, 32) .padding(.bottom, 40) // App branding Text("ifeel") .font(.subheadline.weight(.medium)) .foregroundColor(textColor.opacity(0.3)) .padding(.bottom, 20) } .frame(width: 400) .background(theme.currentTheme.secondaryBGColor) } var body: some View { VStack(spacing: 0) { // Year Header HStack { Button(action: { if UIAccessibility.isReduceMotionEnabled { showStats.toggle() } else { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } } }) { HStack { Text(String(year)) .font(.title2.bold()) .foregroundColor(textColor) 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) } } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.YearView.cardHeader(year: year)) Spacer() Button(action: { onShare(cachedMetrics, yearEntries, year) }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.YearView.shareButton) } .padding(.horizontal, 16) .padding(.vertical, 12) // Stats Section (collapsible) if showStats { HStack(spacing: 16) { // Donut Chart MoodDonutChart(metrics: animatedMetrics, moodTint: moodTint) .frame(width: 100, height: 100) .accessibilityIdentifier(AccessibilityID.YearView.donutChart) // Bar Chart VStack(spacing: 6) { ForEach(displayMetrics) { 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)) .accessibilityLabel(metric.mood.strValue) 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) .accessibilityIdentifier(AccessibilityID.YearView.barChart) } .padding(.horizontal, 16) .padding(.bottom, 12) .transition(.opacity.combined(with: .move(edge: .top))) .accessibilityIdentifier(AccessibilityID.YearView.statsSection) } Divider() .padding(.horizontal, 16) // Month Labels HStack(spacing: 2) { ForEach(months.indices, id: \.self) { index in Text(months[index]) .font(.caption2.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, yearIndex: yearIndex, demoManager: demoManager ) .accessibilityIdentifier(AccessibilityID.YearView.heatmap) .padding(.horizontal, 16) .padding(.bottom, 16) } .background( RoundedRectangle(cornerRadius: 16) .fill(theme.currentTheme.secondaryBGColor) ) .onAppear { // Cache metrics calculation on first appearance if cachedMetrics.isEmpty { cachedMetrics = Random.createTotalPerc(fromEntries: yearEntries) } } } } // MARK: - Year Heatmap Grid struct YearHeatmapGrid: View { let yearData: [Int: [DayChartView]] let moodTint: MoodTints let filteredDays: [Int] let yearIndex: Int // Which year this grid belongs to (0 = most recent) @ObservedObject var demoManager: DemoAnimationManager private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12) /// Pre-sorted month keys to avoid sorting in ForEach on every render private var sortedMonthKeys: [Int] { // This is computed once per yearData change, not on every body access // since yearData is a let constant passed from parent Array(yearData.keys.sorted(by: <)) } /// Total days across all months for animation positioning private var totalDays: Int { yearData.values.reduce(0) { $0 + $1.count } } var body: some View { LazyVGrid(columns: heatmapColumns, spacing: 2) { ForEach(Array(sortedMonthKeys.enumerated()), id: \.element) { monthIndex, monthKey in if let monthData = yearData[monthKey] { DemoMonthColumn( monthData: monthData, moodTint: moodTint, filteredDays: filteredDays, monthIndex: monthIndex, totalMonths: 12, yearIndex: yearIndex, demoManager: demoManager ) } } } } } // 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: - Demo Month Column (with animation support) struct DemoMonthColumn: View { let monthData: [DayChartView] let moodTint: MoodTints let filteredDays: [Int] let monthIndex: Int let totalMonths: Int let yearIndex: Int // Which year this column belongs to @ObservedObject var demoManager: DemoAnimationManager var body: some View { VStack(spacing: 2) { ForEach(Array(monthData.enumerated()), id: \.element) { dayIndex, dayView in DemoYearHeatmapCell( color: dayView.color, weekDay: dayView.weekDay, isFiltered: filteredDays.contains(dayView.weekDay), row: dayIndex, column: monthIndex, totalRows: monthData.count, totalColumns: totalMonths, moodTint: moodTint, yearIndex: yearIndex, demoManager: demoManager ) } } } } // 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) .accessibilityLabel(accessibilityDescription) } private var accessibilityDescription: String { if !isFiltered { return "Filtered out" } else if color == Mood.placeholder.color { return "Empty" } else if color == Mood.missing.color { return "No mood logged" } else { return "Mood entry" } } 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: - Demo Year Heatmap Cell (with animation support) struct DemoYearHeatmapCell: View { let color: Color let weekDay: Int let isFiltered: Bool let row: Int let column: Int let totalRows: Int let totalColumns: Int let moodTint: MoodTints let yearIndex: Int // Which year this is (0 = 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: 2) .fill(Color.gray.opacity(0.3)) .aspectRatio(1, contentMode: .fit) // Foreground: Animated mood color that scales 2x -> 1x if demoManager.isDemoMode { RoundedRectangle(cornerRadius: 2) .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: 2) .fill(normalCellColor) .aspectRatio(1, contentMode: .fit) } } .onAppear { // Generate random mood once when cell appears randomMood = DemoAnimationManager.randomPositiveMood() } .accessibilityLabel(accessibilityDescription) } private var accessibilityDescription: String { if !isFiltered { return "Filtered out" } else if color == Mood.placeholder.color { return "Empty" } else if color == Mood.missing.color { return "No mood logged" } else { return "Mood entry" } } /// 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.isCellVisibleForYear( row: row, column: column, totalRows: totalRows, totalColumns: totalColumns, yearIndex: yearIndex ) } /// Color for normal (non-demo) mode private var normalCellColor: 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) } } }