diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index a2240d4..eeaab5e 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -61,15 +61,16 @@ struct CompactRootView: View { var body: some View { TabView(selection: $tab) { - // The inset is applied per tab (not to the `TabView`): a bottom `safeAreaInset` on the - // container does NOT propagate into the tab pages' scroll content, so the list would run - // under the bar. On each tab's `NavigationStack` it reaches the scroll content and pushes - // it up above the bar. - HomeView().modifier(TabBarInset(height: contentInset)).tag(0) - SessionsView().modifier(TabBarInset(height: contentInset)).tag(1) - ProjectsView().modifier(TabBarInset(height: contentInset)).tag(2) - SettingsView().modifier(TabBarInset(height: contentInset)).tag(3) + HomeView().toolbar(.hidden, for: .tabBar).tag(0) + SessionsView().toolbar(.hidden, for: .tabBar).tag(1) + ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) + SettingsView().toolbar(.hidden, for: .tabBar).tag(3) } + // Each tab's scroll view reads this and reserves room for the floating bar directly on + // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on + // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied + // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. + .environment(\.compactTabBarInset, contentInset) // The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels // continuously off the bottom of the screen (a fixed distance past the home-indicator curve) // instead of a transition that removes it mid-descent and makes it pop. @@ -126,18 +127,34 @@ private struct BarHeightKey: PreferenceKey { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } -/// Per-tab chrome for the compact shell: suppress the system tab bar (we draw our own) and reserve -/// `height` at the bottom of this tab's content so its scroll view lays out above the floating bar. -/// Applied to each tab rather than the `TabView`, whose container-level inset doesn't reach the tab -/// pages' scroll content. -private struct TabBarInset: ViewModifier { - let height: CGFloat +/// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll +/// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). +private struct CompactTabBarInsetKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + var compactTabBarInset: CGFloat { + get { self[CompactTabBarInsetKey.self] } + set { self[CompactTabBarInsetKey.self] = newValue } + } +} + +extension View { + /// Reserve room for the compact shell's floating tab bar at the bottom of a scroll view, so its + /// content clears the bar when scrolled to the end. Apply this directly to the `List`/`ScrollView` + /// inside a tab — a bottom `safeAreaInset` on the scroll view itself reliably insets its content, + /// unlike one on the tab root, which the `TabView` doesn't forward. A no-op at regular width + /// (iPad), where the environment inset is 0. + func compactTabBarInset() -> some View { modifier(CompactTabBarInsetReader()) } +} + +private struct CompactTabBarInsetReader: ViewModifier { + @Environment(\.compactTabBarInset) private var inset func body(content: Content) -> some View { - content - .toolbar(.hidden, for: .tabBar) - .safeAreaInset(edge: .bottom, spacing: 0) { - Color.clear.frame(height: height) - } + content.safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: inset) + } } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 61f1022..2ed6bd6 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -105,6 +105,7 @@ struct HomeView: View { } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) + .compactTabBarInset() .scrollDismissesKeyboard(.interactively) .refreshable { store.refreshSessions() } .onChange(of: focusNewChat.wrappedValue) { _, want in diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 1cb4845..23f85e9 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -35,6 +35,7 @@ struct ProjectsView: View { } } } + .compactTabBarInset() } } .navigationTitle("Projects") @@ -71,6 +72,7 @@ struct ProjectDetailView: View { .navigationTitle(project.name) .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) + .compactTabBarInset() // A floating "+" scoped to this project — the glass composer opens pre-locked to it. Only // at regular width; on compact the shared tab-bar "+" (project picker) covers new chats. .newChatAffordance(project: project, isPresented: $showNewChat, enabled: sizeClass == .regular) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 05101a8..5fc3328 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -59,6 +59,7 @@ struct SessionsView: View { } .listStyle(.insetGrouped) .refreshable { store.refreshSessions() } + .compactTabBarInset() } } .navigationTitle("Sessions") diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 1c68dcf..1278510 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -158,6 +158,7 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .compactTabBarInset() .sheet(isPresented: $showScanner) { PairingScannerView { payload in showScanner = false