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) .presentationBackground(AppSurface.primary)
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationSizing(.form)
} }
private var emptyState: some View { private var emptyState: some View {

View File

@ -17,6 +17,7 @@ struct IdentifiableDate: Identifiable {
struct HistoryView: View { struct HistoryView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedRitual: Ritual? @State private var selectedRitual: Ritual?
@State private var selectedDateItem: IdentifiableDate? @State private var selectedDateItem: IdentifiableDate?
@State private var monthsToShow = 2 @State private var monthsToShow = 2
@ -27,6 +28,21 @@ struct HistoryView: View {
private let baseMonthsToShow = 2 private let baseMonthsToShow = 2
private let monthChunkSize = 6 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 /// 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
@ -73,19 +89,21 @@ struct HistoryView: View {
// Ritual filter picker // Ritual filter picker
ritualPicker ritualPicker
// Month calendars // Month calendars - 2-column grid on iPad/landscape
ForEach(months, id: \.self) { month in LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) {
HistoryMonthView( ForEach(months, id: \.self) { month in
month: month, HistoryMonthView(
selectedRitual: selectedRitual, month: month,
completionRate: { date, ritual in selectedRitual: selectedRitual,
let day = calendar.startOfDay(for: date) completionRate: { date, ritual in
return cachedProgressByDate[day] ?? store.completionRate(for: day, ritual: ritual) let day = calendar.startOfDay(for: date)
}, return cachedProgressByDate[day] ?? store.completionRate(for: day, ritual: ritual)
onDayTapped: { date in },
selectedDateItem = IdentifiableDate(date: date) onDayTapped: { date in
} selectedDateItem = IdentifiableDate(date: date)
) }
)
}
} }
.id(refreshToken) .id(refreshToken)
} }

View File

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

View File

@ -85,7 +85,7 @@ struct SetupWizardView: View {
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
} }
// Content // Content - constrained width on iPad for better readability
Group { Group {
switch currentStep { switch currentStep {
case .welcome: case .welcome:
@ -128,6 +128,7 @@ struct SetupWizardView: View {
WhatsNextStepView(onComplete: onComplete) WhatsNextStepView(onComplete: onComplete)
} }
} }
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
.transition(.asymmetric( .transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity), insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity) removal: .move(edge: .leading).combined(with: .opacity)

View File

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

View File

@ -4,6 +4,7 @@ import Bedrock
struct RitualsView: View { struct RitualsView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore @Bindable var categoryStore: CategoryStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedTab: RitualsTab = .current @State private var selectedTab: RitualsTab = .current
@State private var showingPresetLibrary = false @State private var showingPresetLibrary = false
@State private var showingCreateRitual = false @State private var showingCreateRitual = false
@ -11,6 +12,16 @@ struct RitualsView: View {
@State private var ritualToRestart: Ritual? @State private var ritualToRestart: Ritual?
@State private var refreshToken = UUID() @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 { enum RitualsTab: String, CaseIterable {
case current case current
case past case past
@ -44,6 +55,7 @@ struct RitualsView: View {
} }
.id(refreshToken) .id(refreshToken)
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.adaptiveContentWidth()
} }
.background(LinearGradient( .background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary], colors: [AppSurface.primary, AppSurface.secondary],
@ -137,8 +149,11 @@ struct RitualsView: View {
} }
.padding(.top, Design.Spacing.small) .padding(.top, Design.Spacing.small)
ForEach(group.rituals) { ritual in // Use 2-column grid on iPad/landscape
currentRitualRow(for: ritual) 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 { if store.pastRituals.isEmpty {
pastEmptyState pastEmptyState
} else { } 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 ForEach(store.pastRituals) { ritual in
pastRitualRow(for: ritual) pastRitualRow(for: ritual)
} }

View File

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

View File

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

View File

@ -47,6 +47,7 @@ struct PresetLibrarySheet: View {
} }
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationSizing(.form)
} }
// MARK: - Category Picker // MARK: - Category Picker
@ -196,6 +197,7 @@ struct PresetDetailSheet: View {
} }
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationSizing(.form)
} }
private var headerSection: some View { private var headerSection: some View {

View File

@ -93,6 +93,7 @@ struct RitualEditSheet: View {
} }
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationSizing(.form)
} }
// MARK: - Form Sections // MARK: - Form Sections
@ -556,6 +557,7 @@ struct IconPickerSheet: View {
} }
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationSizing(.form)
} }
private func iconButton(_ icon: String) -> some View { private func iconButton(_ icon: String) -> some View {
@ -672,6 +674,7 @@ struct HabitIconPickerSheet: View {
} }
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationSizing(.form)
} }
private func iconButton(_ icon: String) -> some View { private func iconButton(_ icon: String) -> some View {

View File

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

View File

@ -156,6 +156,7 @@ struct SettingsView: View {
Spacer(minLength: Design.Spacing.xxxLarge) Spacer(minLength: Design.Spacing.xxxLarge)
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
} }
.onAppear { .onAppear {
store.refresh() store.refresh()

View File

@ -4,6 +4,7 @@ import Bedrock
struct TodayView: View { struct TodayView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore @Bindable var categoryStore: CategoryStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// 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] {
@ -20,6 +21,21 @@ struct TodayView: View {
store.ritualNeedingRenewal != nil 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 { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { VStack(alignment: .leading, spacing: Design.Spacing.large) {
@ -34,21 +50,25 @@ struct TodayView: View {
TodayEmptyStateView(store: store, categoryStore: categoryStore) TodayEmptyStateView(store: store, categoryStore: categoryStore)
} }
} else { } else {
ForEach(todayRituals) { ritual in // Use 2-column grid on iPad/landscape when multiple rituals
TodayRitualSectionView( LazyVGrid(columns: ritualColumns, alignment: .leading, spacing: Design.Spacing.large) {
focusTitle: ritual.title, ForEach(todayRituals) { ritual in
focusTheme: ritual.theme, TodayRitualSectionView(
dayLabel: store.ritualDayLabel(for: ritual), focusTitle: ritual.title,
completionSummary: store.completionSummary(for: ritual), focusTheme: ritual.theme,
progress: store.ritualProgress(for: ritual), dayLabel: store.ritualDayLabel(for: ritual),
habitRows: habitRows(for: ritual), completionSummary: store.completionSummary(for: ritual),
iconName: ritual.iconName, progress: store.ritualProgress(for: ritual),
timeOfDay: ritual.timeOfDay habitRows: habitRows(for: ritual),
) iconName: ritual.iconName,
timeOfDay: ritual.timeOfDay
)
}
} }
} }
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.adaptiveContentWidth()
} }
.background(LinearGradient( .background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary], 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)
}
}