// // HomeViewTwo.swift // Feels (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 @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.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle @StateObject private var shareImage = ShareImageStateViewModel() // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 @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 var body: some View { ZStack { if viewModel.hasNoData { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { ScrollView { VStack(spacing: 16) { ForEach(viewModel.grouped.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in // for each month ForEach(months.sorted(by: { $0.key > $1.key }), id: \.key) { month, entries in MonthCard( month: month, year: year, entries: entries, moodTint: moodTint, imagePack: imagePack, textColor: textColor, theme: theme, filteredDays: filteredDays.currentFilters, onTap: { let detailView = MonthDetailView( monthInt: month, yearInt: year, entries: entries, parentViewModel: viewModel ) selectedDetail.selectedItem = detailView selectedDetail.showSheet = true }, onShare: { image in shareImage.selectedShareImage = image shareImage.showSheet = true } ) } } } .padding(.horizontal) .padding(.bottom, 100) .background( GeometryReader { proxy in let offset = proxy.frame(in: .named("scroll")).minY Color.clear.preference(key: ViewOffsetKey.self, value: offset) } ) } .disabled(iapManager.shouldShowPaywall) } // Hidden text to trigger updates when custom tint changes Text(String(customMoodTintUpdateNumber)) .hidden() if iapManager.shouldShowPaywall { // Paywall overlay - tap to show subscription store Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { showSubscriptionStore = true } VStack { Spacer() Button { showSubscriptionStore = true } label: { Text(String(localized: "subscription_required_button")) .font(.headline) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding() .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink)) } .padding() } } else if iapManager.shouldShowTrialWarning { VStack { Spacer() if !trialWarningHidden { IAPWarningView(iapManager: iapManager) } } } } .sheet(isPresented: $showSubscriptionStore) { FeelsSubscriptionStoreView() } .onAppear(perform: { EventLogger.log(event: "show_month_view") }) .padding([.top]) .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .sheet(isPresented: $selectedDetail.showSheet, onDismiss: didDismiss) { selectedDetail.selectedItem } .sheet(isPresented: self.$shareImage.showSheet) { if let uiImage = self.shareImage.selectedShareImage { ImageOnlyShareSheet(photo: uiImage) } } .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { trialWarningHidden = value < 0 } } } func didDismiss() { selectedDetail.showSheet = false selectedDetail.selectedItem = nil } } // MARK: - Month Card Component struct MonthCard: View { let month: Int let year: Int let entries: [MoodEntryModel] let moodTint: MoodTints let imagePack: MoodImages let textColor: Color let theme: Theme let filteredDays: [Int] let onTap: () -> Void let onShare: (UIImage) -> Void @State private var showStats = true private let weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"] private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) private var metrics: [MoodMetrics] { let (startDate, endDate) = Date.dateRange(monthInt: month, yearInt: year) let monthEntries = DataController.shared.getData(startDate: startDate, endDate: endDate, includedDays: [1,2,3,4,5,6,7]) return Random.createTotalPerc(fromEntries: monthEntries) } private var topMood: Mood? { metrics.filter { $0.total > 0 }.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(.system(size: 32, weight: .heavy, design: .rounded)) .foregroundColor(textColor) .padding(.top, 40) .padding(.bottom, 8) Text("Monthly Mood Wrap") .font(.system(size: 16, weight: .medium, design: .rounded)) .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: 100, height: 100) .overlay( imagePack.icon(forMood: topMood) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(24) ) .shadow(color: moodTint.color(forMood: topMood).opacity(0.5), radius: 20, x: 0, y: 10) Text("Top Mood") .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(textColor.opacity(0.5)) Text(topMood.strValue.uppercased()) .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundColor(moodTint.color(forMood: topMood)) } .padding(.bottom, 30) } // Stats row HStack(spacing: 0) { VStack(spacing: 4) { Text("\(totalTrackedDays)") .font(.system(size: 36, weight: .bold, design: .rounded)) .foregroundColor(textColor) Text("Days Tracked") .font(.system(size: 12, weight: .medium, design: .rounded)) .foregroundColor(textColor.opacity(0.5)) } .frame(maxWidth: .infinity) } .padding(.bottom, 30) // Mood breakdown with bars VStack(spacing: 12) { ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { 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) ) GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 6) .fill(Color.gray.opacity(0.2)) 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(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundColor(textColor) .frame(width: 40, alignment: .trailing) } } } .padding(.horizontal, 32) .padding(.bottom, 40) // App branding Text("ifeel") .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(textColor.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: { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } }) { HStack { Text("\(Random.monthName(fromMonthInt: month)) \(String(year))") .font(.title3.bold()) .foregroundColor(textColor) Image(systemName: showStats ? "chevron.up" : "chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(textColor.opacity(0.5)) } } .buttonStyle(.plain) Spacer() Button(action: { let image = shareableView.asImage(size: CGSize(width: 400, height: 700)) onShare(image) }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) } .buttonStyle(.plain) } .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(textColor.opacity(0.5)) .frame(maxWidth: .infinity) } } .padding(.horizontal, 16) .padding(.bottom, 6) // Heatmap Grid LazyVGrid(columns: heatmapColumns, spacing: 2) { ForEach(entries, id: \.self) { entry in HeatmapCell( entry: entry, moodTint: moodTint, isFiltered: filteredDays.contains(Int(entry.weekDay)) ) } } .padding(.horizontal, 16) .padding(.bottom, 12) // Bar Chart Stats (collapsible) if showStats { Divider() .padding(.horizontal, 16) MoodBarChart(metrics: metrics, 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() } } } // 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 { let formatter = DateFormatter() formatter.dateStyle = .medium return formatter.string(from: entry.forDate) } 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: - Mini Bar Chart struct MoodBarChart: View { let metrics: [MoodMetrics] let moodTint: MoodTints let imagePack: MoodImages var body: some View { VStack(spacing: 8) { ForEach(metrics.filter { $0.total > 0 }) { 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)) // 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(.system(size: 20)) }).sheet(isPresented: $showingSheet) { SettingsView() } .padding(.top, 60) .padding(.trailing) Spacer() } } } } struct MonthView_Previews: PreviewProvider { static var previews: some View { MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)) } }