Address findings from comprehensive audit across 5 workstreams: - Memory: Token-based DataController listeners (prevent closure leaks), static DateFormatters, ImageCache observer cleanup, MotionManager reference counting, FoundationModels dedup guard - Concurrency: Replace Task.detached with Task in FeelsApp (preserve MainActor isolation), wrap WatchConnectivity handler in MainActor - Performance: Cache sortedGroupedData in DayViewViewModel, cache demo data in MonthView/YearView, remove broken ReduceMotionModifier - Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell labels, MonthCard button labels, InsightsView header traits, Smart Invert protection on neon headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
934 lines
34 KiB
Swift
934 lines
34 KiB
Swift
//
|
|
// HomeView.swift
|
|
// Shared
|
|
//
|
|
// Created by Trey Tartt on 1/5/22.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct DayViewConstants {
|
|
static let maxHeaderHeight = 200.0
|
|
static let minHeaderHeight = 120.0
|
|
}
|
|
|
|
struct DayView: View {
|
|
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
|
|
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
|
|
|
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
|
|
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
|
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
// MARK: edit row properties
|
|
@State private var showingSheet = false
|
|
@State private var selectedEntry: MoodEntryModel?
|
|
//
|
|
|
|
// MARK: ?? properties
|
|
@State private var showTodayInput = true
|
|
@StateObject private var onboardingData = OnboardingDataDataManager.shared
|
|
@StateObject private var filteredDays = DaysFilterClass.shared
|
|
@EnvironmentObject var iapManager: IAPManager
|
|
|
|
@ObservedObject var viewModel: DayViewViewModel
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
mainView
|
|
.onAppear(perform: {
|
|
AnalyticsManager.shared.trackScreen(.day)
|
|
})
|
|
.sheet(isPresented: $showingSheet) {
|
|
SettingsView()
|
|
}
|
|
.sheet(item: $selectedEntry) { entry in
|
|
EntryDetailView(
|
|
entry: entry,
|
|
onMoodUpdate: { newMood in
|
|
viewModel.update(entry: entry, toMood: newMood)
|
|
},
|
|
onDelete: {
|
|
viewModel.update(entry: entry, toMood: .missing)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding([.top])
|
|
.preferredColorScheme(theme.preferredColorScheme)
|
|
}
|
|
|
|
|
|
// MARK: Views
|
|
public var mainView: some View {
|
|
VStack(spacing: 12) {
|
|
if viewModel.hasNoData {
|
|
Spacer()
|
|
EmptyHomeView(showVote: true, viewModel: viewModel)
|
|
Spacer()
|
|
} else {
|
|
headerView
|
|
|
|
listView
|
|
}
|
|
}
|
|
.padding([.leading, .trailing])
|
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
|
viewModel.updateData()
|
|
}
|
|
.background(
|
|
theme.currentTheme.bg
|
|
)
|
|
}
|
|
|
|
private var headerView: some View {
|
|
VStack {
|
|
if ShowBasedOnVoteLogics.isMissingCurrentVote(onboardingData: UserDefaultsStore.getOnboarding()) {
|
|
AddMoodHeaderView(addItemHeaderClosure: { (mood, date) in
|
|
viewModel.add(mood: mood, forDate: date, entryType: .header)
|
|
})
|
|
.widgetVotingTip()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sorted year/month data cached in ViewModel — avoids re-sorting on every render
|
|
private var sortedGroupedData: [(year: Int, months: [(month: Int, entries: [MoodEntryModel])])] {
|
|
viewModel.sortedGroupedData
|
|
}
|
|
|
|
private var listView: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) {
|
|
ForEach(sortedGroupedData, id: \.year) { yearData in
|
|
ForEach(yearData.months, id: \.month) { monthData in
|
|
Section(header: SectionHeaderView(month: monthData.month, year: yearData.year, entries: monthData.entries)) {
|
|
monthListView(month: monthData.month, year: yearData.year, entries: monthData.entries)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 20)
|
|
}
|
|
.background(
|
|
theme.currentTheme.secondaryBGColor
|
|
)
|
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight])
|
|
}
|
|
}
|
|
|
|
// view that make up the list body
|
|
extension DayView {
|
|
private func SectionHeaderView(month: Int, year: Int, entries: [MoodEntryModel]) -> some View {
|
|
Group {
|
|
switch dayViewStyle {
|
|
case .aura:
|
|
auraSectionHeader(month: month, year: year)
|
|
case .chronicle:
|
|
chronicleSectionHeader(month: month, year: year)
|
|
case .neon:
|
|
neonSectionHeader(month: month, year: year)
|
|
case .ink:
|
|
inkSectionHeader(month: month, year: year)
|
|
case .prism:
|
|
prismSectionHeader(month: month, year: year)
|
|
case .tape:
|
|
tapeSectionHeader(month: month, year: year)
|
|
case .morph:
|
|
morphSectionHeader(month: month, year: year)
|
|
case .stack:
|
|
stackSectionHeader(month: month, year: year)
|
|
case .wave:
|
|
waveSectionHeader(month: month, year: year, entries: entries)
|
|
case .pattern:
|
|
patternSectionHeader(month: month, year: year)
|
|
case .leather:
|
|
leatherSectionHeader(month: month, year: year)
|
|
case .glass:
|
|
glassSectionHeader(month: month, year: year)
|
|
case .motion:
|
|
motionSectionHeader(month: month, year: year)
|
|
case .micro:
|
|
microSectionHeader(month: month, year: year)
|
|
default:
|
|
defaultSectionHeader(month: month, year: year)
|
|
}
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.DaySection.header(month: month, year: year))
|
|
}
|
|
|
|
private func defaultSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(spacing: 10) {
|
|
// Calendar icon
|
|
Image(systemName: "calendar")
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
|
|
.accessibilityHidden(true)
|
|
|
|
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
|
|
.font(.title3.weight(.bold))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(.ultraThinMaterial)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(String(localized: "\(Random.monthName(fromMonthInt: month)) \(String(year))"))
|
|
.accessibilityAddTraits(.isHeader)
|
|
}
|
|
|
|
private func auraSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(spacing: 0) {
|
|
// Large month number as hero element
|
|
Text(String(format: "%02d", month))
|
|
.font(.largeTitle.weight(.black))
|
|
.foregroundStyle(
|
|
LinearGradient(
|
|
colors: [theme.currentTheme.labelColor, theme.currentTheme.labelColor.opacity(0.4)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(width: 80)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(Random.monthName(fromMonthInt: month).uppercased())
|
|
.font(.subheadline.weight(.bold))
|
|
.tracking(3)
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Text(String(year))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Decorative element
|
|
Circle()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.1))
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 20)
|
|
.background(
|
|
ZStack {
|
|
// Base material
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
|
|
// Subtle gradient accent
|
|
LinearGradient(
|
|
colors: [
|
|
theme.currentTheme.labelColor.opacity(0.03),
|
|
Color.clear
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func chronicleSectionHeader(month: Int, year: Int) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Thick editorial rule
|
|
Rectangle()
|
|
.fill(theme.currentTheme.labelColor)
|
|
.frame(height: 4)
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
// Large serif month name
|
|
Text(Random.monthName(fromMonthInt: month).uppercased())
|
|
.font(.title.weight(.regular))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
// Year in lighter weight
|
|
Text(String(year))
|
|
.font(.body.weight(.light))
|
|
.italic()
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
|
|
|
|
Spacer()
|
|
|
|
// Decorative flourish
|
|
Text("§")
|
|
.font(.title3.weight(.regular))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 16)
|
|
|
|
// Thin bottom rule
|
|
Rectangle()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.2))
|
|
.frame(height: 1)
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func neonSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(spacing: 12) {
|
|
// Glowing terminal prompt
|
|
Text(">")
|
|
.font(.headline.weight(.bold).monospaced())
|
|
.foregroundColor(Color(red: 0.4, green: 1.0, blue: 0.4))
|
|
.shadow(color: Color(red: 0.4, green: 1.0, blue: 0.4).opacity(0.8), radius: 4, x: 0, y: 0)
|
|
|
|
Text("\(Random.monthName(fromMonthInt: month).uppercased())_\(String(year))")
|
|
.font(.body.weight(.bold).monospaced())
|
|
.foregroundColor(.white)
|
|
.shadow(color: .white.opacity(0.3), radius: 2, x: 0, y: 0)
|
|
|
|
Spacer()
|
|
|
|
// Blinking cursor effect
|
|
Rectangle()
|
|
.fill(Color(red: 0.4, green: 1.0, blue: 0.4))
|
|
.frame(width: 10, height: 18)
|
|
.shadow(color: Color(red: 0.4, green: 1.0, blue: 0.4), radius: 4, x: 0, y: 0)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 16)
|
|
.background(
|
|
ZStack {
|
|
Color.black
|
|
|
|
// Scanlines - simplified with Canvas for performance
|
|
Canvas { context, size in
|
|
let lineHeight: CGFloat = 4
|
|
var y: CGFloat = 0
|
|
while y < size.height {
|
|
let rect = CGRect(x: 0, y: y, width: size.width, height: 1)
|
|
context.fill(Path(rect), with: .color(.white.opacity(0.02)))
|
|
y += lineHeight
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.accessibilityIgnoresInvertColors(true)
|
|
}
|
|
|
|
private func inkSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(alignment: .center, spacing: 16) {
|
|
// Brush stroke accent
|
|
Capsule()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.15))
|
|
.frame(width: 40, height: 3)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.headline.weight(.thin))
|
|
.tracking(4)
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Text(String(year))
|
|
.font(.caption2.weight(.ultraLight))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Zen circle ornament
|
|
Circle()
|
|
.trim(from: 0, to: 0.7)
|
|
.stroke(theme.currentTheme.labelColor.opacity(0.2), style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
|
.frame(width: 20, height: 20)
|
|
.rotationEffect(.degrees(-60))
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 20)
|
|
.background(
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
)
|
|
}
|
|
|
|
private func prismSectionHeader(month: Int, year: Int) -> some View {
|
|
ZStack {
|
|
// Rainbow edge glow
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(
|
|
AngularGradient(
|
|
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
|
|
center: .leading
|
|
)
|
|
)
|
|
.blur(radius: 8)
|
|
.opacity(0.3)
|
|
|
|
// Glass content
|
|
HStack(spacing: 12) {
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Capsule()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.2))
|
|
.frame(width: 4, height: 4)
|
|
|
|
Text(String(year))
|
|
.font(.body.weight(.medium))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 16)
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
}
|
|
|
|
private func tapeSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(spacing: 12) {
|
|
// Tape reel icon
|
|
ZStack {
|
|
Circle()
|
|
.stroke(theme.currentTheme.labelColor.opacity(0.3), lineWidth: 2)
|
|
.frame(width: 24, height: 24)
|
|
Circle()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.2))
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("SIDE A")
|
|
.font(.caption2.weight(.bold).monospaced())
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
|
|
|
|
Text("\(Random.monthName(fromMonthInt: month).uppercased()) '\(String(year).suffix(2))")
|
|
.font(.body.weight(.black))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
.tracking(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Track counter
|
|
Text(String(format: "%02d", month))
|
|
.font(.title3.weight(.bold).monospaced())
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.ultraThinMaterial)
|
|
)
|
|
}
|
|
|
|
private func morphSectionHeader(month: Int, year: Int) -> some View {
|
|
ZStack {
|
|
// Organic blob background
|
|
HStack {
|
|
Ellipse()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.08))
|
|
.frame(width: 120, height: 60)
|
|
.blur(radius: 15)
|
|
.offset(x: -20)
|
|
Spacer()
|
|
}
|
|
|
|
HStack(spacing: 16) {
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.title2.weight(.light))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Text(String(year))
|
|
.font(.subheadline.weight(.regular))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
|
|
|
|
Spacer()
|
|
|
|
// Blob indicator
|
|
Circle()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.15))
|
|
.frame(width: 12, height: 12)
|
|
.blur(radius: 2)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 18)
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func stackSectionHeader(month: Int, year: Int) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Torn edge - simplified with Canvas for performance
|
|
Canvas { context, size in
|
|
let segmentWidth = size.width / 15
|
|
for i in 0..<15 {
|
|
let height = CGFloat(2 + (i * 5) % 3) // Deterministic pseudo-random heights
|
|
let rect = CGRect(x: CGFloat(i) * segmentWidth, y: 0, width: segmentWidth, height: height)
|
|
context.fill(Path(rect), with: .color(Color(uiColor: UIColor.label).opacity(0.2)))
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
|
|
HStack(spacing: 12) {
|
|
// Red margin line
|
|
Rectangle()
|
|
.fill(Color.red.opacity(0.3))
|
|
.frame(width: 2)
|
|
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.headline.weight(.regular))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Text(String(year))
|
|
.font(.subheadline.weight(.light))
|
|
.italic()
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func waveSectionHeader(month: Int, year: Int, entries: [MoodEntryModel]) -> some View {
|
|
// Calculate average mood (excluding missing entries)
|
|
let validEntries = entries.filter { $0.moodValue != Mood.missing.rawValue && $0.moodValue <= 4 }
|
|
let averageMood: Double = validEntries.isEmpty ? 0 : Double(validEntries.map { $0.moodValue }.reduce(0, +)) / Double(validEntries.count)
|
|
let hasData = !validEntries.isEmpty
|
|
|
|
// Map average to a mood for coloring (0-4 scale)
|
|
let moodForColor: Mood = {
|
|
if !hasData { return .missing }
|
|
switch Int(round(averageMood)) {
|
|
case 0: return .horrible
|
|
case 1: return .bad
|
|
case 2: return .average
|
|
case 3: return .good
|
|
default: return .great
|
|
}
|
|
}()
|
|
|
|
let barColor = hasData ? moodTint.color(forMood: moodForColor) : theme.currentTheme.labelColor.opacity(0.2)
|
|
// Width percentage based on average (0=20%, 4=100%)
|
|
let widthPercent = hasData ? 0.2 + (averageMood / 4.0) * 0.8 : 0.2
|
|
|
|
return HStack(spacing: 0) {
|
|
// Month number
|
|
Text(String(format: "%02d", month))
|
|
.font(.title.weight(.thin))
|
|
.foregroundColor(hasData ? barColor.opacity(0.6) : theme.currentTheme.labelColor.opacity(0.3))
|
|
.frame(width: 50)
|
|
|
|
// Gradient bar sized by average mood
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
// Background track
|
|
Capsule()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.1))
|
|
.frame(height: 8)
|
|
|
|
// Colored bar based on average
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [barColor, barColor.opacity(0.6)],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * widthPercent, height: 8)
|
|
.shadow(color: barColor.opacity(0.4), radius: 4, x: 0, y: 2)
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
.padding(.horizontal, 12)
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
if hasData {
|
|
Text(String(format: "%.1f avg", averageMood + 1)) // Display as 1-5
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(barColor)
|
|
} else {
|
|
Text(String(year))
|
|
.font(.caption2.weight(.regular))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 16)
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func patternSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "calendar")
|
|
.font(.body.weight(.semibold))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
|
|
|
|
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
|
|
.font(.title3.weight(.bold))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func leatherSectionHeader(month: Int, year: Int) -> some View {
|
|
ZStack {
|
|
// Leather texture background
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.45, green: 0.28, blue: 0.18),
|
|
Color(red: 0.38, green: 0.22, blue: 0.12),
|
|
Color(red: 0.42, green: 0.25, blue: 0.15)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
|
|
// Leather grain texture
|
|
Rectangle()
|
|
.fill(
|
|
EllipticalGradient(
|
|
colors: [.white.opacity(0.08), .clear],
|
|
center: .topLeading,
|
|
startRadiusFraction: 0,
|
|
endRadiusFraction: 0.5
|
|
)
|
|
)
|
|
|
|
HStack {
|
|
// Left stitching
|
|
VStack(spacing: 6) {
|
|
ForEach(0..<4, id: \.self) { _ in
|
|
Capsule()
|
|
.fill(Color(red: 0.7, green: 0.55, blue: 0.35))
|
|
.frame(width: 6, height: 2)
|
|
}
|
|
}
|
|
.padding(.leading, 8)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(Random.monthName(fromMonthInt: month).uppercased())
|
|
.font(.body.weight(.bold))
|
|
.foregroundColor(Color(red: 0.9, green: 0.85, blue: 0.75))
|
|
.shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1)
|
|
|
|
Text(String(year))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(Color(red: 0.8, green: 0.7, blue: 0.55))
|
|
}
|
|
.padding(.leading, 12)
|
|
|
|
Spacer()
|
|
|
|
// Metal rivet
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color(red: 0.85, green: 0.75, blue: 0.5),
|
|
Color(red: 0.55, green: 0.45, blue: 0.25)
|
|
],
|
|
center: .topLeading,
|
|
startRadius: 0,
|
|
endRadius: 10
|
|
)
|
|
)
|
|
.frame(width: 16, height: 16)
|
|
Circle()
|
|
.fill(Color(red: 0.4, green: 0.3, blue: 0.2))
|
|
.frame(width: 6, height: 6)
|
|
}
|
|
.padding(.trailing, 16)
|
|
}
|
|
.padding(.vertical, 14)
|
|
}
|
|
.frame(height: 56)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
|
|
private func glassSectionHeader(month: Int, year: Int) -> some View {
|
|
ZStack {
|
|
// Variable blur glass layers
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
|
|
// Light refraction effect
|
|
GeometryReader { geo in
|
|
Ellipse()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.4), .white.opacity(0)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * 0.6, height: 30)
|
|
.blur(radius: 10)
|
|
.offset(x: 20, y: 5)
|
|
}
|
|
|
|
// Rainbow edge shimmer
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.stroke(
|
|
AngularGradient(
|
|
colors: [
|
|
.white.opacity(0.5),
|
|
.blue.opacity(0.2),
|
|
.purple.opacity(0.2),
|
|
.white.opacity(0.3),
|
|
.white.opacity(0.5)
|
|
],
|
|
center: .center
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
.blur(radius: 0.5)
|
|
|
|
HStack(spacing: 16) {
|
|
// Floating glass orb
|
|
ZStack {
|
|
Circle()
|
|
.fill(.ultraThinMaterial)
|
|
.frame(width: 36, height: 36)
|
|
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.6), .clear],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 34, height: 34)
|
|
.blur(radius: 4)
|
|
|
|
Text(String(format: "%02d", month))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.headline.weight(.medium))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Text(String(year))
|
|
.font(.caption.weight(.regular))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Specular highlight dot
|
|
Circle()
|
|
.fill(.white.opacity(0.6))
|
|
.frame(width: 6, height: 6)
|
|
.blur(radius: 1)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
.frame(height: 64)
|
|
}
|
|
|
|
// MARK: - Motion Style Section Header
|
|
private func motionSectionHeader(month: Int, year: Int) -> some View {
|
|
ZStack {
|
|
// Animated gradient background
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.blue.opacity(0.15),
|
|
Color.purple.opacity(0.1),
|
|
Color.pink.opacity(0.1)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
// Floating orbs
|
|
GeometryReader { geo in
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [Color.blue.opacity(0.3), Color.clear],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 30
|
|
)
|
|
)
|
|
.frame(width: 60, height: 60)
|
|
.offset(x: geo.size.width * 0.7, y: -10)
|
|
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [Color.purple.opacity(0.25), Color.clear],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 20
|
|
)
|
|
)
|
|
.frame(width: 40, height: 40)
|
|
.offset(x: 30, y: geo.size.height * 0.5)
|
|
}
|
|
|
|
HStack(spacing: 16) {
|
|
// Motion icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color.blue.opacity(0.5), Color.purple.opacity(0.5)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 44, height: 44)
|
|
|
|
Image(systemName: "gyroscope")
|
|
.font(.title2.weight(.medium))
|
|
.foregroundColor(.white)
|
|
}
|
|
.shadow(color: Color.purple.opacity(0.3), radius: 8, x: 0, y: 4)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(Random.monthName(fromMonthInt: month))
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundColor(theme.currentTheme.labelColor)
|
|
|
|
Text(String(year))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Tilt indicator
|
|
Image(systemName: "iphone.gen3.radiowaves.left.and.right")
|
|
.font(.headline)
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
|
|
}
|
|
.padding(.horizontal, 18)
|
|
}
|
|
.frame(height: 68)
|
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// MARK: - Micro Style Section Header
|
|
private func microSectionHeader(month: Int, year: Int) -> some View {
|
|
HStack(spacing: 8) {
|
|
// Minimal colored bar
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(theme.currentTheme.labelColor.opacity(0.3))
|
|
.frame(width: 3, height: 16)
|
|
|
|
Text("\(Random.monthName(fromMonthInt: month).prefix(3).uppercased())")
|
|
.font(.caption2.weight(.bold).monospaced())
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.6))
|
|
|
|
Text("•")
|
|
.font(.caption2)
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.3))
|
|
|
|
Text(String(year))
|
|
.font(.caption2.weight(.medium).monospaced())
|
|
.foregroundColor(theme.currentTheme.labelColor.opacity(0.4))
|
|
|
|
// Thin separator line
|
|
Rectangle()
|
|
.fill(theme.currentTheme.labelColor.opacity(0.1))
|
|
.frame(height: 1)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private var gridColumns: [GridItem] {
|
|
[
|
|
GridItem(.flexible(), spacing: 10),
|
|
GridItem(.flexible(), spacing: 10),
|
|
GridItem(.flexible(), spacing: 10)
|
|
]
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func monthListView(month: Int, year: Int, entries: [MoodEntryModel]) -> some View {
|
|
let filteredEntries = entries.sorted(by: { $0.forDate > $1.forDate })
|
|
.filter { filteredDays.currentFilters.contains($0.weekDay) }
|
|
|
|
if dayViewStyle.isGridLayout {
|
|
// Grid layout - 3 per row
|
|
LazyVGrid(columns: gridColumns, spacing: 10) {
|
|
ForEach(filteredEntries, id: \.self) { entry in
|
|
EntryListView(entry: entry)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
selectedEntry = entry
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 8)
|
|
} else {
|
|
// Standard vertical layout
|
|
VStack(spacing: 12) {
|
|
ForEach(filteredEntries, id: \.self) { entry in
|
|
EntryListView(entry: entry)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
selectedEntry = entry
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ViewOffsetKey: PreferenceKey {
|
|
typealias Value = CGFloat
|
|
static var defaultValue = CGFloat.zero
|
|
static func reduce(value: inout Value, nextValue: () -> Value) {
|
|
value += nextValue()
|
|
}
|
|
}
|
|
|
|
struct DayView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false))
|
|
.modelContainer(DataController.shared.container)
|
|
.onAppear(perform: {
|
|
DataController.shared.populateMemory()
|
|
})
|
|
|
|
DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false))
|
|
.preferredColorScheme(.dark)
|
|
.modelContainer(DataController.shared.container)
|
|
}
|
|
}
|