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

This commit is contained in:
Matt Bruce 2026-01-26 18:26:23 -06:00
parent dd9d9315b6
commit 9c57230e55
14 changed files with 188 additions and 28 deletions

View File

@ -78,6 +78,7 @@ struct HistoryDayDetailSheet: View {
.presentationBackground(AppSurface.primary)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationSizing(.form)
}
private var emptyState: some View {

View File

@ -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,7 +89,8 @@ struct HistoryView: View {
// Ritual filter picker
ritualPicker
// Month calendars
// 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,
@ -87,6 +104,7 @@ struct HistoryView: View {
}
)
}
}
.id(refreshToken)
}
.padding(Design.Spacing.large)

View File

@ -74,6 +74,7 @@ struct InsightDetailSheet: View {
}
}
}
.presentationSizing(.form)
}
// MARK: - Header Section

View File

@ -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)

View File

@ -94,6 +94,7 @@ struct RitualDetailView: View {
}
}
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],

View File

@ -4,6 +4,7 @@ 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
@ -11,6 +12,16 @@ struct RitualsView: View {
@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
case past
@ -44,6 +55,7 @@ struct RitualsView: View {
}
.id(refreshToken)
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
@ -137,6 +149,8 @@ struct RitualsView: View {
}
.padding(.top, Design.Spacing.small)
// Use 2-column grid on iPad/landscape
LazyVGrid(columns: ritualColumns, spacing: Design.Spacing.medium) {
ForEach(group.rituals) { ritual in
currentRitualRow(for: ritual)
}
@ -144,6 +158,7 @@ struct RitualsView: View {
}
}
}
}
// MARK: - Past Tab Content
@ -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)
}

View File

@ -96,6 +96,7 @@ struct ArcDetailSheet: View {
)
}
}
.presentationSizing(.form)
}
/// Returns habit completions for a specific date within this arc

View File

@ -58,6 +58,7 @@ struct ArcRenewalSheet: View {
.sheet(isPresented: $showingEditSheet) {
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
.presentationSizing(.form)
}
private var celebrationHeader: some View {

View File

@ -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 {

View File

@ -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 {

View File

@ -120,6 +120,7 @@ struct CategoryEditSheet: View {
}
}
.presentationDetents([.medium])
.presentationSizing(.form)
}
private func loadCategory() {

View File

@ -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()

View File

@ -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] {
@ -20,6 +21,21 @@ struct TodayView: View {
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) {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
@ -34,6 +50,8 @@ struct TodayView: View {
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,
@ -48,7 +66,9 @@ struct TodayView: View {
}
}
}
}
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],

View File

@ -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<Content: View>: 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)
}
}