Files
Reflect/Shared/Views/MonthView/MonthView.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

881 lines
36 KiB
Swift

//
// HomeViewTwo.swift
// Reflect (iOS)
//
// Created by Trey Tartt on 2/18/22.
//
import SwiftUI
struct MonthView: View {
@AppStorage(UserDefaultsStore.Keys.needsOnboarding.rawValue, store: GroupUserDefaults.groupDefaults) private var needsOnboarding = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
private var labelColor: Color { theme.currentTheme.labelColor }
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
@StateObject private var shareImage = ShareImageStateViewModel()
@State private var sharePickerData: SharePickerData? = nil
@EnvironmentObject var iapManager: IAPManager
@StateObject private var selectedDetail = DetailViewStateViewModel()
@State private var showingSheet = false
@StateObject private var onboardingData = OnboardingDataDataManager.shared
@StateObject private var filteredDays = DaysFilterClass.shared
class DetailViewStateViewModel: ObservableObject {
@Published var selectedItem: MonthDetailView? = nil
@Published var showSheet = false
}
// Heatmap-style grid with tight spacing
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
@ObservedObject var viewModel: DayViewViewModel
@State private var trialWarningHidden = false
@State private var showSubscriptionStore = false
/// Cached sorted year/month data to avoid recalculating in ForEach
@State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = []
@State private var cachedDemoSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = []
// MARK: - Demo Animation
@StateObject private var demoManager = DemoAnimationManager.shared
/// Generate fake demo data for the past 12 months
private func computeDemoSortedData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] {
var result: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = []
let calendar = Calendar.current
let now = Date()
// Group months by year
var yearDict: [Int: [(month: Int, entries: [MoodEntryModel])]] = [:]
for monthOffset in 0..<12 {
guard let monthDate = calendar.date(byAdding: .month, value: -monthOffset, to: now) else { continue }
let year = calendar.component(.year, from: monthDate)
let month = calendar.component(.month, from: monthDate)
let entries = generateDemoMonthEntries(year: year, month: month)
if yearDict[year] == nil {
yearDict[year] = []
}
yearDict[year]?.append((month: month, entries: entries))
}
// Sort years descending, months descending within each year
for year in yearDict.keys.sorted(by: >) {
if let months = yearDict[year] {
result.append((year: year, months: months.sorted { $0.month > $1.month }))
}
}
return result
}
/// Generate fake entries for a demo month
private func generateDemoMonthEntries(year: Int, month: Int) -> [MoodEntryModel] {
let calendar = Calendar.current
var entries: [MoodEntryModel] = []
// Get first day of month
var components = DateComponents()
components.year = year
components.month = month
components.day = 1
guard let firstOfMonth = calendar.date(from: components) else { return entries }
// Get the weekday of first day (1 = Sunday, 7 = Saturday)
let firstWeekday = calendar.component(.weekday, from: firstOfMonth)
// Add placeholder entries for days before the first
for i in 1..<firstWeekday {
// Create a date before the month starts for placeholder
if let placeholderDate = calendar.date(byAdding: .day, value: -(firstWeekday - i), to: firstOfMonth) {
let entry = MoodEntryModel(
forDate: placeholderDate,
mood: .placeholder,
entryType: .listView,
canEdit: false,
canDelete: false
)
entries.append(entry)
}
}
// Get number of days in month
guard let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return entries }
// Add entries for each day
for day in 1...range.count {
components.day = day
if let date = calendar.date(from: components) {
// Create a fake entry with random positive mood for demo
let entry = MoodEntryModel(
forDate: date,
mood: DemoAnimationManager.randomPositiveMood(),
entryType: .listView,
canEdit: false,
canDelete: false
)
entries.append(entry)
}
}
return entries
}
/// Filters month data to only current month when subscription/trial expired
private func computeFilteredMonthData() -> [Int: [Int: [MoodEntryModel]]] {
guard iapManager.shouldShowPaywall else {
return viewModel.grouped
}
// Only show current month when paywall should show
let currentMonth = Calendar.current.component(.month, from: Date())
let currentYear = Calendar.current.component(.year, from: Date())
var filtered: [Int: [Int: [MoodEntryModel]]] = [:]
if let yearData = viewModel.grouped[currentYear],
let monthData = yearData[currentMonth] {
filtered[currentYear] = [currentMonth: monthData]
}
return filtered
}
/// Sorts the filtered month data - called only when source data changes
private func computeSortedYearMonthData() -> [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] {
computeFilteredMonthData()
.sorted { $0.key > $1.key }
.map { (year: $0.key, months: $0.value.sorted { $0.key > $1.key }.map { (month: $0.key, entries: $0.value) }) }
}
/// Data to display - uses demo data when in demo mode, otherwise cached real data
private var displayData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] {
demoManager.isDemoMode ? cachedDemoSortedData : cachedSortedData
}
var body: some View {
ZStack {
if viewModel.hasNoData && !demoManager.isDemoMode {
EmptyHomeView(showVote: false, viewModel: nil)
.padding()
} else {
ScrollViewReader { scrollProxy in
ScrollView {
VStack(spacing: 16) {
let allMonths = displayData.flatMap { yearData in
yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) }
}
ForEach(Array(allMonths.enumerated()), id: \.element.month) { monthIndex, monthData in
MonthCard(
month: monthData.month,
year: monthData.year,
entries: monthData.entries,
moodTint: moodTint,
imagePack: imagePack,
theme: theme,
filteredDays: filteredDays.currentFilters,
monthIndex: monthIndex,
onTap: {
let detailView = MonthDetailView(
monthInt: monthData.month,
yearInt: monthData.year,
entries: monthData.entries,
parentViewModel: viewModel
)
selectedDetail.selectedItem = detailView
selectedDetail.showSheet = true
},
onShare: { metrics, entries, month in
sharePickerData = SharePickerData(
title: Random.monthName(fromMonthInt: month),
designs: [
SharingDesign(
name: "Clean Calendar",
shareView: AnyView(MonthTotalV1(moodMetrics: metrics, moodEntries: entries, month: month)),
image: { MonthTotalV1(moodMetrics: metrics, moodEntries: entries, month: month).image }
),
SharingDesign(
name: "Stacked Bars",
shareView: AnyView(MonthTotalV5(moodMetrics: metrics, moodEntries: entries, month: month)),
image: { MonthTotalV5(moodMetrics: metrics, moodEntries: entries, month: month).image }
),
]
)
}
)
.id("month-\(monthIndex)")
}
// Scroll anchor at the very bottom
Color.clear.frame(height: 1).id("scroll-end")
}
.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.MonthView.grid)
.onChange(of: demoManager.animationProgress) { _, progress in
guard demoManager.isDemoMode && demoManager.animationStarted else { return }
// Start slow scroll once first month is 50% done
let halfwayPoint = demoManager.monthAnimationDuration * 0.5
if progress >= halfwayPoint && progress < halfwayPoint + 0.1 {
// Trigger once: scroll to bottom with long duration for smooth constant speed
let totalMonths = displayData.flatMap { $0.months }.count
let totalDuration = Double(totalMonths) * demoManager.monthAnimationDuration
withAnimation(.linear(duration: totalDuration)) {
scrollProxy.scrollTo("scroll-end", anchor: .bottom)
}
}
}
}
.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 month history prompt - bottom half (hidden in demo mode)
VStack(spacing: 20) {
// Icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.purple.opacity(0.2), .pink.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "calendar.badge.clock")
.font(.title)
.foregroundStyle(
LinearGradient(
colors: [.purple, .pink],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
// Text
VStack(spacing: 10) {
Text("Explore Your Mood History")
.font(.title3.weight(.bold))
.foregroundColor(labelColor)
.multilineTextAlignment(.center)
Text("See your complete monthly journey. Track patterns and understand what shapes your days.")
.font(.subheadline)
.foregroundColor(labelColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
// Subscribe button
Button {
showSubscriptionStore = true
} label: {
HStack {
Image(systemName: "calendar")
Text("Unlock Full History")
}
.font(.headline.weight(.bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [.purple, .pink],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 24)
.accessibilityIdentifier(AccessibilityID.Paywall.monthUnlockButton)
}
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
.background(theme.currentTheme.bg)
.frame(maxHeight: .infinity, alignment: .bottom)
.accessibilityIdentifier(AccessibilityID.Paywall.monthOverlay)
} else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode {
VStack {
Spacer()
if !trialWarningHidden {
IAPWarningView(iapManager: iapManager)
}
}
}
}
.sheet(isPresented: $showSubscriptionStore) {
ReflectSubscriptionStoreView(source: "month_gate")
}
.onAppear(perform: {
AnalyticsManager.shared.trackScreen(.month)
})
.padding([.top])
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
)
.sheet(isPresented: $selectedDetail.showSheet,
onDismiss: didDismiss) {
selectedDetail.selectedItem
}
.sheet(item: $sharePickerData) { data in
SharingStylePickerView(title: data.title, designs: data.designs)
}
.onPreferenceChange(ViewOffsetKey.self) { value in
withAnimation {
trialWarningHidden = value < 0
}
}
.onAppear {
cachedSortedData = computeSortedYearMonthData()
cachedDemoSortedData = computeDemoSortedData()
}
.onChange(of: viewModel.numberOfItems) { _, _ in
// Use numberOfItems as a lightweight proxy for data changes
// instead of comparing the entire grouped dictionary
cachedSortedData = computeSortedYearMonthData()
}
.onChange(of: iapManager.shouldShowPaywall) { _, _ in
cachedSortedData = computeSortedYearMonthData()
}
.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
}
func didDismiss() {
selectedDetail.showSheet = false
selectedDetail.selectedItem = nil
}
}
// MARK: - Month Card Component
struct MonthCard: View, Equatable {
let month: Int
let year: Int
let entries: [MoodEntryModel]
let moodTint: MoodTints
let imagePack: MoodImages
let theme: Theme
let filteredDays: [Int]
let monthIndex: Int // Index for demo animation sequencing
let onTap: () -> Void
let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void
private var labelColor: Color { theme.currentTheme.labelColor }
// Demo animation support
@ObservedObject private var demoManager = DemoAnimationManager.shared
// Equatable conformance to prevent unnecessary re-renders
static func == (lhs: MonthCard, rhs: MonthCard) -> Bool {
lhs.month == rhs.month &&
lhs.year == rhs.year &&
lhs.entries.count == rhs.entries.count &&
lhs.moodTint == rhs.moodTint &&
lhs.imagePack == rhs.imagePack &&
lhs.filteredDays == rhs.filteredDays &&
lhs.theme == rhs.theme &&
lhs.monthIndex == rhs.monthIndex
}
@State private var showStats = true
@State private var cachedMetrics: [MoodMetrics] = []
private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
// Animated metrics for demo mode - scales based on visible percentage
private var animatedMetrics: [MoodMetrics] {
guard demoManager.isDemoMode else { return cachedMetrics }
let totalCells = entries.filter { $0.mood != .placeholder }.count
let visiblePercentage = demoManager.visiblePercentageForMonth(totalCells: totalCells, monthIndex: monthIndex)
// 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 }
}
private var topMood: Mood? {
displayMetrics.max(by: { $0.total < $1.total })?.mood
}
private var totalTrackedDays: Int {
entries.filter { ![.missing, .placeholder].contains($0.mood) }.count
}
private var shareableView: some View {
VStack(spacing: 0) {
// Header with month/year
Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))")
.font(.title.weight(.heavy))
.foregroundColor(labelColor)
.padding(.top, 40)
.padding(.bottom, 8)
Text("Monthly Mood Wrap")
.font(.body.weight(.medium))
.foregroundColor(labelColor.opacity(0.6))
.padding(.bottom, 30)
// Top mood highlight
if let topMood = topMood {
VStack(spacing: 12) {
Circle()
.fill(moodTint.color(forMood: topMood))
.frame(width: 100, height: 100)
.overlay(
imagePack.icon(forMood: topMood)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.white)
.padding(24)
.accessibilityLabel(topMood.strValue)
)
.shadow(color: moodTint.color(forMood: topMood).opacity(0.5), radius: 20, x: 0, y: 10)
Text("Top Mood")
.font(.subheadline.weight(.medium))
.foregroundColor(labelColor.opacity(0.5))
Text(topMood.strValue.uppercased())
.font(.title3.weight(.bold))
.foregroundColor(moodTint.color(forMood: topMood))
}
.padding(.bottom, 30)
}
// Stats row
HStack(spacing: 0) {
VStack(spacing: 4) {
Text("\(totalTrackedDays)")
.font(.largeTitle.weight(.bold))
.foregroundColor(labelColor)
Text("Days Tracked")
.font(.caption.weight(.medium))
.foregroundColor(labelColor.opacity(0.5))
}
.frame(maxWidth: .infinity)
}
.padding(.bottom, 30)
// Mood breakdown with bars (all 5 moods)
VStack(spacing: 12) {
ForEach(allMoodMetrics) { metric in
HStack(spacing: 12) {
Circle()
.fill(moodTint.color(forMood: metric.mood))
.frame(width: 32, height: 32)
.overlay(
imagePack.icon(forMood: metric.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.white)
.padding(7)
.accessibilityLabel(metric.mood.strValue)
)
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
if metric.percent > 0 {
RoundedRectangle(cornerRadius: 6)
.fill(moodTint.color(forMood: metric.mood))
.frame(width: max(8, geo.size.width * CGFloat(metric.percent / 100)))
}
}
}
.frame(height: 12)
Text("\(Int(metric.percent))%")
.font(.subheadline.weight(.semibold))
.foregroundColor(labelColor)
.frame(width: 40, alignment: .trailing)
}
}
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
// App branding
Text("ifeel")
.font(.subheadline.weight(.medium))
.foregroundColor(labelColor.opacity(0.3))
.padding(.bottom, 20)
}
.frame(width: 400)
.background(theme.currentTheme.secondaryBGColor)
}
var body: some View {
VStack(spacing: 0) {
// Month Header
HStack {
Button(action: {
if UIAccessibility.isReduceMotionEnabled {
showStats.toggle()
} else {
withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() }
}
}) {
HStack {
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.title3.bold())
.foregroundColor(labelColor)
Image(systemName: showStats ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundColor(labelColor.opacity(0.5))
}
}
.buttonStyle(.plain)
.accessibilityLabel("\(Random.monthName(fromMonthInt: month)) \(String(year)), \(showStats ? "expanded" : "collapsed")")
.accessibilityHint("Double tap to toggle statistics")
Spacer()
Button(action: {
onShare(cachedMetrics, entries, month)
}) {
Image(systemName: "square.and.arrow.up")
.font(.subheadline.weight(.medium))
.foregroundColor(labelColor.opacity(0.6))
}
.buttonStyle(.plain)
.accessibilityLabel("Share \(Random.monthName(fromMonthInt: month)) \(String(year)) mood data")
.accessibilityIdentifier(AccessibilityID.MonthView.shareButton)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// Weekday Labels
HStack(spacing: 2) {
ForEach(weekdayLabels.indices, id: \.self) { index in
Text(weekdayLabels[index])
.font(.caption2.weight(.medium))
.foregroundColor(labelColor.opacity(0.5))
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 6)
// Heatmap Grid
LazyVGrid(columns: heatmapColumns, spacing: 2) {
ForEach(Array(entries.enumerated()), id: \.element) { index, entry in
let row = index / 7
let column = index % 7
let totalRows = (entries.count + 6) / 7
DemoHeatmapCell(
entry: entry,
moodTint: moodTint,
isFiltered: filteredDays.contains(Int(entry.weekDay)),
row: row,
column: column,
totalRows: totalRows,
totalColumns: 7,
monthIndex: monthIndex,
demoManager: demoManager
)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
// Bar Chart Stats (collapsible)
if showStats {
Divider()
.padding(.horizontal, 16)
MoodBarChart(metrics: animatedMetrics, moodTint: moodTint, imagePack: imagePack)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.background(
RoundedRectangle(cornerRadius: 16)
.fill(theme.currentTheme.secondaryBGColor)
)
.contentShape(Rectangle())
.onTapGesture {
onTap()
}
.onAppear {
// Cache metrics calculation on first appearance
if cachedMetrics.isEmpty {
cachedMetrics = Random.createTotalPerc(fromEntries: entries)
}
}
}
}
// MARK: - Heatmap Cell
struct HeatmapCell: View {
let entry: MoodEntryModel
let moodTint: MoodTints
let isFiltered: Bool
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(cellColor)
.aspectRatio(1, contentMode: .fit)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(entry.mood != .placeholder && entry.mood != .missing ? "Double tap to edit" : "")
}
private var accessibilityDescription: String {
if entry.mood == .placeholder {
return "Empty day"
} else if entry.mood == .missing {
return "No mood logged for \(formattedDate)"
} else if !isFiltered {
return "\(formattedDate): \(entry.mood.strValue) (filtered out)"
} else {
return "\(formattedDate): \(entry.mood.strValue)"
}
}
private var formattedDate: String {
DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)
}
private var cellColor: Color {
if entry.mood == .placeholder {
return Color.gray.opacity(0.1)
} else if entry.mood == .missing {
return Color.gray.opacity(0.25)
} else if !isFiltered {
return Color.gray.opacity(0.1)
} else {
return moodTint.color(forMood: entry.mood)
}
}
}
// MARK: - Demo Heatmap Cell (with animation support)
struct DemoHeatmapCell: View {
let entry: MoodEntryModel
let moodTint: MoodTints
let isFiltered: Bool
let row: Int
let column: Int
let totalRows: Int
let totalColumns: Int
let monthIndex: Int // Which month this is (0 = first/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: 4)
.fill(Color.gray.opacity(0.3))
.aspectRatio(1, contentMode: .fit)
// Foreground: Animated mood color that scales 2x -> 1x
if demoManager.isDemoMode {
// Skip placeholder cells (first row offset cells)
if entry.mood != .placeholder {
RoundedRectangle(cornerRadius: 4)
.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: 4)
.fill(normalCellColor)
.aspectRatio(1, contentMode: .fit)
}
}
.onAppear {
// Generate random mood once when cell appears
randomMood = DemoAnimationManager.randomPositiveMood()
}
.accessibilityLabel(accessibilityDescription)
}
/// 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.isCellVisible(
row: row,
column: column,
totalRows: totalRows,
totalColumns: totalColumns,
monthIndex: monthIndex
)
}
/// Color for normal (non-demo) mode
private var normalCellColor: Color {
if entry.mood == .placeholder {
return Color.gray.opacity(0.1)
} else if entry.mood == .missing {
return Color.gray.opacity(0.25)
} else if !isFiltered {
return Color.gray.opacity(0.1)
} else {
return moodTint.color(forMood: entry.mood)
}
}
private var accessibilityDescription: String {
if entry.mood == .placeholder {
return "Empty day"
} else if entry.mood == .missing {
return "No mood logged for \(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium))"
} else if !isFiltered {
return "\(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)): \(entry.mood.strValue) (filtered out)"
} else {
return "\(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)): \(entry.mood.strValue)"
}
}
}
// MARK: - Mini Bar Chart
struct MoodBarChart: View {
let metrics: [MoodMetrics]
let moodTint: MoodTints
let imagePack: MoodImages
var body: some View {
VStack(spacing: 8) {
ForEach(metrics) { metric in
HStack(spacing: 10) {
// Mood icon
imagePack.icon(forMood: metric.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.foregroundColor(moodTint.color(forMood: metric.mood))
.accessibilityLabel(metric.mood.strValue)
// Bar
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.15))
// Filled bar
RoundedRectangle(cornerRadius: 4)
.fill(moodTint.color(forMood: metric.mood))
.frame(width: max(4, geo.size.width * CGFloat(metric.percent / 100)))
}
}
.frame(height: 10)
// Count and percentage
Text("\(metric.total)")
.font(.caption.weight(.semibold))
.foregroundColor(moodTint.color(forMood: metric.mood))
.frame(width: 28, alignment: .trailing)
}
}
}
}
}
// MARK: - Legacy support for settings button
extension MonthView {
private var settingsButtonView: some View {
HStack {
Spacer()
VStack {
Button(action: {
showingSheet.toggle()
}, label: {
Image(systemName: "gear")
.foregroundColor(Color(UIColor.darkGray))
.font(.title3)
}).sheet(isPresented: $showingSheet) {
SettingsView()
}
.padding(.top, 60)
.padding(.trailing)
Spacer()
}
}
}
}
struct MonthView_Previews: PreviewProvider {
static var previews: some View {
MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true))
}
}