Files
Reflect/Shared/Views/YearView/YearView.swift
Trey T d97db4910e Rewrite all UI tests following fail-fast TEST_RULES patterns
Rewrote 60+ test files to follow honeydue-style test guidelines:
- defaultTimeout=2s, navigationTimeout=5s — fail fast, no long waits
- No coordinate taps (except onboarding paged TabView swipes)
- No sleep(), no retry loops
- No guard...else { return } silent passes — XCTFail everywhere
- All elements by accessibility ID via UITestID constants
- Screen objects for all navigation/actions/assertions
- One logical assertion per test method

Added missing accessibility identifiers to app views:
- MonthView.swift: added AccessibilityID.MonthView.grid to ScrollView
- YearView.swift: added AccessibilityID.YearView.heatmap to ScrollView

Framework rewrites:
- BaseUITestCase: added session ID, localeArguments, extraLaunchArguments
- WaitHelpers: waitForExistenceOrFail, waitUntilHittableOrFail,
  waitForNonExistence, scrollIntoView, forceTap
- All 7 screen objects rewritten with fail-fast semantics
- TEST_RULES.md added with non-negotiable rules

Known remaining issues:
- OnboardingTests: paged TabView swipes unreliable on iOS 26 simulator
- SettingsLegalLinksTests: EULA/Privacy buttons too deep in DEBUG scroll
- Customization horizontal picker scrolling needs further tuning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:00:30 -05:00

908 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)
}
.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)
}
}
}