diff --git a/Andromida/App/Views/History/HistoryMonthView.swift b/Andromida/App/Views/History/HistoryMonthView.swift index de364a8..d08bcdb 100644 --- a/Andromida/App/Views/History/HistoryMonthView.swift +++ b/Andromida/App/Views/History/HistoryMonthView.swift @@ -72,6 +72,16 @@ struct HistoryMonthView: View { .padding(Design.Spacing.medium) .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 weekdayHeader: some View { diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index 7821ef8..e32484a 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -23,6 +23,7 @@ struct HistoryView: View { @State private var monthsToShow = 2 @State private var refreshToken = UUID() @State private var cachedProgressByDate: [Date: Double] = [:] + @State private var headerMinY: CGFloat = 0 private let calendar = Calendar.current 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 /// - Collapsed: Last month + current month (2 months) /// - Expanded: Up to 12 months of history @@ -82,34 +106,51 @@ struct HistoryView: View { var body: some View { ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: Design.Spacing.large) { - // Header with Show More/Less button - headerSection - - // 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) + LazyVStack(alignment: .leading, spacing: Design.Spacing.large, pinnedViews: [.sectionHeaders]) { + Section( + header: headerSection + .padding(.top, Design.Spacing.large) + .padding(.bottom, Design.Spacing.small) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: headerHeight, alignment: .bottom) + .scaleEffect(headerScale, anchor: .leading) + .opacity(headerOpacity) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: HistoryHeaderMinYKey.self, value: proxy.frame(in: .named("historyScroll")).minY) } ) - .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( colors: [AppSurface.primary, AppSurface.secondary], 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 { HistoryView(store: RitualStore.preview) } diff --git a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift index 403300b..618f442 100644 --- a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift +++ b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift @@ -21,34 +21,40 @@ struct TodayRitualSectionView: View { var body: some View { 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 - SectionHeaderView( - title: String(localized: "Habits"), - subtitle: String(localized: "Tap to check in") - ) + VStack(alignment: .leading, spacing: Bedrock.Design.Spacing.large) { + HStack { + 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 { diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index 153fdbe..e24299e 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -5,6 +5,7 @@ struct TodayView: View { @Bindable var store: RitualStore @Bindable var categoryStore: CategoryStore @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var headerMinY: CGFloat = 0 /// Rituals to show now based on current time of day private var todayRituals: [Ritual] { @@ -35,42 +36,83 @@ struct TodayView: View { 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 { ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: Design.Spacing.large) { - TodayHeaderView(dateText: store.todayDisplayString) - - if todayRituals.isEmpty { - if hasRitualsButNotNow { - // Has active rituals but none for current time of day - TodayNoRitualsForTimeView(store: store) + LazyVStack(alignment: .leading, spacing: Design.Spacing.large, pinnedViews: [.sectionHeaders]) { + Section( + header: TodayHeaderView(dateText: store.todayDisplayString) + .padding(.top, Design.Spacing.large) + .padding(.bottom, Design.Spacing.small) + .frame(maxWidth: .infinity, alignment: .leading) + .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 { - // No active rituals at all - TodayEmptyStateView(store: store, categoryStore: categoryStore) - } - } else { - // Use 2-column grid on iPad/landscape when multiple rituals - LazyVGrid(columns: ritualColumns, alignment: .leading, spacing: Design.Spacing.large) { - ForEach(todayRituals) { ritual in - TodayRitualSectionView( - focusTitle: ritual.title, - focusTheme: ritual.theme, - dayLabel: store.ritualDayLabel(for: ritual), - completionSummary: store.completionSummary(for: ritual), - progress: store.ritualProgress(for: ritual), - habitRows: habitRows(for: ritual), - iconName: ritual.iconName, - timeOfDay: ritual.timeOfDay - ) - .frame(maxHeight: .infinity, alignment: .top) + // Use 2-column grid on iPad/landscape when multiple rituals + LazyVGrid(columns: ritualColumns, alignment: .leading, spacing: Design.Spacing.large) { + ForEach(todayRituals) { ritual in + TodayRitualSectionView( + focusTitle: ritual.title, + focusTheme: ritual.theme, + dayLabel: store.ritualDayLabel(for: ritual), + completionSummary: store.completionSummary(for: ritual), + progress: store.ritualProgress(for: ritual), + habitRows: habitRows(for: ritual), + 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() } + .coordinateSpace(name: "todayScroll") + .onPreferenceChange(TodayHeaderMinYKey.self) { headerMinY = $0 } .background(LinearGradient( colors: [AppSurface.primary, AppSurface.secondary], 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 { TodayView(store: RitualStore.preview, categoryStore: CategoryStore.preview) }