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.
881 lines
36 KiB
Swift
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))
|
|
}
|
|
}
|