Files
Reflect/Shared/Views/DayView/DayView.swift
Trey t 440b04159e Add Apple platform features and UX improvements
- Add HealthKit State of Mind sync for mood entries
- Add Live Activity with streak display and rating time window
- Add App Shortcuts/Siri integration for voice mood logging
- Add TipKit hints for feature discovery
- Add centralized MoodLogger for consistent side effects
- Add reminder time setting in Settings with time picker
- Fix duplicate notifications when changing reminder time
- Fix Live Activity streak showing 0 when not yet rated today
- Fix slow tap response in entry detail mood selection
- Update widget timeline to refresh at rating time
- Sync widgets when reminder time changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:21:55 -06:00

812 lines
29 KiB
Swift

//
// HomeView.swift
// Shared
//
// Created by Trey Tartt on 1/5/22.
//
import SwiftUI
import SwiftData
import Charts
import TipKit
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.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.dayViewStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var dayViewStyle: DayViewStyle = .classic
// store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change
@AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0
// 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 {
Text(String(customMoodTintUpdateNumber))
.hidden()
mainView
.onAppear(perform: {
EventLogger.log(event: "show_home_view")
})
.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])
}
// 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
DataController.shared.fillInMissingDates()
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()
}
}
}
private var listView: some View {
ScrollView {
LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) {
ForEach(viewModel.grouped.sorted(by: {
$0.key > $1.key
}), id: \.key) { year, months in
// for reach month
ForEach(months.sorted(by: {
$0.key > $1.key
}), id: \.key) { month, entries in
Section(header: SectionHeaderView(month: month, year: year, entries: entries)) {
monthListView(month: month, year: year, entries: 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)
default:
defaultSectionHeader(month: month, year: year)
}
}
}
private func defaultSectionHeader(month: Int, year: Int) -> some View {
HStack(spacing: 10) {
// Calendar icon
Image(systemName: "calendar")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(textColor.opacity(0.6))
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(textColor)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.ultraThinMaterial)
}
private func auraSectionHeader(month: Int, year: Int) -> some View {
HStack(spacing: 0) {
// Large month number as hero element
Text(String(format: "%02d", month))
.font(.system(size: 48, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [textColor, textColor.opacity(0.4)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 80)
VStack(alignment: .leading, spacing: 2) {
Text(Random.monthName(fromMonthInt: month).uppercased())
.font(.system(size: 14, weight: .bold, design: .rounded))
.tracking(3)
.foregroundColor(textColor)
Text(String(year))
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundColor(textColor.opacity(0.5))
}
Spacer()
// Decorative element
Circle()
.fill(textColor.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: [
textColor.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(textColor)
.frame(height: 4)
HStack(alignment: .firstTextBaseline, spacing: 12) {
// Large serif month name
Text(Random.monthName(fromMonthInt: month).uppercased())
.font(.system(size: 28, weight: .regular, design: .serif))
.foregroundColor(textColor)
// Year in lighter weight
Text(String(year))
.font(.system(size: 16, weight: .light, design: .serif))
.italic()
.foregroundColor(textColor.opacity(0.5))
Spacer()
// Decorative flourish
Text("§")
.font(.system(size: 20, weight: .regular, design: .serif))
.foregroundColor(textColor.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
// Thin bottom rule
Rectangle()
.fill(textColor.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(.system(size: 18, weight: .bold, design: .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(.system(size: 16, weight: .bold, design: .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
VStack(spacing: 3) {
ForEach(0..<8, id: \.self) { _ in
Rectangle()
.fill(Color.white.opacity(0.02))
.frame(height: 1)
Spacer().frame(height: 3)
}
}
}
)
}
private func inkSectionHeader(month: Int, year: Int) -> some View {
HStack(alignment: .center, spacing: 16) {
// Brush stroke accent
Capsule()
.fill(textColor.opacity(0.15))
.frame(width: 40, height: 3)
VStack(alignment: .leading, spacing: 2) {
Text(Random.monthName(fromMonthInt: month))
.font(.system(size: 18, weight: .thin))
.tracking(4)
.foregroundColor(textColor)
Text(String(year))
.font(.system(size: 11, weight: .ultraLight))
.foregroundColor(textColor.opacity(0.4))
}
Spacer()
// Zen circle ornament
Circle()
.trim(from: 0, to: 0.7)
.stroke(textColor.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(.system(size: 20, weight: .semibold))
.foregroundColor(textColor)
Capsule()
.fill(textColor.opacity(0.2))
.frame(width: 4, height: 4)
Text(String(year))
.font(.system(size: 16, weight: .medium))
.foregroundColor(textColor.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(textColor.opacity(0.3), lineWidth: 2)
.frame(width: 24, height: 24)
Circle()
.fill(textColor.opacity(0.2))
.frame(width: 10, height: 10)
}
VStack(alignment: .leading, spacing: 2) {
Text("SIDE A")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.4))
Text("\(Random.monthName(fromMonthInt: month).uppercased()) '\(String(year).suffix(2))")
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundColor(textColor)
.tracking(1)
}
Spacer()
// Track counter
Text(String(format: "%02d", month))
.font(.system(size: 20, weight: .bold, design: .monospaced))
.foregroundColor(textColor.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(textColor.opacity(0.08))
.frame(width: 120, height: 60)
.blur(radius: 15)
.offset(x: -20)
Spacer()
}
HStack(spacing: 16) {
Text(Random.monthName(fromMonthInt: month))
.font(.system(size: 22, weight: .light))
.foregroundColor(textColor)
Text(String(year))
.font(.system(size: 14, weight: .regular))
.foregroundColor(textColor.opacity(0.4))
Spacer()
// Blob indicator
Circle()
.fill(textColor.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
HStack(spacing: 0) {
ForEach(0..<30, id: \.self) { _ in
Rectangle()
.fill(textColor.opacity(0.2))
.frame(height: CGFloat.random(in: 2...4))
}
}
.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(.system(size: 18, weight: .regular, design: .serif))
.foregroundColor(textColor)
Text(String(year))
.font(.system(size: 14, weight: .light, design: .serif))
.italic()
.foregroundColor(textColor.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) : textColor.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(.system(size: 32, weight: .thin))
.foregroundColor(hasData ? barColor.opacity(0.6) : textColor.opacity(0.3))
.frame(width: 50)
// Gradient bar sized by average mood
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background track
Capsule()
.fill(textColor.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(.system(size: 14, weight: .medium))
.foregroundColor(textColor)
if hasData {
Text(String(format: "%.1f avg", averageMood + 1)) // Display as 1-5
.font(.system(size: 10, weight: .semibold))
.foregroundColor(barColor)
} else {
Text(String(year))
.font(.system(size: 11, weight: .regular))
.foregroundColor(textColor.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(.system(size: 16, weight: .semibold))
.foregroundColor(textColor.opacity(0.6))
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(textColor)
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(.system(size: 16, weight: .bold, design: .serif))
.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(.system(size: 12, weight: .medium, design: .serif))
.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(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundColor(textColor)
}
VStack(alignment: .leading, spacing: 2) {
Text(Random.monthName(fromMonthInt: month))
.font(.system(size: 18, weight: .medium))
.foregroundColor(textColor)
Text(String(year))
.font(.system(size: 12, weight: .regular))
.foregroundColor(textColor.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)
}
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)
}
}