wip
This commit is contained in:
@@ -26,26 +26,126 @@ struct YearView: View {
|
||||
/// Cached sorted year keys to avoid re-sorting in ForEach on every render
|
||||
@State private var cachedSortedYearKeys: [Int] = []
|
||||
|
||||
// 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 var demoYearData: [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(demoYearData.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 demoYearData[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 {
|
||||
if self.viewModel.data.keys.isEmpty && !demoManager.isDemoMode {
|
||||
EmptyHomeView(showVote: false, viewModel: nil)
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(cachedSortedYearKeys, id: \.self) { yearKey in
|
||||
ForEach(Array(displayYearKeys.enumerated()), id: \.element) { yearIndex, yearKey in
|
||||
YearCard(
|
||||
year: yearKey,
|
||||
yearData: self.viewModel.data[yearKey]!,
|
||||
yearEntries: self.viewModel.entriesByYear[yearKey] ?? [],
|
||||
yearData: yearDataFor(yearKey),
|
||||
yearEntries: entriesFor(yearKey),
|
||||
moodTint: moodTint,
|
||||
imagePack: imagePack,
|
||||
theme: theme,
|
||||
filteredDays: filteredDays.currentFilters,
|
||||
yearIndex: yearIndex,
|
||||
demoManager: demoManager,
|
||||
onShare: { image in
|
||||
shareImage.selectedShareImage = image
|
||||
shareImage.showSheet = true
|
||||
@@ -63,10 +163,10 @@ struct YearView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.scrollDisabled(iapManager.shouldShowPaywall)
|
||||
.scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode)
|
||||
.mask(
|
||||
// Fade effect when paywall should show: 100% at top, 0% halfway down
|
||||
iapManager.shouldShowPaywall ?
|
||||
// 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: [
|
||||
@@ -81,8 +181,8 @@ struct YearView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if iapManager.shouldShowPaywall {
|
||||
// Premium year overview prompt - bottom half
|
||||
if iapManager.shouldShowPaywall && !demoManager.isDemoMode {
|
||||
// Premium year overview prompt - bottom half (hidden in demo mode)
|
||||
VStack(spacing: 20) {
|
||||
// Icon
|
||||
ZStack {
|
||||
@@ -148,7 +248,7 @@ struct YearView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(theme.currentTheme.bg)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
} else if iapManager.shouldShowTrialWarning {
|
||||
} else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode {
|
||||
VStack {
|
||||
Spacer()
|
||||
if !trialWarningHidden {
|
||||
@@ -168,6 +268,15 @@ struct YearView: View {
|
||||
.onAppear(perform: {
|
||||
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
|
||||
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
||||
#if DEBUG
|
||||
// Auto-start or restart demo mode for video recording
|
||||
if demoManager.isDemoMode {
|
||||
// Already in demo mode (e.g., came from MonthView), restart animation
|
||||
demoManager.restartAnimation()
|
||||
} else {
|
||||
demoManager.startDemoMode()
|
||||
}
|
||||
#endif
|
||||
})
|
||||
.onChange(of: viewModel.data.keys.count) { _, _ in
|
||||
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
||||
@@ -188,6 +297,16 @@ struct YearView: View {
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +319,8 @@ struct YearCard: View, Equatable {
|
||||
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: (UIImage) -> Void
|
||||
|
||||
private var textColor: Color { theme.currentTheme.labelColor }
|
||||
@@ -211,7 +332,8 @@ struct YearCard: View, Equatable {
|
||||
lhs.moodTint == rhs.moodTint &&
|
||||
lhs.imagePack == rhs.imagePack &&
|
||||
lhs.filteredDays == rhs.filteredDays &&
|
||||
lhs.theme == rhs.theme
|
||||
lhs.theme == rhs.theme &&
|
||||
lhs.yearIndex == rhs.yearIndex
|
||||
}
|
||||
|
||||
@State private var showStats = true
|
||||
@@ -220,18 +342,41 @@ struct YearCard: View, Equatable {
|
||||
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] {
|
||||
cachedMetrics.filter { $0.total > 0 }.sorted { $0.mood.rawValue > $1.mood.rawValue }
|
||||
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] {
|
||||
cachedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue }
|
||||
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 {
|
||||
yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count
|
||||
animatedTotalEntries
|
||||
}
|
||||
|
||||
private var topMood: Mood? {
|
||||
@@ -390,7 +535,7 @@ struct YearCard: View, Equatable {
|
||||
if showStats {
|
||||
HStack(spacing: 16) {
|
||||
// Donut Chart
|
||||
MoodDonutChart(metrics: cachedMetrics, moodTint: moodTint)
|
||||
MoodDonutChart(metrics: animatedMetrics, moodTint: moodTint)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
// Bar Chart
|
||||
@@ -449,7 +594,9 @@ struct YearCard: View, Equatable {
|
||||
YearHeatmapGrid(
|
||||
yearData: yearData,
|
||||
moodTint: moodTint,
|
||||
filteredDays: filteredDays
|
||||
filteredDays: filteredDays,
|
||||
yearIndex: yearIndex,
|
||||
demoManager: demoManager
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
@@ -472,6 +619,8 @@ 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)
|
||||
|
||||
@@ -482,14 +631,23 @@ struct YearHeatmapGrid: View {
|
||||
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(sortedMonthKeys, id: \.self) { monthKey in
|
||||
ForEach(Array(sortedMonthKeys.enumerated()), id: \.element) { monthIndex, monthKey in
|
||||
if let monthData = yearData[monthKey] {
|
||||
MonthColumn(
|
||||
DemoMonthColumn(
|
||||
monthData: monthData,
|
||||
moodTint: moodTint,
|
||||
filteredDays: filteredDays
|
||||
filteredDays: filteredDays,
|
||||
monthIndex: monthIndex,
|
||||
totalMonths: 12,
|
||||
yearIndex: yearIndex,
|
||||
demoManager: demoManager
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -516,6 +674,36 @@ struct MonthColumn: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -552,6 +740,86 @@ struct YearHeatmapCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(cellColor)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.scaleEffect(cellScale)
|
||||
.opacity(cellOpacity)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.5), value: isVisible)
|
||||
.onAppear {
|
||||
// Generate random mood once when cell appears
|
||||
randomMood = DemoAnimationManager.randomPositiveMood()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this cell should be visible in demo mode
|
||||
/// For year view, we animate column by column (left to right), then row by row within each column
|
||||
private var isVisible: Bool {
|
||||
if !demoManager.isDemoMode {
|
||||
return true // Normal mode - always visible
|
||||
}
|
||||
if !demoManager.animationStarted {
|
||||
return false // Demo mode but animation hasn't started - hide all
|
||||
}
|
||||
|
||||
return demoManager.isCellVisibleForYear(
|
||||
row: row,
|
||||
column: column,
|
||||
totalRows: totalRows,
|
||||
totalColumns: totalColumns,
|
||||
yearIndex: yearIndex
|
||||
)
|
||||
}
|
||||
|
||||
private var cellScale: CGFloat {
|
||||
isVisible ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
private var cellOpacity: Double {
|
||||
isVisible ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
private var cellColor: Color {
|
||||
if demoManager.isDemoMode {
|
||||
// In demo mode, show random positive mood colors
|
||||
if !isVisible {
|
||||
return Color.gray.opacity(0.1)
|
||||
}
|
||||
// Skip placeholder/empty cells
|
||||
if color == Mood.placeholder.color || color == Mood.missing.color {
|
||||
return Color.gray.opacity(0.1)
|
||||
}
|
||||
return moodTint.color(forMood: randomMood)
|
||||
}
|
||||
|
||||
// Normal mode - use actual colors
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user