wip
This commit is contained in:
@@ -44,6 +44,92 @@ struct MonthView: View {
|
||||
/// Cached sorted year/month data to avoid recalculating in ForEach
|
||||
@State private var cachedSortedData: [(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 var demoSortedData: [(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 {
|
||||
@@ -69,41 +155,47 @@ struct MonthView: View {
|
||||
.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 ? demoSortedData : cachedSortedData
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if viewModel.hasNoData {
|
||||
if viewModel.hasNoData && !demoManager.isDemoMode {
|
||||
EmptyHomeView(showVote: false, viewModel: nil)
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(cachedSortedData, id: \.year) { yearData in
|
||||
// for each month
|
||||
ForEach(yearData.months, id: \.month) { monthData in
|
||||
MonthCard(
|
||||
month: monthData.month,
|
||||
year: yearData.year,
|
||||
entries: monthData.entries,
|
||||
moodTint: moodTint,
|
||||
imagePack: imagePack,
|
||||
theme: theme,
|
||||
filteredDays: filteredDays.currentFilters,
|
||||
onTap: {
|
||||
let detailView = MonthDetailView(
|
||||
monthInt: monthData.month,
|
||||
yearInt: yearData.year,
|
||||
entries: monthData.entries,
|
||||
parentViewModel: viewModel
|
||||
)
|
||||
selectedDetail.selectedItem = detailView
|
||||
selectedDetail.showSheet = true
|
||||
},
|
||||
onShare: { image in
|
||||
shareImage.selectedShareImage = image
|
||||
shareImage.showSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
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: { image in
|
||||
shareImage.selectedShareImage = image
|
||||
shareImage.showSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -116,10 +208,10 @@ struct MonthView: 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: [
|
||||
@@ -134,8 +226,8 @@ struct MonthView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if iapManager.shouldShowPaywall {
|
||||
// Premium month history prompt - bottom half
|
||||
if iapManager.shouldShowPaywall && !demoManager.isDemoMode {
|
||||
// Premium month history prompt - bottom half (hidden in demo mode)
|
||||
VStack(spacing: 20) {
|
||||
// Icon
|
||||
ZStack {
|
||||
@@ -201,7 +293,7 @@ struct MonthView: 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 {
|
||||
@@ -237,6 +329,15 @@ struct MonthView: View {
|
||||
}
|
||||
.onAppear {
|
||||
cachedSortedData = computeSortedYearMonthData()
|
||||
#if DEBUG
|
||||
// Auto-start or restart demo mode for video recording
|
||||
if demoManager.isDemoMode {
|
||||
// Already in demo mode (e.g., came from YearView), restart animation
|
||||
demoManager.restartAnimation()
|
||||
} else {
|
||||
demoManager.startDemoMode()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: viewModel.numberOfItems) { _, _ in
|
||||
// Use numberOfItems as a lightweight proxy for data changes
|
||||
@@ -247,6 +348,16 @@ struct MonthView: View {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -265,11 +376,15 @@ struct MonthCard: View, Equatable {
|
||||
let imagePack: MoodImages
|
||||
let theme: Theme
|
||||
let filteredDays: [Int]
|
||||
let monthIndex: Int // Index for demo animation sequencing
|
||||
let onTap: () -> Void
|
||||
let onShare: (UIImage) -> 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 &&
|
||||
@@ -278,7 +393,8 @@ struct MonthCard: View, Equatable {
|
||||
lhs.moodTint == rhs.moodTint &&
|
||||
lhs.imagePack == rhs.imagePack &&
|
||||
lhs.filteredDays == rhs.filteredDays &&
|
||||
lhs.theme == rhs.theme
|
||||
lhs.theme == rhs.theme &&
|
||||
lhs.monthIndex == rhs.monthIndex
|
||||
}
|
||||
|
||||
@State private var showStats = true
|
||||
@@ -287,14 +403,29 @@ struct MonthCard: View, Equatable {
|
||||
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] {
|
||||
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 }
|
||||
}
|
||||
|
||||
private var topMood: Mood? {
|
||||
@@ -462,11 +593,21 @@ struct MonthCard: View, Equatable {
|
||||
|
||||
// Heatmap Grid
|
||||
LazyVGrid(columns: heatmapColumns, spacing: 2) {
|
||||
ForEach(entries, id: \.self) { entry in
|
||||
HeatmapCell(
|
||||
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))
|
||||
isFiltered: filteredDays.contains(Int(entry.weekDay)),
|
||||
row: row,
|
||||
column: column,
|
||||
totalRows: totalRows,
|
||||
totalColumns: 7,
|
||||
monthIndex: monthIndex,
|
||||
demoManager: demoManager
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -478,7 +619,7 @@ struct MonthCard: View, Equatable {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
MoodBarChart(metrics: cachedMetrics, moodTint: moodTint, imagePack: imagePack)
|
||||
MoodBarChart(metrics: animatedMetrics, moodTint: moodTint, imagePack: imagePack)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
@@ -544,6 +685,85 @@ struct HeatmapCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(cellColor)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.scaleEffect(cellScale)
|
||||
.opacity(cellOpacity)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: isVisible)
|
||||
.onAppear {
|
||||
// Generate random mood once when cell appears
|
||||
randomMood = DemoAnimationManager.randomPositiveMood()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this cell should be visible in demo mode
|
||||
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.isCellVisible(
|
||||
row: row,
|
||||
column: column,
|
||||
totalRows: totalRows,
|
||||
totalColumns: totalColumns,
|
||||
monthIndex: monthIndex
|
||||
)
|
||||
}
|
||||
|
||||
private var cellScale: CGFloat {
|
||||
isVisible ? 1.0 : 0.3
|
||||
}
|
||||
|
||||
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 cells at the start of month
|
||||
if entry.mood == .placeholder {
|
||||
return Color.gray.opacity(0.1)
|
||||
}
|
||||
return moodTint.color(forMood: randomMood)
|
||||
}
|
||||
|
||||
// Normal mode - use actual entry data
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user