wip
This commit is contained in:
@@ -583,14 +583,14 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Feels;
|
INFOPLIST_KEY_CFBundleDisplayName = Feels;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.feelsDebug;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.88oakapps.feels.debug;
|
||||||
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.2;
|
MARKETING_VERSION = 1.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.watch.debug;
|
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug.watch;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -832,7 +832,7 @@
|
|||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feels;
|
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels;
|
||||||
PRODUCT_NAME = Feels;
|
PRODUCT_NAME = Feels;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -863,7 +863,7 @@
|
|||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug;
|
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug;
|
||||||
PRODUCT_NAME = Feels;
|
PRODUCT_NAME = Feels;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -966,7 +966,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.2;
|
MARKETING_VERSION = 1.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.widget.debug;
|
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug.widget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1026,7 +1026,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Feels;
|
INFOPLIST_KEY_CFBundleDisplayName = Feels;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.feelsDebug;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.88oakapps.feels;
|
||||||
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
236
Shared/DemoAnimationManager.swift
Normal file
236
Shared/DemoAnimationManager.swift
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
//
|
||||||
|
// DemoAnimationManager.swift
|
||||||
|
// Feels
|
||||||
|
//
|
||||||
|
// Manages demo animation mode for promotional videos.
|
||||||
|
// Animates filling mood entries from top-left to bottom-right.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Manages demo animation state for promotional video recording
|
||||||
|
@MainActor
|
||||||
|
final class DemoAnimationManager: ObservableObject {
|
||||||
|
static let shared = DemoAnimationManager()
|
||||||
|
|
||||||
|
/// Whether demo mode is active
|
||||||
|
@Published var isDemoMode: Bool = false
|
||||||
|
|
||||||
|
/// Whether the animation has started (after the 3-second delay)
|
||||||
|
@Published var animationStarted: Bool = false
|
||||||
|
|
||||||
|
/// Current animation progress (0.0 to 1.0)
|
||||||
|
@Published var animationProgress: Double = 0.0
|
||||||
|
|
||||||
|
/// The delay before starting the fill animation (in seconds)
|
||||||
|
let startDelay: TimeInterval = 3.0
|
||||||
|
|
||||||
|
/// Total animation duration for filling all cells in one month
|
||||||
|
let monthAnimationDuration: TimeInterval = 1.5
|
||||||
|
|
||||||
|
/// Delay between months
|
||||||
|
let monthDelay: TimeInterval = 0.3
|
||||||
|
|
||||||
|
/// Delay between each cell in year view
|
||||||
|
let yearCellDelay: TimeInterval = 0.05
|
||||||
|
|
||||||
|
/// Delay between month columns in year view
|
||||||
|
let yearMonthDelay: TimeInterval = 0.1
|
||||||
|
|
||||||
|
/// Timer for animation progress
|
||||||
|
private var animationTimer: Timer?
|
||||||
|
private var startTime: Date?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Start demo mode - will begin animation after delay
|
||||||
|
func startDemoMode() {
|
||||||
|
isDemoMode = true
|
||||||
|
animationStarted = false
|
||||||
|
animationProgress = 0.0
|
||||||
|
|
||||||
|
// Start animation after delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + startDelay) { [weak self] in
|
||||||
|
self?.beginAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart demo mode animation (for switching between tabs)
|
||||||
|
func restartAnimation() {
|
||||||
|
animationTimer?.invalidate()
|
||||||
|
animationTimer = nil
|
||||||
|
animationStarted = false
|
||||||
|
animationProgress = 0.0
|
||||||
|
startTime = nil
|
||||||
|
|
||||||
|
// Start animation after delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + startDelay) { [weak self] in
|
||||||
|
self?.beginAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop demo mode and reset
|
||||||
|
func stopDemoMode() {
|
||||||
|
isDemoMode = false
|
||||||
|
animationStarted = false
|
||||||
|
animationProgress = 0.0
|
||||||
|
animationTimer?.invalidate()
|
||||||
|
animationTimer = nil
|
||||||
|
startTime = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin the fill animation
|
||||||
|
private func beginAnimation() {
|
||||||
|
guard isDemoMode else { return }
|
||||||
|
|
||||||
|
animationStarted = true
|
||||||
|
startTime = Date()
|
||||||
|
|
||||||
|
// Update progress at 60fps
|
||||||
|
animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update animation progress - runs continuously for multi-month animation
|
||||||
|
private func updateProgress() {
|
||||||
|
guard let startTime = startTime else { return }
|
||||||
|
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime)
|
||||||
|
// Progress goes from 0 to a large number (we use elapsed time directly)
|
||||||
|
animationProgress = elapsed
|
||||||
|
|
||||||
|
// Stop after a reasonable max time (300 seconds for year view with 0.65s per cell)
|
||||||
|
if elapsed >= 300.0 {
|
||||||
|
animationTimer?.invalidate()
|
||||||
|
animationTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the animation time for a specific month (0-indexed from most recent)
|
||||||
|
/// MonthView shows months in reverse chronological order, so monthIndex 0 is the current month
|
||||||
|
func monthStartTime(monthIndex: Int) -> Double {
|
||||||
|
return Double(monthIndex) * (monthAnimationDuration + monthDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a cell should be visible based on current animation progress
|
||||||
|
/// For MonthView: animates cells within each month from top-left to bottom-right
|
||||||
|
/// - Parameters:
|
||||||
|
/// - row: Row index within the month grid
|
||||||
|
/// - column: Column index within the month grid
|
||||||
|
/// - totalRows: Total number of rows in this month
|
||||||
|
/// - totalColumns: Total number of columns (7 for weekdays)
|
||||||
|
/// - monthIndex: Which month this is (0 = most recent/current month)
|
||||||
|
/// - Returns: True if cell should be visible
|
||||||
|
func isCellVisible(row: Int, column: Int, totalRows: Int, totalColumns: Int, monthIndex: Int = 0) -> Bool {
|
||||||
|
guard animationStarted else { return false }
|
||||||
|
|
||||||
|
// Calculate when this month's animation starts
|
||||||
|
let monthStart = monthStartTime(monthIndex: monthIndex)
|
||||||
|
|
||||||
|
// Check if we've reached this month yet
|
||||||
|
if animationProgress < monthStart {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position within this month (top-left to bottom-right)
|
||||||
|
let totalCells = Double(totalRows * totalColumns)
|
||||||
|
let cellIndex = Double(row * totalColumns + column)
|
||||||
|
let normalizedPosition = cellIndex / max(1, totalCells - 1)
|
||||||
|
|
||||||
|
// Calculate when this specific cell should appear
|
||||||
|
let cellDelay = monthStart + (normalizedPosition * monthAnimationDuration)
|
||||||
|
|
||||||
|
return animationProgress >= cellDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a cell should be visible for YearView
|
||||||
|
/// For YearView: animates column by column (month by month) within each year
|
||||||
|
/// Each cell waits for the previous cell to complete before starting
|
||||||
|
func isCellVisibleForYear(row: Int, column: Int, totalRows: Int, totalColumns: Int, yearIndex: Int = 0) -> Bool {
|
||||||
|
guard animationStarted else { return false }
|
||||||
|
|
||||||
|
// Calculate total cells in previous years
|
||||||
|
let cellsPerYear = 31 * 12 // approximate max cells per year
|
||||||
|
let yearStart = Double(yearIndex) * (Double(cellsPerYear) * yearCellDelay + yearMonthDelay)
|
||||||
|
|
||||||
|
if animationProgress < yearStart {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cell position: column-major order (month by month, then day within month)
|
||||||
|
// Each month column has up to 31 days
|
||||||
|
let cellsBeforeThisMonth = column * 31
|
||||||
|
let cellIndex = cellsBeforeThisMonth + row
|
||||||
|
|
||||||
|
// Add month delay between columns
|
||||||
|
let monthDelays = Double(column) * yearMonthDelay
|
||||||
|
|
||||||
|
// Each cell starts after the previous cell's delay
|
||||||
|
let cellStart = yearStart + Double(cellIndex) * yearCellDelay + monthDelays
|
||||||
|
|
||||||
|
return animationProgress >= cellStart
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the percentage of cells visible for a month (0.0 to 1.0)
|
||||||
|
/// Used for animating charts in demo mode
|
||||||
|
func visiblePercentageForMonth(totalCells: Int, monthIndex: Int) -> Double {
|
||||||
|
guard animationStarted else { return 0.0 }
|
||||||
|
|
||||||
|
let monthStart = monthStartTime(monthIndex: monthIndex)
|
||||||
|
|
||||||
|
// Before this month starts
|
||||||
|
if animationProgress < monthStart {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how far through the month animation we are
|
||||||
|
let progressInMonth = animationProgress - monthStart
|
||||||
|
let percentage = min(1.0, progressInMonth / monthAnimationDuration)
|
||||||
|
|
||||||
|
return percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the percentage of cells visible for a year (0.0 to 1.0)
|
||||||
|
/// Used for animating charts in year view demo mode
|
||||||
|
func visiblePercentageForYear(totalCells: Int, yearIndex: Int) -> Double {
|
||||||
|
guard animationStarted else { return 0.0 }
|
||||||
|
|
||||||
|
// Calculate when this year's animation starts
|
||||||
|
let cellsPerYear = 31 * 12
|
||||||
|
let yearStart = Double(yearIndex) * (Double(cellsPerYear) * yearCellDelay + yearMonthDelay)
|
||||||
|
|
||||||
|
// Before this year starts
|
||||||
|
if animationProgress < yearStart {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total year duration based on cell delays and month delays
|
||||||
|
let yearDuration = Double(cellsPerYear) * yearCellDelay + 12.0 * yearMonthDelay
|
||||||
|
let progressInYear = animationProgress - yearStart
|
||||||
|
let percentage = min(1.0, progressInYear / yearDuration)
|
||||||
|
|
||||||
|
return percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random mood biased towards positive values
|
||||||
|
/// Distribution: Great 35%, Good 30%, Average 20%, Bad 10%, Horrible 5%
|
||||||
|
static func randomPositiveMood() -> Mood {
|
||||||
|
let random = Double.random(in: 0...1)
|
||||||
|
switch random {
|
||||||
|
case 0..<0.35:
|
||||||
|
return .great
|
||||||
|
case 0.35..<0.65:
|
||||||
|
return .good
|
||||||
|
case 0.65..<0.85:
|
||||||
|
return .average
|
||||||
|
case 0.85..<0.95:
|
||||||
|
return .bad
|
||||||
|
default:
|
||||||
|
return .horrible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,92 @@ struct MonthView: View {
|
|||||||
/// Cached sorted year/month data to avoid recalculating in ForEach
|
/// Cached sorted year/month data to avoid recalculating in ForEach
|
||||||
@State private var cachedSortedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] = []
|
@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
|
/// Filters month data to only current month when subscription/trial expired
|
||||||
private func computeFilteredMonthData() -> [Int: [Int: [MoodEntryModel]]] {
|
private func computeFilteredMonthData() -> [Int: [Int: [MoodEntryModel]]] {
|
||||||
guard iapManager.shouldShowPaywall else {
|
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) }) }
|
.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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if viewModel.hasNoData {
|
if viewModel.hasNoData && !demoManager.isDemoMode {
|
||||||
EmptyHomeView(showVote: false, viewModel: nil)
|
EmptyHomeView(showVote: false, viewModel: nil)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ForEach(cachedSortedData, id: \.year) { yearData in
|
let allMonths = displayData.flatMap { yearData in
|
||||||
// for each month
|
yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) }
|
||||||
ForEach(yearData.months, id: \.month) { monthData in
|
}
|
||||||
MonthCard(
|
ForEach(Array(allMonths.enumerated()), id: \.element.month) { monthIndex, monthData in
|
||||||
month: monthData.month,
|
MonthCard(
|
||||||
year: yearData.year,
|
month: monthData.month,
|
||||||
entries: monthData.entries,
|
year: monthData.year,
|
||||||
moodTint: moodTint,
|
entries: monthData.entries,
|
||||||
imagePack: imagePack,
|
moodTint: moodTint,
|
||||||
theme: theme,
|
imagePack: imagePack,
|
||||||
filteredDays: filteredDays.currentFilters,
|
theme: theme,
|
||||||
onTap: {
|
filteredDays: filteredDays.currentFilters,
|
||||||
let detailView = MonthDetailView(
|
monthIndex: monthIndex,
|
||||||
monthInt: monthData.month,
|
onTap: {
|
||||||
yearInt: yearData.year,
|
let detailView = MonthDetailView(
|
||||||
entries: monthData.entries,
|
monthInt: monthData.month,
|
||||||
parentViewModel: viewModel
|
yearInt: monthData.year,
|
||||||
)
|
entries: monthData.entries,
|
||||||
selectedDetail.selectedItem = detailView
|
parentViewModel: viewModel
|
||||||
selectedDetail.showSheet = true
|
)
|
||||||
},
|
selectedDetail.selectedItem = detailView
|
||||||
onShare: { image in
|
selectedDetail.showSheet = true
|
||||||
shareImage.selectedShareImage = image
|
},
|
||||||
shareImage.showSheet = true
|
onShare: { image in
|
||||||
}
|
shareImage.selectedShareImage = image
|
||||||
)
|
shareImage.showSheet = true
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -116,10 +208,10 @@ struct MonthView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.scrollDisabled(iapManager.shouldShowPaywall)
|
.scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode)
|
||||||
.mask(
|
.mask(
|
||||||
// Fade effect when paywall should show: 100% at top, 0% halfway down
|
// Fade effect when paywall should show: 100% at top, 0% halfway down (disabled in demo mode)
|
||||||
iapManager.shouldShowPaywall ?
|
(iapManager.shouldShowPaywall && !demoManager.isDemoMode) ?
|
||||||
AnyView(
|
AnyView(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
gradient: Gradient(stops: [
|
gradient: Gradient(stops: [
|
||||||
@@ -134,8 +226,8 @@ struct MonthView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if iapManager.shouldShowPaywall {
|
if iapManager.shouldShowPaywall && !demoManager.isDemoMode {
|
||||||
// Premium month history prompt - bottom half
|
// Premium month history prompt - bottom half (hidden in demo mode)
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Icon
|
// Icon
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -201,7 +293,7 @@ struct MonthView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.bg)
|
.background(theme.currentTheme.bg)
|
||||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
} else if iapManager.shouldShowTrialWarning {
|
} else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
if !trialWarningHidden {
|
if !trialWarningHidden {
|
||||||
@@ -237,6 +329,15 @@ struct MonthView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
cachedSortedData = computeSortedYearMonthData()
|
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
|
.onChange(of: viewModel.numberOfItems) { _, _ in
|
||||||
// Use numberOfItems as a lightweight proxy for data changes
|
// Use numberOfItems as a lightweight proxy for data changes
|
||||||
@@ -247,6 +348,16 @@ struct MonthView: View {
|
|||||||
cachedSortedData = computeSortedYearMonthData()
|
cachedSortedData = computeSortedYearMonthData()
|
||||||
}
|
}
|
||||||
.preferredColorScheme(theme.preferredColorScheme)
|
.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 imagePack: MoodImages
|
||||||
let theme: Theme
|
let theme: Theme
|
||||||
let filteredDays: [Int]
|
let filteredDays: [Int]
|
||||||
|
let monthIndex: Int // Index for demo animation sequencing
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
let onShare: (UIImage) -> Void
|
let onShare: (UIImage) -> Void
|
||||||
|
|
||||||
private var labelColor: Color { theme.currentTheme.labelColor }
|
private var labelColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
|
// Demo animation support
|
||||||
|
@ObservedObject private var demoManager = DemoAnimationManager.shared
|
||||||
|
|
||||||
// Equatable conformance to prevent unnecessary re-renders
|
// Equatable conformance to prevent unnecessary re-renders
|
||||||
static func == (lhs: MonthCard, rhs: MonthCard) -> Bool {
|
static func == (lhs: MonthCard, rhs: MonthCard) -> Bool {
|
||||||
lhs.month == rhs.month &&
|
lhs.month == rhs.month &&
|
||||||
@@ -278,7 +393,8 @@ struct MonthCard: View, Equatable {
|
|||||||
lhs.moodTint == rhs.moodTint &&
|
lhs.moodTint == rhs.moodTint &&
|
||||||
lhs.imagePack == rhs.imagePack &&
|
lhs.imagePack == rhs.imagePack &&
|
||||||
lhs.filteredDays == rhs.filteredDays &&
|
lhs.filteredDays == rhs.filteredDays &&
|
||||||
lhs.theme == rhs.theme
|
lhs.theme == rhs.theme &&
|
||||||
|
lhs.monthIndex == rhs.monthIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var showStats = true
|
@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 weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"]
|
||||||
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
|
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
|
// Cached filtered/sorted metrics to avoid recalculating in ForEach
|
||||||
private var displayMetrics: [MoodMetrics] {
|
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)
|
// All 5 moods for share view (shows 0% for moods with no entries)
|
||||||
private var allMoodMetrics: [MoodMetrics] {
|
private var allMoodMetrics: [MoodMetrics] {
|
||||||
cachedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue }
|
animatedMetrics.sorted { $0.mood.rawValue > $1.mood.rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topMood: Mood? {
|
private var topMood: Mood? {
|
||||||
@@ -462,11 +593,21 @@ struct MonthCard: View, Equatable {
|
|||||||
|
|
||||||
// Heatmap Grid
|
// Heatmap Grid
|
||||||
LazyVGrid(columns: heatmapColumns, spacing: 2) {
|
LazyVGrid(columns: heatmapColumns, spacing: 2) {
|
||||||
ForEach(entries, id: \.self) { entry in
|
ForEach(Array(entries.enumerated()), id: \.element) { index, entry in
|
||||||
HeatmapCell(
|
let row = index / 7
|
||||||
|
let column = index % 7
|
||||||
|
let totalRows = (entries.count + 6) / 7
|
||||||
|
|
||||||
|
DemoHeatmapCell(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
moodTint: moodTint,
|
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()
|
Divider()
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
MoodBarChart(metrics: cachedMetrics, moodTint: moodTint, imagePack: imagePack)
|
MoodBarChart(metrics: animatedMetrics, moodTint: moodTint, imagePack: imagePack)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.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
|
// MARK: - Mini Bar Chart
|
||||||
struct MoodBarChart: View {
|
struct MoodBarChart: View {
|
||||||
let metrics: [MoodMetrics]
|
let metrics: [MoodMetrics]
|
||||||
|
|||||||
@@ -26,26 +26,126 @@ struct YearView: View {
|
|||||||
/// Cached sorted year keys to avoid re-sorting in ForEach on every render
|
/// Cached sorted year keys to avoid re-sorting in ForEach on every render
|
||||||
@State private var cachedSortedYearKeys: [Int] = []
|
@State private var cachedSortedYearKeys: [Int] = []
|
||||||
|
|
||||||
|
// MARK: - Demo Animation
|
||||||
|
@StateObject private var demoManager = DemoAnimationManager.shared
|
||||||
|
|
||||||
// Heatmap-style grid: 12 columns for months
|
// Heatmap-style grid: 12 columns for months
|
||||||
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
|
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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if self.viewModel.data.keys.isEmpty {
|
if self.viewModel.data.keys.isEmpty && !demoManager.isDemoMode {
|
||||||
EmptyHomeView(showVote: false, viewModel: nil)
|
EmptyHomeView(showVote: false, viewModel: nil)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ForEach(cachedSortedYearKeys, id: \.self) { yearKey in
|
ForEach(Array(displayYearKeys.enumerated()), id: \.element) { yearIndex, yearKey in
|
||||||
YearCard(
|
YearCard(
|
||||||
year: yearKey,
|
year: yearKey,
|
||||||
yearData: self.viewModel.data[yearKey]!,
|
yearData: yearDataFor(yearKey),
|
||||||
yearEntries: self.viewModel.entriesByYear[yearKey] ?? [],
|
yearEntries: entriesFor(yearKey),
|
||||||
moodTint: moodTint,
|
moodTint: moodTint,
|
||||||
imagePack: imagePack,
|
imagePack: imagePack,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
filteredDays: filteredDays.currentFilters,
|
filteredDays: filteredDays.currentFilters,
|
||||||
|
yearIndex: yearIndex,
|
||||||
|
demoManager: demoManager,
|
||||||
onShare: { image in
|
onShare: { image in
|
||||||
shareImage.selectedShareImage = image
|
shareImage.selectedShareImage = image
|
||||||
shareImage.showSheet = true
|
shareImage.showSheet = true
|
||||||
@@ -63,10 +163,10 @@ struct YearView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.scrollDisabled(iapManager.shouldShowPaywall)
|
.scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode)
|
||||||
.mask(
|
.mask(
|
||||||
// Fade effect when paywall should show: 100% at top, 0% halfway down
|
// Fade effect when paywall should show: 100% at top, 0% halfway down (disabled in demo mode)
|
||||||
iapManager.shouldShowPaywall ?
|
(iapManager.shouldShowPaywall && !demoManager.isDemoMode) ?
|
||||||
AnyView(
|
AnyView(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
gradient: Gradient(stops: [
|
gradient: Gradient(stops: [
|
||||||
@@ -81,8 +181,8 @@ struct YearView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if iapManager.shouldShowPaywall {
|
if iapManager.shouldShowPaywall && !demoManager.isDemoMode {
|
||||||
// Premium year overview prompt - bottom half
|
// Premium year overview prompt - bottom half (hidden in demo mode)
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Icon
|
// Icon
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -148,7 +248,7 @@ struct YearView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.bg)
|
.background(theme.currentTheme.bg)
|
||||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
} else if iapManager.shouldShowTrialWarning {
|
} else if iapManager.shouldShowTrialWarning && !demoManager.isDemoMode {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
if !trialWarningHidden {
|
if !trialWarningHidden {
|
||||||
@@ -168,6 +268,15 @@ struct YearView: View {
|
|||||||
.onAppear(perform: {
|
.onAppear(perform: {
|
||||||
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
|
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
|
||||||
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
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
|
.onChange(of: viewModel.data.keys.count) { _, _ in
|
||||||
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
||||||
@@ -188,6 +297,16 @@ struct YearView: View {
|
|||||||
}
|
}
|
||||||
.padding([.top])
|
.padding([.top])
|
||||||
.preferredColorScheme(theme.preferredColorScheme)
|
.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 imagePack: MoodImages
|
||||||
let theme: Theme
|
let theme: Theme
|
||||||
let filteredDays: [Int]
|
let filteredDays: [Int]
|
||||||
|
let yearIndex: Int // Which year this is (0 = most recent)
|
||||||
|
@ObservedObject var demoManager: DemoAnimationManager
|
||||||
let onShare: (UIImage) -> Void
|
let onShare: (UIImage) -> Void
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
@@ -211,7 +332,8 @@ struct YearCard: View, Equatable {
|
|||||||
lhs.moodTint == rhs.moodTint &&
|
lhs.moodTint == rhs.moodTint &&
|
||||||
lhs.imagePack == rhs.imagePack &&
|
lhs.imagePack == rhs.imagePack &&
|
||||||
lhs.filteredDays == rhs.filteredDays &&
|
lhs.filteredDays == rhs.filteredDays &&
|
||||||
lhs.theme == rhs.theme
|
lhs.theme == rhs.theme &&
|
||||||
|
lhs.yearIndex == rhs.yearIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var showStats = true
|
@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 months = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]
|
||||||
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
|
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
|
// Cached filtered/sorted metrics to avoid recalculating in ForEach
|
||||||
private var displayMetrics: [MoodMetrics] {
|
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)
|
// All 5 moods for share view (shows 0% for moods with no entries)
|
||||||
private var allMoodMetrics: [MoodMetrics] {
|
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 {
|
private var totalEntries: Int {
|
||||||
yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count
|
animatedTotalEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topMood: Mood? {
|
private var topMood: Mood? {
|
||||||
@@ -390,7 +535,7 @@ struct YearCard: View, Equatable {
|
|||||||
if showStats {
|
if showStats {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Donut Chart
|
// Donut Chart
|
||||||
MoodDonutChart(metrics: cachedMetrics, moodTint: moodTint)
|
MoodDonutChart(metrics: animatedMetrics, moodTint: moodTint)
|
||||||
.frame(width: 100, height: 100)
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
// Bar Chart
|
// Bar Chart
|
||||||
@@ -449,7 +594,9 @@ struct YearCard: View, Equatable {
|
|||||||
YearHeatmapGrid(
|
YearHeatmapGrid(
|
||||||
yearData: yearData,
|
yearData: yearData,
|
||||||
moodTint: moodTint,
|
moodTint: moodTint,
|
||||||
filteredDays: filteredDays
|
filteredDays: filteredDays,
|
||||||
|
yearIndex: yearIndex,
|
||||||
|
demoManager: demoManager
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
@@ -472,6 +619,8 @@ struct YearHeatmapGrid: View {
|
|||||||
let yearData: [Int: [DayChartView]]
|
let yearData: [Int: [DayChartView]]
|
||||||
let moodTint: MoodTints
|
let moodTint: MoodTints
|
||||||
let filteredDays: [Int]
|
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)
|
private let heatmapColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 12)
|
||||||
|
|
||||||
@@ -482,14 +631,23 @@ struct YearHeatmapGrid: View {
|
|||||||
Array(yearData.keys.sorted(by: <))
|
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 {
|
var body: some View {
|
||||||
LazyVGrid(columns: heatmapColumns, spacing: 2) {
|
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] {
|
if let monthData = yearData[monthKey] {
|
||||||
MonthColumn(
|
DemoMonthColumn(
|
||||||
monthData: monthData,
|
monthData: monthData,
|
||||||
moodTint: moodTint,
|
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
|
// MARK: - Year Heatmap Cell
|
||||||
struct YearHeatmapCell: View {
|
struct YearHeatmapCell: View {
|
||||||
let color: Color
|
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
|
// MARK: - Donut Chart
|
||||||
struct MoodDonutChart: View {
|
struct MoodDonutChart: View {
|
||||||
let metrics: [MoodMetrics]
|
let metrics: [MoodMetrics]
|
||||||
|
|||||||
Reference in New Issue
Block a user