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.
909 lines
35 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|