diff --git a/Andromida/App/Views/History/HistoryDayDetailSheet.swift b/Andromida/App/Views/History/HistoryDayDetailSheet.swift index 5e1dcb0..f698ab6 100644 --- a/Andromida/App/Views/History/HistoryDayDetailSheet.swift +++ b/Andromida/App/Views/History/HistoryDayDetailSheet.swift @@ -78,6 +78,7 @@ struct HistoryDayDetailSheet: View { .presentationBackground(AppSurface.primary) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) + .presentationSizing(.form) } private var emptyState: some View { diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index 888a404..c92e1c4 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -17,6 +17,7 @@ struct IdentifiableDate: Identifiable { struct HistoryView: View { @Bindable var store: RitualStore + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var selectedRitual: Ritual? @State private var selectedDateItem: IdentifiableDate? @State private var monthsToShow = 2 @@ -27,6 +28,21 @@ struct HistoryView: View { private let baseMonthsToShow = 2 private let monthChunkSize = 6 + /// Whether to use wide layout (2 columns) on iPad/landscape + private var useWideLayout: Bool { + horizontalSizeClass == .regular + } + + /// Grid columns for month cards - 2 columns on regular width, 1 on compact + private var monthColumns: [GridItem] { + AdaptiveColumns.columns( + compactCount: 1, + regularCount: 2, + spacing: Design.Spacing.large, + horizontalSizeClass: horizontalSizeClass + ) + } + /// Generate months based on expanded state /// - Collapsed: Last month + current month (2 months) /// - Expanded: Up to 12 months of history @@ -73,19 +89,21 @@ struct HistoryView: View { // Ritual filter picker ritualPicker - // Month calendars - 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) - } - ) + // 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) + } + ) + } } .id(refreshToken) } diff --git a/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift index b7c6d69..ba9a90d 100644 --- a/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift +++ b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift @@ -74,6 +74,7 @@ struct InsightDetailSheet: View { } } } + .presentationSizing(.form) } // MARK: - Header Section diff --git a/Andromida/App/Views/Onboarding/SetupWizardView.swift b/Andromida/App/Views/Onboarding/SetupWizardView.swift index 05a83d9..fcf562e 100644 --- a/Andromida/App/Views/Onboarding/SetupWizardView.swift +++ b/Andromida/App/Views/Onboarding/SetupWizardView.swift @@ -85,7 +85,7 @@ struct SetupWizardView: View { .padding(.horizontal, Design.Spacing.large) } - // Content + // Content - constrained width on iPad for better readability Group { switch currentStep { case .welcome: @@ -128,6 +128,7 @@ struct SetupWizardView: View { WhatsNextStepView(onComplete: onComplete) } } + .adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait) .transition(.asymmetric( insertion: .move(edge: .trailing).combined(with: .opacity), removal: .move(edge: .leading).combined(with: .opacity) diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index f3440bb..1bb6f71 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -94,6 +94,7 @@ struct RitualDetailView: View { } } .padding(Design.Spacing.large) + .adaptiveContentWidth() } .background(LinearGradient( colors: [AppSurface.primary, AppSurface.secondary], diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index 7ce7e51..ab51bfb 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -4,12 +4,23 @@ import Bedrock struct RitualsView: View { @Bindable var store: RitualStore @Bindable var categoryStore: CategoryStore + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var selectedTab: RitualsTab = .current @State private var showingPresetLibrary = false @State private var showingCreateRitual = false @State private var ritualToDelete: Ritual? @State private var ritualToRestart: Ritual? @State private var refreshToken = UUID() + + /// Grid columns for ritual cards - 2 columns on regular width, 1 on compact + private var ritualColumns: [GridItem] { + AdaptiveColumns.columns( + compactCount: 1, + regularCount: 2, + spacing: Design.Spacing.medium, + horizontalSizeClass: horizontalSizeClass + ) + } enum RitualsTab: String, CaseIterable { case current @@ -44,6 +55,7 @@ struct RitualsView: View { } .id(refreshToken) .padding(Design.Spacing.large) + .adaptiveContentWidth() } .background(LinearGradient( colors: [AppSurface.primary, AppSurface.secondary], @@ -137,8 +149,11 @@ struct RitualsView: View { } .padding(.top, Design.Spacing.small) - ForEach(group.rituals) { ritual in - currentRitualRow(for: ritual) + // Use 2-column grid on iPad/landscape + LazyVGrid(columns: ritualColumns, spacing: Design.Spacing.medium) { + ForEach(group.rituals) { ritual in + currentRitualRow(for: ritual) + } } } } @@ -152,7 +167,8 @@ struct RitualsView: View { if store.pastRituals.isEmpty { pastEmptyState } else { - VStack(spacing: Design.Spacing.medium) { + // Use 2-column grid on iPad/landscape + LazyVGrid(columns: ritualColumns, spacing: Design.Spacing.medium) { ForEach(store.pastRituals) { ritual in pastRitualRow(for: ritual) } diff --git a/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift b/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift index 10bad7b..3b77f10 100644 --- a/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift @@ -96,6 +96,7 @@ struct ArcDetailSheet: View { ) } } + .presentationSizing(.form) } /// Returns habit completions for a specific date within this arc diff --git a/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift b/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift index 85d73e8..2cbe4ab 100644 --- a/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/ArcRenewalSheet.swift @@ -58,6 +58,7 @@ struct ArcRenewalSheet: View { .sheet(isPresented: $showingEditSheet) { RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual) } + .presentationSizing(.form) } private var celebrationHeader: some View { diff --git a/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift b/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift index 6bfa67c..d6d933e 100644 --- a/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift @@ -47,6 +47,7 @@ struct PresetLibrarySheet: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationSizing(.form) } // MARK: - Category Picker @@ -196,6 +197,7 @@ struct PresetDetailSheet: View { } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) + .presentationSizing(.form) } private var headerSection: some View { diff --git a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift index 323aea2..43e3e65 100644 --- a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift @@ -93,6 +93,7 @@ struct RitualEditSheet: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationSizing(.form) } // MARK: - Form Sections @@ -556,6 +557,7 @@ struct IconPickerSheet: View { } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) + .presentationSizing(.form) } private func iconButton(_ icon: String) -> some View { @@ -672,6 +674,7 @@ struct HabitIconPickerSheet: View { } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) + .presentationSizing(.form) } private func iconButton(_ icon: String) -> some View { diff --git a/Andromida/App/Views/Settings/CategoryEditSheet.swift b/Andromida/App/Views/Settings/CategoryEditSheet.swift index 09c321c..cc10070 100644 --- a/Andromida/App/Views/Settings/CategoryEditSheet.swift +++ b/Andromida/App/Views/Settings/CategoryEditSheet.swift @@ -120,6 +120,7 @@ struct CategoryEditSheet: View { } } .presentationDetents([.medium]) + .presentationSizing(.form) } private func loadCategory() { diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index 4cd0bea..091ca57 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -156,6 +156,7 @@ struct SettingsView: View { Spacer(minLength: Design.Spacing.xxxLarge) } .padding(.horizontal, Design.Spacing.large) + .adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait) } .onAppear { store.refresh() diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index ca8fc3c..42471de 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -4,6 +4,7 @@ import Bedrock struct TodayView: View { @Bindable var store: RitualStore @Bindable var categoryStore: CategoryStore + @Environment(\.horizontalSizeClass) private var horizontalSizeClass /// Rituals to show now based on current time of day private var todayRituals: [Ritual] { @@ -19,6 +20,21 @@ struct TodayView: View { private var showRenewalSheet: Bool { store.ritualNeedingRenewal != nil } + + /// Whether to use wide layout on iPad/landscape + private var useWideLayout: Bool { + horizontalSizeClass == .regular + } + + /// Grid columns for ritual sections - 2 columns on regular width when multiple rituals + private var ritualColumns: [GridItem] { + AdaptiveColumns.columns( + compactCount: 1, + regularCount: todayRituals.count > 1 ? 2 : 1, + spacing: Design.Spacing.large, + horizontalSizeClass: horizontalSizeClass + ) + } var body: some View { ScrollView(.vertical, showsIndicators: false) { @@ -34,21 +50,25 @@ struct TodayView: View { TodayEmptyStateView(store: store, categoryStore: categoryStore) } } else { - 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 - ) + // 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 + ) + } } } } .padding(Design.Spacing.large) + .adaptiveContentWidth() } .background(LinearGradient( colors: [AppSurface.primary, AppSurface.secondary], diff --git a/Andromida/Shared/AdaptiveLayout.swift b/Andromida/Shared/AdaptiveLayout.swift new file mode 100644 index 0000000..f78325a --- /dev/null +++ b/Andromida/Shared/AdaptiveLayout.swift @@ -0,0 +1,93 @@ +// +// AdaptiveLayout.swift +// Andromida +// +// Reusable layout modifiers and helpers for responsive iPad/iPhone layouts. +// + +import SwiftUI +import Bedrock + +// MARK: - Adaptive Content Width Modifier + +/// A view modifier that constrains content width on regular-width displays (iPad, large iPhone landscape). +/// On compact-width displays (iPhone portrait), content remains full-width. +struct AdaptiveContentWidth: ViewModifier { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + let maxWidth: CGFloat + let centerContent: Bool + + init(maxWidth: CGFloat = Design.Size.maxContentWidthLandscape, centerContent: Bool = true) { + self.maxWidth = maxWidth + self.centerContent = centerContent + } + + func body(content: Content) -> some View { + if horizontalSizeClass == .regular { + if centerContent { + content + .frame(maxWidth: maxWidth) + .frame(maxWidth: .infinity) + } else { + content + .frame(maxWidth: maxWidth) + } + } else { + content + } + } +} + +extension View { + /// Constrains the view's width on regular-width displays (iPad, large iPhone landscape). + /// - Parameters: + /// - maxWidth: The maximum width to constrain to. Defaults to `Design.Size.maxContentWidthLandscape` (800pt). + /// - centered: Whether to center the content horizontally. Defaults to `true`. + /// - Returns: A view with adaptive width constraints applied. + func adaptiveContentWidth( + maxWidth: CGFloat = Design.Size.maxContentWidthLandscape, + centered: Bool = true + ) -> some View { + modifier(AdaptiveContentWidth(maxWidth: maxWidth, centerContent: centered)) + } +} + +// MARK: - Adaptive Grid Columns + +/// Helper for creating responsive grid columns based on horizontal size class. +enum AdaptiveColumns { + /// Creates grid columns that adapt based on horizontal size class. + /// - Parameters: + /// - compactCount: Number of columns for compact width (iPhone portrait). Defaults to 1. + /// - regularCount: Number of columns for regular width (iPad, landscape). Defaults to 2. + /// - spacing: Spacing between columns. Defaults to `Design.Spacing.medium`. + /// - horizontalSizeClass: The current horizontal size class. + /// - Returns: An array of `GridItem` configured for the given size class. + static func columns( + compactCount: Int = 1, + regularCount: Int = 2, + spacing: CGFloat = Design.Spacing.medium, + horizontalSizeClass: UserInterfaceSizeClass? + ) -> [GridItem] { + let count = horizontalSizeClass == .regular ? regularCount : compactCount + return Array(repeating: GridItem(.flexible(), spacing: spacing), count: count) + } +} + +// MARK: - Use Wide Layout Helper + +/// Environment-based helper view that provides layout decisions. +struct AdaptiveLayoutReader: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + let content: (_ useWideLayout: Bool) -> Content + + init(@ViewBuilder content: @escaping (_ useWideLayout: Bool) -> Content) { + self.content = content + } + + var body: some View { + content(horizontalSizeClass == .regular) + } +}