Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-26 19:46:19 -06:00
parent 1f55b1b1f2
commit e6a2bce76d
4 changed files with 187 additions and 72 deletions

View File

@ -72,6 +72,16 @@ struct HistoryMonthView: View {
.padding(Design.Spacing.medium) .padding(Design.Spacing.medium)
.background(AppSurface.card) .background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.stroke(AppBorder.subtle.opacity(Design.Opacity.light), lineWidth: 1)
)
.shadow(
color: AppBorder.subtle.opacity(Design.Opacity.light),
radius: AppMetrics.Shadow.radiusSmall,
x: AppMetrics.Shadow.xOffsetNone,
y: AppMetrics.Shadow.yOffsetSmall
)
} }
private var weekdayHeader: some View { private var weekdayHeader: some View {

View File

@ -23,6 +23,7 @@ struct HistoryView: View {
@State private var monthsToShow = 2 @State private var monthsToShow = 2
@State private var refreshToken = UUID() @State private var refreshToken = UUID()
@State private var cachedProgressByDate: [Date: Double] = [:] @State private var cachedProgressByDate: [Date: Double] = [:]
@State private var headerMinY: CGFloat = 0
private let calendar = Calendar.current private let calendar = Calendar.current
private let baseMonthsToShow = 2 private let baseMonthsToShow = 2
@ -43,6 +44,29 @@ struct HistoryView: View {
) )
} }
private let headerMaxHeight: CGFloat = 84
private let headerMinHeight: CGFloat = 58
private var headerHeight: CGFloat {
let collapseDistance = headerMaxHeight - headerMinHeight
let collapseAmount = min(max(-headerMinY, 0), collapseDistance)
return headerMaxHeight - collapseAmount
}
private var headerScale: CGFloat {
let collapseDistance = headerMaxHeight - headerMinHeight
guard collapseDistance > 0 else { return 1 }
let progress = min(max(-headerMinY / collapseDistance, 0), 1)
return 1 - (0.08 * progress)
}
private var headerOpacity: Double {
let collapseDistance = headerMaxHeight - headerMinHeight
guard collapseDistance > 0 else { return 1 }
let progress = min(max(-headerMinY / collapseDistance, 0), 1)
return 1 - (0.25 * progress)
}
/// Generate months based on expanded state /// Generate months based on expanded state
/// - Collapsed: Last month + current month (2 months) /// - Collapsed: Last month + current month (2 months)
/// - Expanded: Up to 12 months of history /// - Expanded: Up to 12 months of history
@ -82,34 +106,51 @@ struct HistoryView: View {
var body: some View { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { LazyVStack(alignment: .leading, spacing: Design.Spacing.large, pinnedViews: [.sectionHeaders]) {
// Header with Show More/Less button Section(
headerSection header: headerSection
.padding(.top, Design.Spacing.large)
// Ritual filter picker .padding(.bottom, Design.Spacing.small)
ritualPicker .frame(maxWidth: .infinity, alignment: .leading)
.frame(height: headerHeight, alignment: .bottom)
// Month calendars - 2-column grid on iPad/landscape .scaleEffect(headerScale, anchor: .leading)
LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) { .opacity(headerOpacity)
ForEach(months, id: \.self) { month in .background(
HistoryMonthView( GeometryReader { proxy in
month: month, Color.clear
selectedRitual: selectedRitual, .preference(key: HistoryHeaderMinYKey.self, value: proxy.frame(in: .named("historyScroll")).minY)
completionRate: { date, ritual in
let day = calendar.startOfDay(for: date)
return cachedProgressByDate[day] ?? store.completionRate(for: day, ritual: ritual)
},
onDayTapped: { date in
selectedDateItem = IdentifiableDate(date: date)
} }
) )
.frame(maxHeight: .infinity, alignment: .top) .background(AppSurface.primary.opacity(0.9))
) {
// Ritual filter picker
ritualPicker
// Month calendars - 2-column grid on iPad/landscape
LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) {
ForEach(months, id: \.self) { month in
HistoryMonthView(
month: month,
selectedRitual: selectedRitual,
completionRate: { date, ritual in
let day = calendar.startOfDay(for: date)
return cachedProgressByDate[day] ?? store.completionRate(for: day, ritual: ritual)
},
onDayTapped: { date in
selectedDateItem = IdentifiableDate(date: date)
}
)
.frame(maxHeight: .infinity, alignment: .top)
}
} }
.id(refreshToken)
} }
.id(refreshToken)
} }
.padding(Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.bottom, Design.Spacing.large)
} }
.coordinateSpace(name: "historyScroll")
.onPreferenceChange(HistoryHeaderMinYKey.self) { headerMinY = $0 }
.background(LinearGradient( .background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary], colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading, startPoint: .topLeading,
@ -247,6 +288,14 @@ struct HistoryView: View {
} }
} }
private struct HistoryHeaderMinYKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
#Preview { #Preview {
HistoryView(store: RitualStore.preview) HistoryView(store: RitualStore.preview)
} }

View File

@ -21,34 +21,40 @@ struct TodayRitualSectionView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Bedrock.Design.Spacing.large) { VStack(alignment: .leading, spacing: Bedrock.Design.Spacing.large) {
// Section header with time indicator
HStack {
VStack(alignment: .leading, spacing: Bedrock.Design.Spacing.xSmall) {
Text(focusTitle)
.font(.headline)
.foregroundStyle(AppTextColors.primary)
Text(focusTheme)
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
// Time of day indicator
Image(systemName: timeOfDay.symbolName)
.foregroundStyle(AppTextColors.tertiary)
.accessibilityLabel(timeOfDay.displayName)
}
focusCard focusCard
SectionHeaderView( VStack(alignment: .leading, spacing: Bedrock.Design.Spacing.large) {
title: String(localized: "Habits"), HStack {
subtitle: String(localized: "Tap to check in") SectionHeaderView(
) title: String(localized: "Habits"),
subtitle: String(localized: "Tap to check in")
)
habitsList Spacer()
// Time of day indicator
Image(systemName: timeOfDay.symbolName)
.foregroundStyle(AppTextColors.tertiary)
.accessibilityLabel(timeOfDay.displayName)
}
habitsList
}
.padding(.horizontal, Bedrock.Design.Spacing.large)
.padding(.bottom, Bedrock.Design.Spacing.large)
} }
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.stroke(AppBorder.subtle.opacity(Design.Opacity.light), lineWidth: 1)
)
.shadow(
color: AppBorder.subtle.opacity(Design.Opacity.light),
radius: AppMetrics.Shadow.radiusSmall,
x: AppMetrics.Shadow.xOffsetNone,
y: AppMetrics.Shadow.yOffsetSmall
)
} }
private var focusCard: some View { private var focusCard: some View {

View File

@ -5,6 +5,7 @@ struct TodayView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore @Bindable var categoryStore: CategoryStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var headerMinY: CGFloat = 0
/// Rituals to show now based on current time of day /// Rituals to show now based on current time of day
private var todayRituals: [Ritual] { private var todayRituals: [Ritual] {
@ -35,42 +36,83 @@ struct TodayView: View {
horizontalSizeClass: horizontalSizeClass horizontalSizeClass: horizontalSizeClass
) )
} }
private let headerMaxHeight: CGFloat = 76
private let headerMinHeight: CGFloat = 52
private var headerHeight: CGFloat {
let collapseDistance = headerMaxHeight - headerMinHeight
let collapseAmount = min(max(-headerMinY, 0), collapseDistance)
return headerMaxHeight - collapseAmount
}
private var headerScale: CGFloat {
let collapseDistance = headerMaxHeight - headerMinHeight
guard collapseDistance > 0 else { return 1 }
let progress = min(max(-headerMinY / collapseDistance, 0), 1)
return 1 - (0.08 * progress)
}
private var headerOpacity: Double {
let collapseDistance = headerMaxHeight - headerMinHeight
guard collapseDistance > 0 else { return 1 }
let progress = min(max(-headerMinY / collapseDistance, 0), 1)
return 1 - (0.25 * progress)
}
var body: some View { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { LazyVStack(alignment: .leading, spacing: Design.Spacing.large, pinnedViews: [.sectionHeaders]) {
TodayHeaderView(dateText: store.todayDisplayString) Section(
header: TodayHeaderView(dateText: store.todayDisplayString)
if todayRituals.isEmpty { .padding(.top, Design.Spacing.large)
if hasRitualsButNotNow { .padding(.bottom, Design.Spacing.small)
// Has active rituals but none for current time of day .frame(maxWidth: .infinity, alignment: .leading)
TodayNoRitualsForTimeView(store: store) .frame(height: headerHeight, alignment: .bottom)
.scaleEffect(headerScale, anchor: .leading)
.opacity(headerOpacity)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: TodayHeaderMinYKey.self, value: proxy.frame(in: .named("todayScroll")).minY)
}
)
.background(AppSurface.primary.opacity(0.9))
) {
if todayRituals.isEmpty {
if hasRitualsButNotNow {
// Has active rituals but none for current time of day
TodayNoRitualsForTimeView(store: store)
} else {
// No active rituals at all
TodayEmptyStateView(store: store, categoryStore: categoryStore)
}
} else { } else {
// No active rituals at all // Use 2-column grid on iPad/landscape when multiple rituals
TodayEmptyStateView(store: store, categoryStore: categoryStore) LazyVGrid(columns: ritualColumns, alignment: .leading, spacing: Design.Spacing.large) {
} ForEach(todayRituals) { ritual in
} else { TodayRitualSectionView(
// Use 2-column grid on iPad/landscape when multiple rituals focusTitle: ritual.title,
LazyVGrid(columns: ritualColumns, alignment: .leading, spacing: Design.Spacing.large) { focusTheme: ritual.theme,
ForEach(todayRituals) { ritual in dayLabel: store.ritualDayLabel(for: ritual),
TodayRitualSectionView( completionSummary: store.completionSummary(for: ritual),
focusTitle: ritual.title, progress: store.ritualProgress(for: ritual),
focusTheme: ritual.theme, habitRows: habitRows(for: ritual),
dayLabel: store.ritualDayLabel(for: ritual), iconName: ritual.iconName,
completionSummary: store.completionSummary(for: ritual), timeOfDay: ritual.timeOfDay
progress: store.ritualProgress(for: ritual), )
habitRows: habitRows(for: ritual), .frame(maxHeight: .infinity, alignment: .top)
iconName: ritual.iconName, }
timeOfDay: ritual.timeOfDay
)
.frame(maxHeight: .infinity, alignment: .top)
} }
} }
} }
} }
.padding(Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.bottom, Design.Spacing.large)
.adaptiveContentWidth() .adaptiveContentWidth()
} }
.coordinateSpace(name: "todayScroll")
.onPreferenceChange(TodayHeaderMinYKey.self) { headerMinY = $0 }
.background(LinearGradient( .background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary], colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading, startPoint: .topLeading,
@ -99,6 +141,14 @@ struct TodayView: View {
} }
} }
private struct TodayHeaderMinYKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
#Preview { #Preview {
TodayView(store: RitualStore.preview, categoryStore: CategoryStore.preview) TodayView(store: RitualStore.preview, categoryStore: CategoryStore.preview)
} }