Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
1f55b1b1f2
commit
e6a2bce76d
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user