Files
Reflect/Shared/Views/YearView/YearView.swift
Trey T e7648ddd8a Add missing accessibility identifiers to all interactive UI elements
Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts,
links) missing accessibility identifiers across 13 view files. Added
centralized ID definitions and applied them to every entry detail button,
guided reflection control, settings toggle, paywall unlock button,
subscription/IAP button, lock screen control, and photo action dialog.
2026-03-26 07:59:52 -05:00

909 lines
35 KiB
Swift

//
// 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)
.accessibilityIdentifier(AccessibilityID.Paywall.yearUnlockButton)
}
.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)
}
}
}