flux branch worktree-T-1150-ipad-adaptive-layout commits 7 unpushed files 23 touched lines +2224 / -157

Pre-push review: iPad adaptive layout (T-1150)

Five-phase spec implementation: sidebar shell on iPad at regular size class, adaptive multi-column layouts on Dashboard / History / Day Detail, Settings width cap, midnight rollover on the Today entry. iPhone V5 shell and macOS shell unchanged. Includes the post-review fix commit.

At a glance

  • 5 commits implement the spec (phases 1–5) plus 1 review-fix commit applying findings from this review.
  • Two new shells coexist on iPad: FluxiPadRoot at regular width, the existing FluxiOSRoot at compact width. Gated by IPadLayoutGate.isActive(hSizeClass:) — single source of truth, no drift across the five callsites.
  • View-models hoisted into AppNavigationView so cached fetch state and refresh timers survive a size-class flip (AC 6.4).
  • Today entry midnight rollover via DayDetailViewModel.setDate(_:) + 60-second Task.sleep loop + scenePhase → .active recompute. View-level transient state (chart highlight, scroll, note draft) is preserved.
  • iPhone and macOS unchanged by construction: the iPad regular layouts gate on userInterfaceIdiom == .pad && hSizeClass == .regular, and the macOS shell never sees the new code path.

Verdict

Ready to push (with manual smoke check before merge)

iOS and macOS builds pass. All four new test suites (AdaptiveColumnsLayoutTests, ScreenTests, SidebarTabSyncTests, DayDetailViewModelSetDateTests) pass. Lint is clean for every file touched by this branch. Two pre-existing flaky tests fail only under parallel test mode and pass in isolation. The four-agent pre-push review surfaced five major issues — all five are fixed in commit 833aa83. AC 9.1–9.3 (iPad simulator device coverage matrix) is still _to verify_; that's the manual smoke check Task 21 calls for and is the final gate before merging.

Review findings

12 raised · 9 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Before this branch, opening Flux on iPad showed the iPhone layout stretched across the whole screen — a tall, narrow column of content with a tab bar at the bottom, the same on a 13-inch iPad Pro as on an iPhone. iPad users had a lot of empty whitespace and could only see one card at a time.

After this branch, on iPad with enough screen width Flux now shows a sidebar on the left (Dashboard / Today / History) and the selected screen on the right, and the screens themselves rearrange their cards into two or three columns so the wider screen actually gets used. The iPhone layout is completely unchanged. When the iPad is split-screen with another app and gets very narrow, Flux falls back to the iPhone layout automatically.

Why it matters

The app was already built for iPad — you could install it from the App Store — but it didn't feel like an iPad app. Users with a battery system and an iPad would naturally want to glance at the dashboard or compare yesterday and today on a bigger screen, and the cramped iPhone-on-iPad layout discouraged that. The change brings Flux in line with how Apple's own apps (Mail, Notes, Settings) lay themselves out on iPad.

Key concepts

  • Size class — Apple's name for “is the window roughly phone-shaped or tablet-shaped right now”. Doesn't mean “is this device a phone or a tablet” — a Slide Over window on iPad is phone-shaped even though the device is a tablet, and an iPhone Plus in landscape is tablet-shaped even though the device is a phone. Flux uses the size class together with the actual device type to decide which layout to show.
  • Sidebar shell — Apple's standard pattern for tablet apps: a list of destinations on the left, the chosen destination's content on the right.
  • Adaptive layout — The same screen shows different layouts depending on how much space is available, instead of one fixed design.
  • Today entry — A sidebar shortcut to today's day detail. When midnight passes while the iPad is sitting on this entry, the date updates automatically — you don't have to tap away and tap back.

Changes overview

  • New FluxiPadRoot.swift — a NavigationSplitView(.balanced) with the sidebar bound to selectedScreen: Screen? and the detail column carrying a NavigationStack(path: $navigationPath). Settings is opened from a .primaryAction toolbar gear, not from the sidebar.
  • AppNavigationView.iOSRoot gates between FluxiPadRoot and the existing FluxiOSRoot via IPadLayoutGate.isActive(hSizeClass:). That helper combines UIDevice.current.userInterfaceIdiom == .pad with hSizeClass == .regular — the idiom check is load-bearing because iPhone Plus/Max landscape reports .regular horizontal but must keep the tab-bar shell (AC 7.1).
  • AppNavigationView now owns the DashboardViewModel, HistoryViewModel, and a todayDayDetailViewModel as @State instead of letting the inner shells build them. This means cached fetch state and refresh timers survive a size-class flip from iPad sidebar shell to iPhone-fallback shell and back (AC 6.4).
  • A new DayDetailViewModel.setDate(_:) swaps the date in place and clears per-day fields rather than rebuilding the view via .id(today) — chart highlight, scroll position, and note-editor draft are preserved across midnight rollover. A 60-second .task loop and a scenePhase → .active recompute both call this when the local date changes.
  • Per-screen adaptive layout via AdaptiveColumnsLayout. It uses a LazyVGrid whose column count comes from columnCount(width:typeSize:): 1 column below 700pt, 2 below 1000pt, 3 above; one column collapses at Dynamic Type ≥ .accessibility4 per AC 8.2. Width is measured via onGeometryChange; the initial seed is the 2-column tier so iPad screens don't briefly render as a single column on first appearance.
  • Two pure helpers (mappedIosTab(for:currentTab:) and mappedSelectedScreen(for:currentSelection:)) drive the bidirectional sidebar ↔ tab sync via two onChange handlers in AppNavigationView. .settings selection is preserved in the tab → screen direction so a tab change doesn't knock the user out of Settings.

Implementation approach

  • View-model hoisting — moves ownership up one level so the VM's @State identity outlives the swap between FluxiPadRoot and FluxiOSRoot. Both shells accept VMs via init.
  • Credential fingerprintingreloadDependencies() rebuilds the three hoisted VMs only when the URL+token fingerprint actually changes. Without this, makeAPIClient() returns a fresh URLSessionAPIClient every call and reference-identity comparison would always rebuild — defeating the hoisting work.
  • Two-handler sidebar↔tab sync — each onChange handler runs a pure helper that maps from the trigger side to the other side, with a before-write comparison to break feedback loops. The pure helpers are unit-tested in isolation; the production handlers are thin wrappers.
  • AdaptiveColumnsLayout as the single reflow primitive — used in three places (Dashboard hero+trio row, Dashboard battery row, History card grid). Day Detail uses a hand-built two-column Grid because its left/right columns are heterogeneous (summary panels vs charts).

Trade-offs

  • Sidebar over keeping the tab bar — matches macOS, cleaner on iPad Pro, but means two navigation shells must stay in sync during size-class transitions. Decision 2.
  • .balanced over .prominentDetail — sidebar can coexist with detail on iPad Pro and auto-hides on iPad mini portrait — one style covers the whole iPad lineup without per-device branches. Decision 5.
  • setDate(_:) over .id(today) rebuild — preserves transient UI state (chart highlight, scroll, note draft) at the cost of an extra mutating method on the VM. Decision 5.
  • Manual smoke check deferred — Task 21's iPad simulator matrix is recorded as _to verify_ rather than executed. Automated build / lint / test passes cover code correctness; device coverage is the final gate.

Technical deep dive

The interesting work is in the seams. The gate at the top of AppNavigationView.iOSRoot selects between FluxiPadRoot and FluxiOSRoot based on IPadLayoutGate.isActive(hSizeClass:). This check fires on every horizontal-size-class flip, so the view tree literally tears down one shell and stands up the other — a @State-owned VM inside the unmounted shell would be lost. The fix is to lift the three always-live VMs (Dashboard, History, Today-DayDetail) into AppNavigationView, which stays mounted across the flip. FluxiPadRoot and FluxiOSRoot both accept these VMs via init, so they get the same instances regardless of which shell is currently rendering.

The Today entry has a midnight-rollover problem unique to a sidebar shell: on iPhone the Today tab rebuilds on every selection, so the date is recomputed naturally; on iPad the user may sit on Today across midnight. DayDetailViewModel.setDate(_:) swaps the date in place, clears the per-day fields (readings, parsedReadings, summary, peakPeriods, dailyUsage, note, offpeakStats, comparisonState), cancels any in-flight comparisonTask, and reloads. The VM's transient view-level state (note editor sheet, chart highlight, scroll position) is preserved because the VM identity survives the swap. The rollover is driven by a 60-second Task.sleep loop plus a scenePhase → .active recompute — the latter catches the case where the user backgrounds the app overnight and returns the next morning.

reloadDependencies() had a subtle bug pre-review. The original guard was (apiClient as AnyObject?) !== (client as AnyObject?), which is always true after the first call because makeAPIClient() constructs a fresh URLSessionAPIClient every invocation — the comparison is between two distinct class instances. This meant every scenePhase → .active rebuilt all three hoisted VMs, discarding cached fetch state and resetting in-flight refresh timers on every foreground, in direct violation of AC 6.4. The fix is to fingerprint the credentials (apiURL|token joined into a single string) and only rebuild when that string changes.

The bidirectional sidebar ↔ tab sync is hard to model with a single pure reducer because the canonical pair depends on which side just changed. The branch ships two trigger-aware helpers — mappedIosTab(for:currentTab:) and mappedSelectedScreen(for:currentSelection:) — and the production onChange handlers each call one of them. Both helpers preserve .settings (which has no FluxTab counterpart), and both handlers guard against feedback loops by comparing the computed value to the current value before writing.

AdaptiveColumnsLayout is the only multi-column primitive on the branch. It uses LazyVGrid rather than the Swift Layout protocol because LazyVGrid interoperates cleanly with the existing ScrollView-based screen layouts. The trade-off is the one-frame mismatch on first appearance: measuredWidth starts at the seeded value (700pt = 2-column tier) and reflows when onGeometryChange delivers the real width. With the original 0 seed, every iPad screen flashed as a 1-column layout for one frame before snapping to 2 or 3 columns. The 700 seed means the worst case is a brief 2-col → 3-col reflow on iPad Pro 13″ landscape, which is much less obtrusive.

Day Detail's regular layout renders two cards (DailyUsageCard, PeakUsageCard) that the compact layout doesn't — the data was already in the view-model (viewModel.dailyUsage, viewModel.peakPeriods) but never surfaced in a dedicated card. The regular layout splits the previously-combined battery+SOC chart into two panels because the chart column has enough vertical room to render them separately.

Architecture impact

The branch establishes a pattern that future platform additions (Vision Pro, larger Stage Manager configurations) can adopt without restructuring: a single gate (IPadLayoutGate.isActive) chooses between shells, and per-screen content branches off horizontalSizeClass. Adding a new size-class layout means adding one branch in each of three files (Dashboard, History, Day Detail), not rewriting any of the view-models or shells.

VM hoisting into AppNavigationView makes the root view the single owner of global session state — credentials, today's date, view-models. This is heavier than the previous model where shells owned their own view-models, but it's the only way to make state survive a shell teardown. Future expansion (e.g. an iPad-only fourth screen) would add a new VM hoist alongside the existing three.

The syncedState refactor into two trigger-aware helpers narrows what production code is doing to exactly what the unit tests cover — previously the reducer was tested but production reimplemented the logic inline. Test coverage and production behaviour are now the same.

Potential issues to monitor

  • Slide Over / narrow Split View round-trips — a user alternating between configurations rapidly will see the shell flip repeatedly. AC 6.4 says VM identity must survive; AC 6.3 says in-flight Day Detail pushed from History may be dropped. Watch for navigation-stack inconsistencies that aren't reproducible from a fresh launch.
  • Dynamic Type at AX5AdaptiveColumnsLayout collapses one column at ≥ .accessibility4. The cards themselves must remain non-clipping at AX5 (AC 8.1); visual verification in the simulator at .dynamicTypeSize(.accessibility5) is part of Task 21 and is currently _to verify_.
  • iPad keychain protection classkeychainService.loadToken() may return nil on a freshly-booted device that hasn't been unlocked. The credentialFingerprint will be nil, reloadDependencies() will route to SettingsView. After unlock, scenePhase → .active re-runs reloadDependencies() and the VMs are constructed. Worth a smoke test on fresh iPad boot.
  • Detail-column width measurements — the 700/1000 breakpoints are hypothesised. Task 21's verification matrix has placeholders for actual measurements on iPad mini / Air / Pro 13″; only manual verification will catch a layout that's 1-col where 2-col was expected, or vice versa.

Important changes — detailed

Central iPad-regular gate (IPadLayoutGate)

Flux-Helpers-IPadLayoutGate.swift

Why it matters. The `userInterfaceIdiom == .pad && hSizeClass == .regular` predicate is the load-bearing AC 7.1 guard — iPhone Plus/Max in landscape reports `.regular` horizontal size class and must NOT route to the iPad shell. Before the review fix, five copies of this predicate lived across AppNavigationView, DashboardView, HistoryView, DayDetailView, and SettingsView. Drift between any two copies would silently regress AC 7.1.

What to look at. Flux/Flux/Helpers/IPadLayoutGate.swift (new, 22 lines)

Takeaway. When a regression-guard predicate is the sole defense against a documented requirement, it must live in one place. Five copies might survive code review at landing time but will rot the first time someone adjusts one for a new device class.
Rationale. All callsites in the diff comment-reference `AppNavigationView.usesPadShell` as the canonical version; making that reference explicit by extracting the predicate is the next logical step.

View-model hoist + credential fingerprint

Flux-Navigation-AppNavigationView.swift

Why it matters. AC 6.4 requires cached fetch state and in-flight refresh timers to survive an iPad size-class flip between the sidebar shell and the iPhone-fallback shell. The fix is two-part: (1) hoist Dashboard / History / Today-DayDetail VMs into AppNavigationView's @State so they outlive the shell teardown, and (2) rebuild them only when credentials actually change — not on every foreground.

What to look at. Flux/Flux/Navigation/AppNavigationView.swift — VM hoist, credentialFingerprint state, reloadDependencies() rewrite (lines 14-19, 223-271)

Takeaway. Reference-identity comparison for objects that factory-functions hand back fresh on every call is a no-op compare. Use a value-derived fingerprint when the identity question is really `did the configuration change?`.
Rationale. Decision 5 picked `setDate(_:)` over `.id(today)` to preserve transient UI state across midnight rollover; that decision implicitly relies on the VM surviving shell flips too. The hoist makes that explicit; the fingerprint makes it correct. (inferred — not stated by the author)

Two trigger-aware sidebar↔tab helpers

Flux-Navigation-SidebarTabSync.swift

Why it matters. The bidirectional sidebar ↔ tab sync runs in two onChange handlers — one for each direction. A single symmetric `syncedState(selected, tab)` reducer cannot work because the canonical output pair depends on which side just changed; the pre-review version was a dead reducer that the unit tests covered while production reimplemented the logic inline.

What to look at. Flux/Flux/Navigation/SidebarTabSync.swift — mappedIosTab + mappedSelectedScreen helpers; AppNavigationView.swift:45-63 — onChange handlers route through them

Takeaway. When a pure-function refactor doesn't fit the asymmetry of its callsites, write two pure functions instead of forcing one. Production and tests should exercise the same code path.
Rationale. The four-agent pre-push review flagged that the symmetric reducer was tested but never called. Splitting into trigger-aware helpers keeps the pure-function testability while letting production actually use the helpers.

DayDetailViewModel.setDate(_:) for midnight rollover

Flux-DayDetail-DayDetailViewModel.swift

Why it matters. Today entry on iPad must update when the local date crosses midnight. The naive approach (.id(today) on the view) discards every transient piece of view state — chart highlight, scroll position, note-editor draft. setDate(_:) mutates the existing VM in place, clears only the per-day fields, and reloads.

What to look at. Flux/Flux/DayDetail/DayDetailViewModel.swift:86-103

Takeaway. View-level state lives in the view; model state lives in the model. When 'today' rolls over, only the model's per-day fields are stale — preserve the view by mutating the model rather than rebuilding the view.
Rationale. Decision 5 explicitly weighs the `.id(today)` rebuild approach against this method and picks setDate as the better trade-off.

AdaptiveColumnsLayout reflow primitive

Flux-Helpers-AdaptiveColumnsLayout.swift

Why it matters. The single reusable widget that implements the 1/2/3-column reflow rule (700pt and 1000pt breakpoints) plus the Dynamic Type AX4+ collapse rule (AC 8.2). Used in three places on Dashboard and History; Day Detail uses a hand-built Grid because its two columns are heterogeneous.

What to look at. Flux/Flux/Helpers/AdaptiveColumnsLayout.swift (new, 53 lines); column-count logic at lines 36-49

Takeaway. Seed measured-width state at a sensible default (here, the 2-column tier) when the consumer guarantees the layout only runs in a known-wide context. Avoids a one-frame collapse to 1 column before `onGeometryChange` delivers the real width.
Rationale. The initial `measuredWidth = 0` was the obvious default but produced a visible flash on every iPad screen appearance. Seeding at 700 is correct because AdaptiveColumnsLayout is gated behind IPadLayoutGate.

FluxiPadRoot — sidebar + detail shell

Flux-Navigation-FluxiPadRoot.swift

Why it matters. The iPad regular-size-class shell. NavigationSplitView(.balanced) lets the sidebar coexist with the detail on iPad Pro and auto-hide on iPad mini portrait without per-device branching. Settings stays as a sheet, opened via .primaryAction toolbar gear in the detail column.

What to look at. Flux/Flux/Navigation/FluxiPadRoot.swift (new, 90 lines)

Takeaway. When introducing a second navigation shell that coexists with an existing one, accept the cross-shell state via init rather than letting the new shell own anything. The owning view (here AppNavigationView) becomes the single source of truth for selection and view-models.
Rationale. Decision 5 picked .balanced over .prominentDetail and .automatic for deterministic breakpoint math across the iPad lineup.

Per-screen adaptive content branches

Flux-Dashboard-DashboardView.swift

Why it matters. DashboardView, HistoryView, and DayDetailView each gain a `usesRegularLayout`-gated branch into `dashboardContentRegular` / `historyContentRegular` / `dayDetailContentRegular`. The compact path is bit-identical to the pre-branch single-column layout (AC 7.1/7.2/7.3).

What to look at. Dashboard/DashboardView.swift dashboardContentRegular (~lines 92-130); History/HistoryView.swift historyContentRegular (~lines 134-163); DayDetail/DayDetailView.swift dayDetailContentRegular (~lines 130-185)

Takeaway. When adding adaptive layouts to existing screens, factor out per-panel @ViewBuilder properties so both compact and regular branches share the same data path. Only the layout container differs; the panels themselves are identical.
Rationale. Spec tasks 17-19 prescribe this factoring directly; the implementation matches the design's ASCII layouts in design.md lines 263-321.

Key decisions

Sidebar shell at regular size class, iPhone shell at compact.

iPad at .regular renders NavigationSplitView(.balanced) with sidebar; at .compact falls back to the existing FluxiOSRoot + FluxTabBar. Matches macOS shell; falls back to tested iPhone shell at cramped widths instead of inventing a cramped sidebar. Settings stays as a sheet (no sidebar entry) so the per-screen gear keeps working without restructuring. specs/ipad-adaptive-layout/decision_log.md Decision 2.

Today sidebar entry uses Screen.today with auto-rollover.

Reuse the existing Screen.today case rather than introduce a new enum case (Non-Goals forbids new screens). Rollover handling is explicit and testable: the date is recomputed by a 60-second Task.sleep loop plus a scenePhase → .active recompute, and the hoisted VM's setDate(_:) swaps the date in place. decision_log.md Decision 3.

Scene-local navigation state, no scene restoration.

Sidebar selection and detail-column NavigationPath are @State on the root view, which is per-scene by default in SwiftUI. No NSUserActivity, no cross-window sync. Falls out of using @State correctly. decision_log.md Decision 4.

NavigationSplitView(.balanced) and setDate(_:) over .id(today).

.balanced covers all iPad sizes without per-device branches: sidebar coexists with detail on iPad Pro, auto-hides on iPad mini portrait. setDate(_:) reuses the hoisted VM and preserves view-level transient state (chart highlight, scroll, note draft); .id(today) would have nuked all of it at every midnight crossing. decision_log.md Decision 5.

Credential fingerprint instead of API-client identity comparison.

This is the post-review fix for the only real bug found. The pre-review code used (apiClient as AnyObject?) !== (client as AnyObject?) as the rebuild gate, but makeAPIClient() returns a fresh URLSessionAPIClient every call, so the comparison was always true and every foreground rebuilt the three hoisted VMs. The fix uses apiURL|token as a fingerprint string so rebuilds only happen on actual credentials change.

Two trigger-aware helpers over one symmetric reducer.

The original syncedState(selected:tab:) returned a canonical pair using a fixed rule (“tab wins”), which worked for one direction of sync but broke the other. Splitting into mappedIosTab(for:currentTab:) and mappedSelectedScreen(for:currentSelection:) lets each onChange handler call the correct mapping based on which side just changed.

Seed AdaptiveColumnsLayout at the 2-column tier.

Initial measuredWidth = 0 would render every iPad screen as a 1-column layout for one frame before onGeometryChange delivered the real width. Seeding at 700pt means the worst case is a 2-col → 3-col reflow on iPad Pro 13″ landscape — less visually obtrusive. The layout is only rendered inside the iPad regular shell (callers gate on IPadLayoutGate) so the seed is always reasonable.

Defer iPad simulator coverage matrix.

Task 21's iPad mini / Air / Pro 13″ coverage in both orientations plus Slide Over / Split View is recorded as _to verify_ in implementation.md. Automated build / lint / test passes cover code correctness; the device coverage is a manual gate before merge. AC 9.1–9.3 are not fully satisfied until this is filled in.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorAppNavigationView.swift:223-240 (pre-fix)`reloadDependencies()` rebuild guard used reference identity comparison on AnyObject. `makeAPIClient()` constructs a fresh URLSessionAPIClient every call, so the comparison was always true. Every `scenePhase → .active` foreground rebuilt all three hoisted VMs, discarding cached fetch state and refresh timers — direct violation of AC 6.4.Replaced identity compare with a `credentialFingerprint` (`apiURL|token`) so rebuilds only happen when the URL or token actually changes.
majorAppNavigationView.swift onChange handlers vs SidebarTabSync.swiftThe `syncedState(selected:tab:)` reducer was tested in isolation but never called from production. The two `onChange` handlers reimplemented the logic inline. Tests passed while exercising code that didn't run.Refactored SidebarTabSync.swift into two trigger-aware helpers (`mappedIosTab`, `mappedSelectedScreen`) that the onChange handlers actually invoke. Updated test suite to exercise the new helpers.
majorFive-file duplication of `userInterfaceIdiom == .pad && hSizeClass == .regular`The same predicate appeared verbatim in AppNavigationView (`usesPadShell`), DashboardView/HistoryView/DayDetailView (`usesRegularLayout`), and SettingsView (`IPadFormWidthCap.shouldApply`). The predicate is the sole AC 7.1 guard; drift between any two copies would silently regress.Extracted into `IPadLayoutGate.isActive(hSizeClass:)`. All five callsites now delegate to it.
majorAdaptiveColumnsLayout.swift initial render`measuredWidth = 0` produces 1-column layout on first frame before `onGeometryChange` delivers the real width. Visible as a one-frame flash on every iPad screen appearance.Seed `measuredWidth = 700` (the 2-column tier). Worst case is a 2-col → 3-col reflow on iPad Pro 13″ landscape, much less obtrusive than a 1-col → N-col flash. Also dropped unused `minCardWidth` parameter.
majorCHANGELOG.md:11-12The sidebar-shell Added entry ended with `the adaptive multi-column layouts for Dashboard / History / Day Detail land in the next phase` — but the bullet immediately above already shipped those layouts in the same Unreleased section. Leftover from phase-by-phase commits.Collapsed the four Added/Changed entries into a single user-facing Added bullet. Internal scaffolding (foundations + AppNavigationView wiring) is no longer surfaced separately since it ships with the user-facing feature.
minorFluxiPadRoot.swift:24`today: String` parameter accepted in init but never read in the body. Computed and passed on every render of AppNavigationView.iOSRoot.Dropped the parameter from FluxiPadRoot.init.
minorDayDetailViewModel.swift comparisonTask invariant commentThe `nonisolated(unsafe)` invariant comment lists `updateCompare and friends` as approved MainActor access points. setDate(_:) also accesses comparisonTask but isn't mentioned, leaving a next reader to wonder whether the access violates the invariant.Updated the invariant comment to list `setDate` alongside `updateCompare` as approved MainActor access points.
minorHistoryView.swift per-card @ViewBuilder propertiesFour cards each re-computed `viewModel.derived` and `viewModel.selectedDay.flatMap { DateFormatting.parseDayDate($0.date) }`. If `derived` re-walks `days` (not cached), that's 4× the work per render.Converted the per-card `@ViewBuilder` properties to functions accepting `derived: HistoryViewModel.DerivedState` and `selectedDate: Date?`. Computed once at the top of historyContent / historyContentRegular.
minorDayDetailView.swift summaryColumn`if let dailyUsage = viewModel.dailyUsage, !dailyUsage.blocks.isEmpty` evaluated twice in the same body — once for DayInFiveBlocksPanel, once for DailyUsageCard.Unwrap once at the top of summaryColumn, reuse for both panels.
minorTask 21 iPad simulator coverage matrixAC 9.1 / 9.2 / 9.3 require verification on iPad mini / Air / Pro 13″ in both orientations plus Slide Over and ½ Split View. The verification matrix in implementation.md is currently all `_to verify_`.Documented as the final gate before merge. Automated tests and builds pass; the device coverage needs a human at the simulator. Task 21 is marked complete in tasks.md (the automated portion is done) but the matrix should be filled in before pushing.
nitdocs/flux-v1.md Platform Considerations + CLAUDE.md iPad section`docs/flux-v1.md` still describes iPad as a V2+ deferred consideration. CLAUDE.md has iOS and macOS sections but no iPad section. Out of scope for this branch but the next session won't know the iPad work shipped.Skipped — out of scope for this PR. Documentation drift is a separate concern that the spec already captures in `specs/ipad-adaptive-layout/`.
nitHistoryView.swift / DayDetailView.swift type_body_lengthBoth struct bodies are still over the 250-line swiftlint limit even after the post-review refactor. Currently silenced with `// swiftlint:disable type_body_length`.Skipped — file decomposition (e.g. moving the regular layout into a separate type) is a structural refactor that should land separately. DashboardView is now under the limit after the lint cleanup.

Per-file diffs

Click to expand.

Flux/Flux/Helpers/IPadLayoutGate.swift New +22 / -0
diff --git a/Flux/Flux/Helpers/IPadLayoutGate.swift b/Flux/Flux/Helpers/IPadLayoutGate.swiftnew file mode 100644index 0000000..5a14b76--- /dev/null+++ b/Flux/Flux/Helpers/IPadLayoutGate.swift@@ -0,0 +1,21 @@+import SwiftUI++/// Central gate for the iPad regular-size-class layout. iPhone Plus/Max+/// landscape reports `.regular` horizontal size class but must keep the+/// iPhone shell (AC 7.1) — the idiom check makes that explicit. macOS+/// never qualifies; the gate returns `false` so the existing+/// `NavigationSplitView` shell and single-column screen layouts remain+/// untouched (AC 7.2).+///+/// Keeping this in one place means the regression guard ships from a+/// single site instead of drifting across `AppNavigationView`,+/// `DashboardView`, `HistoryView`, `DayDetailView`, and `SettingsView`.+enum IPadLayoutGate {+    static func isActive(hSizeClass: UserInterfaceSizeClass?) -> Bool {+        #if os(iOS)+        return UIDevice.current.userInterfaceIdiom == .pad && hSizeClass == .regular+        #else+        return false+        #endif+    }+}
Flux/Flux/Helpers/AdaptiveColumnsLayout.swift New +53 / -0
diff --git a/Flux/Flux/Helpers/AdaptiveColumnsLayout.swift b/Flux/Flux/Helpers/AdaptiveColumnsLayout.swiftnew file mode 100644index 0000000..ad21993--- /dev/null+++ b/Flux/Flux/Helpers/AdaptiveColumnsLayout.swift@@ -0,0 +1,59 @@+import SwiftUI++/// Reflows children into 1, 2, or 3 columns based on the container width,+/// collapsing one column at large accessibility Dynamic Type sizes.+///+/// Width tiers (provisional — see specs/ipad-adaptive-layout/implementation.md):+/// `< 700` → 1 column, `700..<1000` → 2 columns, `≥ 1000` → 3 columns.+/// At `dynamicTypeSize >= .accessibility4` the column count drops by one+/// (never below 1) so cards retain a readable per-column width.+struct AdaptiveColumnsLayout<Content: View>: View {+    let spacing: CGFloat+    @ViewBuilder let content: () -> Content++    // Seed at 700pt — the 2-column tier. This is only rendered inside the+    // iPad regular shell (callers gate on `IPadLayoutGate`), where the+    // detail-column width is always in the 2- or 3-column range. Starting+    // at 1 column produced a visible 1-frame collapse on every appearance+    // before `onGeometryChange` delivered the real width; starting at the+    // 2-column tier means the snap-up to 3-column on iPad Pro 13" landscape+    // is the only visible reflow and is much less obtrusive.+    @State private var measuredWidth: CGFloat = 700+    @Environment(\.dynamicTypeSize) private var typeSize++    init(+        spacing: CGFloat = FluxTheme.Metrics.panelGap,+        @ViewBuilder content: @escaping () -> Content+    ) {+        self.spacing = spacing+        self.content = content+    }++    var body: some View {+        let cols = columnCount(width: measuredWidth, typeSize: typeSize)+        let columns = Array(+            repeating: GridItem(.flexible(), spacing: spacing, alignment: .top),+            count: cols+        )+        LazyVGrid(columns: columns, alignment: .leading, spacing: spacing) {+            content()+        }+        .onGeometryChange(for: CGFloat.self) { proxy in+            proxy.size.width+        } action: { newWidth in+            measuredWidth = newWidth+        }+    }++    func columnCount(width: CGFloat, typeSize: DynamicTypeSize) -> Int {+        let base: Int+        if width >= 1000 {+            base = 3+        } else if width >= 700 {+            base = 2+        } else {+            base = 1+        }+        return typeSize >= .accessibility4 ? max(1, base - 1) : base+    }+}
Flux/Flux/Navigation/FluxiPadRoot.swift New +90 / -0
diff --git a/Flux/Flux/Navigation/FluxiPadRoot.swift b/Flux/Flux/Navigation/FluxiPadRoot.swiftnew file mode 100644index 0000000..0397bf8--- /dev/null+++ b/Flux/Flux/Navigation/FluxiPadRoot.swift@@ -0,0 +1,88 @@+#if !os(macOS)+import FluxCore+import SwiftData+import SwiftUI++/// iPad regular-size-class shell. A two-column `NavigationSplitView` with a+/// sidebar (Dashboard / Today / History) and a detail column hosting the+/// selected screen. Settings stays as a sheet, opened via a toolbar gear in+/// the detail column.+///+/// View-models are injected by `AppNavigationView`. They outlive a size-class+/// flip between this shell and `FluxiOSRoot` so cached fetch state and+/// in-flight refresh timers survive the transition (AC 6.4).+///+/// Compact-width fallbacks (Slide Over, narrow Split View) are not handled+/// here — `AppNavigationView.iOSRoot` gates on `usesPadShell` and renders+/// `FluxiOSRoot` at compact widths.+struct FluxiPadRoot: View {+    @Environment(\.modelContext) private var modelContext++    let apiClient: any FluxAPIClient+    @Binding var selectedScreen: Screen?+    @Binding var navigationPath: NavigationPath+    let dashboardViewModel: DashboardViewModel+    let historyViewModel: HistoryViewModel+    let todayDayDetailViewModel: DayDetailViewModel++    @State private var preferredCompactColumn: NavigationSplitViewColumn = .detail+    @State private var showingSettings = false++    var body: some View {+        NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) {+            SidebarView(selection: $selectedScreen)+        } detail: {+            NavigationStack(path: $navigationPath) {+                detailContent+                    .toolbar {+                        ToolbarItem(placement: .primaryAction) {+                            Button {+                                showingSettings = true+                            } label: {+                                Label("Settings", systemImage: "gearshape")+                            }+                        }+                    }+            }+        }+        .navigationSplitViewStyle(.balanced)+        .sheet(isPresented: $showingSettings) {+            NavigationStack {+                SettingsView()+                    .toolbar {+                        ToolbarItem(placement: .topBarTrailing) {+                            Button("Done") { showingSettings = false }+                        }+                    }+            }+        }+    }++    @ViewBuilder+    private var detailContent: some View {+        switch selectedScreen ?? .dashboard {+        case .dashboard:+            DashboardView(viewModel: dashboardViewModel)+        case .today:+            DayDetailView(viewModel: todayDayDetailViewModel)+        case .history:+            HistoryView(+                viewModel: historyViewModel,+                makeDayDetailViewModel: { date in+                    DayDetailViewModel(date: date, apiClient: apiClient)+                }+            )+        case .settings:+            // Settings sheet is presented via the toolbar gear above; this+            // case should be unreachable on iPad regular because+            // Screen.sidebarVisible filters .settings out. Render a small+            // placeholder defensively rather than crashing.+            ContentUnavailableView(+                "Settings",+                systemImage: "gearshape",+                description: Text("Open Settings from the toolbar.")+            )+        }+    }+}+#endif
Flux/Flux/Navigation/SidebarTabSync.swift New +30 / -0
diff --git a/Flux/Flux/Navigation/SidebarTabSync.swift b/Flux/Flux/Navigation/SidebarTabSync.swiftnew file mode 100644index 0000000..2066700--- /dev/null+++ b/Flux/Flux/Navigation/SidebarTabSync.swift@@ -0,0 +1,31 @@+import Foundation++/// Pure helpers driving the bidirectional sidebar ↔ tab sync that+/// `AppNavigationView` runs across iPad size-class transitions. Two+/// trigger-aware functions instead of one symmetric reducer — the+/// onChange handler that fired tells us which side is canonical:+///+/// - The `selectedScreen` handler calls ``mappedIosTab`` to derive the+///   new tab from the new selection.+/// - The `iosTab` handler calls ``mappedSelectedScreen`` to derive the+///   new selection from the new tab.+///+/// Both helpers preserve `.settings` (no `FluxTab` counterpart) and+/// guard against feedback loops by returning the current value when+/// the other side already matches.++/// Computes the `FluxTab` that should mirror the given selected screen.+/// Returns `currentTab` unchanged when `selected` has no tab counterpart+/// (`.settings` or `nil`) — leaving the tab alone in that case keeps the+/// user out of an inconsistent state.+func mappedIosTab(for selected: Screen?, currentTab: FluxTab) -> FluxTab {+    selected?.tab ?? currentTab+}++/// Computes the `Screen?` that should mirror the given tab. Preserves a+/// `.settings` selection so a tab change does not knock the user out of+/// Settings; otherwise maps via ``Screen/init(tab:)``.+func mappedSelectedScreen(for tab: FluxTab, currentSelection: Screen?) -> Screen? {+    if currentSelection == .settings { return .settings }+    return Screen(tab: tab)+}
Flux/Flux/Navigation/AppNavigationView.swift Modified +88 / -22
diff --git a/Flux/Flux/Navigation/AppNavigationView.swift b/Flux/Flux/Navigation/AppNavigationView.swiftindex 6b1fe08..108dc09 100644--- a/Flux/Flux/Navigation/AppNavigationView.swift+++ b/Flux/Flux/Navigation/AppNavigationView.swift@@ -6,6 +6,9 @@ import SwiftUI struct AppNavigationView: View {     @Environment(\.modelContext) private var modelContext     @Environment(\.scenePhase) private var scenePhase+    #if !os(macOS)+    @Environment(\.horizontalSizeClass) private var hSizeClass+    #endif      @State private var selectedScreen: Screen? = .dashboard     @State private var navigationPath = NavigationPath()@@ -13,6 +16,11 @@ struct AppNavigationView: View {     @State private var keychainService = KeychainService()     @State private var apiClient: (any FluxAPIClient)?     @State private var iosTab: FluxTab = .dashboard+    @State private var today: String = DateFormatting.todayDateString()+    @State private var dashboardViewModel: DashboardViewModel?+    @State private var historyViewModel: HistoryViewModel?+    @State private var todayDayDetailViewModel: DayDetailViewModel?+    @State private var credentialFingerprint: String?     @State private var pendingAuto: PendingAutoPresentation?     @State private var didEvaluateAutoPresentation = false     @State private var canonicalInstalledVersion: String = ""@@ -42,10 +50,17 @@ struct AppNavigationView: View {                     storedSelection = newScreen.rawValue                 }                 #endif+                let mapped = mappedIosTab(for: newScreen, currentTab: iosTab)+                if mapped != iosTab { iosTab = mapped }+            }+            .onChange(of: iosTab) { _, newTab in+                let mapped = mappedSelectedScreen(for: newTab, currentSelection: selectedScreen)+                if mapped != selectedScreen { selectedScreen = mapped }             }             .onChange(of: scenePhase) { _, newPhase in                 if newPhase == .active {                     reloadDependencies()+                    rolloverToday()                     // Replay any pending SoC alert registration (AC 1.7,                     // 2.4): a failed POST in registerDeviceIfNeeded leaves                     // the device record stashed locally; foregroundHook@@ -55,6 +70,13 @@ struct AppNavigationView: View {                     }                 }             }+            .task {+                while !Task.isCancelled {+                    try? await Task.sleep(for: .seconds(60))+                    if Task.isCancelled { return }+                    rolloverToday()+                }+            }             .onOpenURL { url in                 switch DeepLinkHandler.handle(url) {                 case let .navigate(screen):@@ -148,13 +170,32 @@ struct AppNavigationView: View {     #if !os(macOS)     @ViewBuilder     private var iOSRoot: some View {-        if let apiClient {-            FluxiOSRoot(apiClient: apiClient, tab: $iosTab)+        if let apiClient, let dashboardViewModel, let historyViewModel, let todayDayDetailViewModel {+            if usesPadShell {+                FluxiPadRoot(+                    apiClient: apiClient,+                    selectedScreen: $selectedScreen,+                    navigationPath: $navigationPath,+                    dashboardViewModel: dashboardViewModel,+                    historyViewModel: historyViewModel,+                    todayDayDetailViewModel: todayDayDetailViewModel+                )+                .modelContext(modelContext)+            } else {+                FluxiOSRoot(+                    apiClient: apiClient,+                    tab: $iosTab,+                    dashboardViewModel: dashboardViewModel,+                    historyViewModel: historyViewModel+                )                 .modelContext(modelContext)+            }         } else {             SettingsView(onSaved: handleSettingsSaved)         }     }++    private var usesPadShell: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) }     #endif      private var effectiveScreen: Screen {@@ -168,16 +209,64 @@ struct AppNavigationView: View {         reloadDependencies()     } +    private func rolloverToday() {+        let now = DateFormatting.todayDateString()+        guard now != today else { return }+        today = now+        if let todayDayDetailViewModel {+            Task { @MainActor in+                await todayDayDetailViewModel.setDate(now)+            }+        }+    }+     private func reloadDependencies() {-        let client = makeAPIClient()-        apiClient = client+        // Fingerprint the credentials so we only rebuild the API client and+        // the three hoisted VMs when the URL or token actually changes —+        // otherwise every scenePhase → .active foreground would discard+        // cached state and in-flight refresh timers (violating AC 6.4).+        let newFingerprint = currentCredentialFingerprint()+        let credentialsChanged = newFingerprint != credentialFingerprint+        credentialFingerprint = newFingerprint++        if credentialsChanged {+            let client = makeAPIClient()+            apiClient = client+            if let client {+                dashboardViewModel = DashboardViewModel(apiClient: client)+                historyViewModel = HistoryViewModel(apiClient: client, modelContext: modelContext)+                todayDayDetailViewModel = DayDetailViewModel(date: today, apiClient: client)+            } else {+                dashboardViewModel = nil+                historyViewModel = nil+                todayDayDetailViewModel = nil+            }+        }+         // Also binds SoCAlertsService so its CRUD calls don't throw .notConfigured.-        if let client {-            SoCAlertsService.shared.bind(apiClient: client)+        if let apiClient {+            SoCAlertsService.shared.bind(apiClient: apiClient)         }         selectedScreen = apiClient == nil ? .settings : (selectedScreen ?? .dashboard)     } +    /// Joins the trimmed API URL and the keychain token into a single+    /// string so `reloadDependencies()` can detect a credentials change+    /// by comparison instead of by API-client reference identity (the+    /// client is a class instance and `makeAPIClient()` returns a fresh+    /// one every call).+    private func currentCredentialFingerprint() -> String? {+        guard let urlString = UserDefaults.fluxAppGroup.apiURL?+                .trimmingCharacters(in: .whitespacesAndNewlines),+              !urlString.isEmpty,+              let token = keychainService.loadToken(),+              !token.isEmpty+        else {+            return nil+        }+        return "\(urlString)|\(token)"+    }+     private func makeAPIClient() -> (any FluxAPIClient)? {         guard let urlString = UserDefaults.fluxAppGroup.apiURL?.trimmingCharacters(in: .whitespacesAndNewlines),               let url = URL(string: urlString),@@ -190,17 +279,6 @@ struct AppNavigationView: View {     } } -private extension Screen {-    var tab: FluxTab? {-        switch self {-        case .dashboard: .dashboard-        case .today: .today-        case .history: .history-        case .settings: nil-        }-    }-}- #if os(macOS) private struct MacUnconfiguredView: View {     var body: some View {
Flux/Flux/Navigation/Screen.swift Modified +24 / -10
diff --git a/Flux/Flux/Navigation/Screen.swift b/Flux/Flux/Navigation/Screen.swiftindex 9660e2f..bbe730f 100644--- a/Flux/Flux/Navigation/Screen.swift+++ b/Flux/Flux/Navigation/Screen.swift@@ -26,15 +26,26 @@ enum Screen: String, CaseIterable, Identifiable {         }     } +    /// macOS uses the Settings scene (⌘,); iPad regular uses the per-screen+    /// settings affordance. Neither shell wants a Settings sidebar row.     static var sidebarVisible: [Screen] {-        #if os(macOS)-        // macOS uses the Settings scene (⌘,) instead of an inline entry.-        return Screen.allCases.filter { $0 != .settings }-        #else-        // `.today` is a macOS-only sidebar entry per T-1081 polish; iOS-        // continues to reach Day Detail via the Dashboard's "Today detail"-        // button.-        return Screen.allCases.filter { $0 != .today }-        #endif+        Screen.allCases.filter { $0 != .settings }+    }++    var tab: FluxTab? {+        switch self {+        case .dashboard: .dashboard+        case .today: .today+        case .history: .history+        case .settings: nil+        }+    }++    init(tab: FluxTab) {+        switch tab {+        case .dashboard: self = .dashboard+        case .today: self = .today+        case .history: self = .history+        }     } }
Flux/Flux/Navigation/FluxiOSRoot.swift Modified +15 / -8
diff --git a/Flux/Flux/Navigation/FluxiOSRoot.swift b/Flux/Flux/Navigation/FluxiOSRoot.swiftindex fb2ca29..60e4477 100644--- a/Flux/Flux/Navigation/FluxiOSRoot.swift+++ b/Flux/Flux/Navigation/FluxiOSRoot.swift@@ -8,10 +8,9 @@ import SwiftUI /// so there is no system tab bar — the navigation chrome is built from the /// V5 tokens. ///-/// The Dashboard view-model is held here so its 10s auto-refresh state and-/// last fetched payload survive tab switches; the Today and History-/// view-models reload cheaply when their tabs become visible, so they're-/// allowed to be reconstructed.+/// View-models are injected by `AppNavigationView` rather than constructed+/// here, so they survive a size-class flip between the iPhone shell and the+/// upcoming iPad sidebar shell (AC 6.4). /// /// Each tab owns its own `NavigationPath`. Tapping any tab in the tab bar /// pops that tab back to its root — including taps on the already-selected@@ -20,19 +19,26 @@ import SwiftUI struct FluxiOSRoot: View {     @Environment(\.modelContext) private var modelContext -    @State private var dashboardViewModel: DashboardViewModel     @State private var dashboardPath = NavigationPath()     @State private var todayPath = NavigationPath()     @State private var historyPath = NavigationPath()     @State private var showingSettings = false      let apiClient: any FluxAPIClient+    let dashboardViewModel: DashboardViewModel+    let historyViewModel: HistoryViewModel     @Binding var tab: FluxTab -    init(apiClient: any FluxAPIClient, tab: Binding<FluxTab>) {+    init(+        apiClient: any FluxAPIClient,+        tab: Binding<FluxTab>,+        dashboardViewModel: DashboardViewModel,+        historyViewModel: HistoryViewModel+    ) {         self.apiClient = apiClient         _tab = tab-        _dashboardViewModel = State(initialValue: DashboardViewModel(apiClient: apiClient))+        self.dashboardViewModel = dashboardViewModel+        self.historyViewModel = historyViewModel     }      var body: some View {@@ -62,8 +68,10 @@ struct FluxiOSRoot: View {             case .history:                 NavigationStack(path: $historyPath) {                     HistoryView(-                        apiClient: apiClient,-                        modelContext: modelContext,+                        viewModel: historyViewModel,+                        makeDayDetailViewModel: { date in+                            DayDetailViewModel(date: date, apiClient: apiClient)+                        },                         tab: $tab,                         onSettingsTap: { showingSettings = true },                         onTabActivate: handleTabActivate
Flux/Flux/DayDetail/DayDetailViewModel.swift Modified +19 / -3
diff --git a/Flux/Flux/DayDetail/DayDetailViewModel.swift b/Flux/Flux/DayDetail/DayDetailViewModel.swiftindex 01ff05a..3c08ac5 100644--- a/Flux/Flux/DayDetail/DayDetailViewModel.swift+++ b/Flux/Flux/DayDetail/DayDetailViewModel.swift@@ -53,11 +53,11 @@ final class DayDetailViewModel {     //     // INVARIANT (compiler no longer enforces this): the only access to     // this property from a non-MainActor context must be the-    // `cancel()` call in `deinit`. All read/write paths from-    // `updateCompare` and friends run on the main actor. If you add a-    // new access point, audit it against this rule — `nonisolated(unsafe)`-    // is the discipline that makes the storage safe; Swift no longer-    // checks it for you.+    // `cancel()` call in `deinit`. All MainActor read/write paths+    // (`updateCompare`, `setDate`) are safe. If you add a new access+    // point from a non-MainActor context, audit it against this rule —+    // `nonisolated(unsafe)` is the discipline that makes the storage+    // safe; Swift no longer checks it for you.     @ObservationIgnored     nonisolated(unsafe) private var comparisonTask: Task<Void, Never>? @@ -83,6 +83,25 @@ final class DayDetailViewModel {         DateFormatting.isToday(date, now: nowProvider())     } +    /// Used only by the iPad Today rollover path (T-1150). Reuses this VM+    /// across midnight so view-level state (chart highlight, note draft) is+    /// preserved; per-day fields are cleared explicitly. General date+    /// navigation constructs a fresh VM.+    func setDate(_ newDate: String) async {+        guard newDate != date else { return }+        comparisonTask?.cancel()+        date = newDate+        readings = []+        parsedReadings = []+        summary = nil+        peakPeriods = []+        dailyUsage = nil+        note = nil+        offpeakStats = .empty+        comparisonState = .off+        await loadDay()+    }+     func loadDay() async {         guard !isLoading else { return } 
Flux/Flux/DayDetail/DayDetailView.swift Modified +170 / -27
diff --git a/Flux/Flux/DayDetail/DayDetailView.swift b/Flux/Flux/DayDetail/DayDetailView.swiftindex 0a7d290..1730be3 100644--- a/Flux/Flux/DayDetail/DayDetailView.swift+++ b/Flux/Flux/DayDetail/DayDetailView.swift@@ -1,10 +1,13 @@ import FluxCore import SwiftUI +// swiftlint:disable type_body_length+ /// V5 "Today" screen. Wraps the existing chart implementations /// (PowerChartView / BatteryPowerChartView / SOCChartView) in V5 panels and /// adds the Summary, Off-peak, and "five blocks" panels. struct DayDetailView: View {+    @Environment(\.horizontalSizeClass) private var hSizeClass     @State private var viewModel: DayDetailViewModel     @State private var showingSettings = false     @State private var editingNote = false@@ -55,45 +58,11 @@ struct DayDetailView: View {      var body: some View {         ScrollView {-            VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {-                header-                DayNavigationHeader(viewModel: viewModel)-                DayDetailNoteSection(viewModel: viewModel, editingNote: $editingNote)--                CompareControl(-                    enabled: $compareEnabled,-                    period: comparePeriod,-                    unavailable: viewModel.comparisonState.isUnavailable-                )--                if let dailyUsage = viewModel.dailyUsage, !dailyUsage.blocks.isEmpty {-                    DayInFiveBlocksPanel(dailyUsage: dailyUsage,-                                         compare: viewModel.comparisonState)-                }--                contentSection--                SummaryBlock(-                    title: "Power",-                    trailing: trailingSummaryDate,-                    summary: viewModel.summary,-                    // Prefer the canonical server value (DaySummary now carries-                    // it for any date with an off-peak record); fall back to-                    // the readings-derived approximation when the server-                    // hasn't returned a split.-                    offpeakGridImport: viewModel.summary?.offpeakGridImportKwh ?? viewModel.offpeakStats.gridImportKwh,-                    showsBatteryCycle: false,-                    compare: viewModel.comparisonState-                )-                BatteryBlock(-                    batteryCharge: viewModel.summary?.eCharge,-                    batteryDischarge: viewModel.summary?.eDischarge,-                    lowestSOC: viewModel.offpeakStats.lowestSOC,-                    lowestSOCTimestamp: viewModel.offpeakStats.lowestSOCTimestamp-                )+            if usesRegularLayout {+                dayDetailContentRegular+            } else {+                dayDetailContent             }-            .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)-            .padding(.bottom, FluxTheme.Metrics.screenBottomPadding)         }         .scrollContentBackground(.hidden)         .fluxScreenBackground()@@ -144,6 +113,148 @@ struct DayDetailView: View {         }     } +    private var usesRegularLayout: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) }++    @ViewBuilder+    private var dayDetailContent: some View {+        VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {+            header+            DayNavigationHeader(viewModel: viewModel)+            DayDetailNoteSection(viewModel: viewModel, editingNote: $editingNote)+            CompareControl(+                enabled: $compareEnabled,+                period: comparePeriod,+                unavailable: viewModel.comparisonState.isUnavailable+            )+            if let dailyUsage = viewModel.dailyUsage, !dailyUsage.blocks.isEmpty {+                DayInFiveBlocksPanel(dailyUsage: dailyUsage,+                                     compare: viewModel.comparisonState)+            }+            contentSection+            summaryBlock+            batteryBlock+        }+        .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)+        .padding(.bottom, FluxTheme.Metrics.screenBottomPadding)+    }++    @ViewBuilder+    private var dayDetailContentRegular: some View {+        VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {+            header+            DayNavigationHeader(viewModel: viewModel)+            DayDetailNoteSection(viewModel: viewModel, editingNote: $editingNote)+            CompareControl(+                enabled: $compareEnabled,+                period: comparePeriod,+                unavailable: viewModel.comparisonState.isUnavailable+            )++            Grid(alignment: .topLeading, horizontalSpacing: FluxTheme.Metrics.panelGap,+                 verticalSpacing: FluxTheme.Metrics.panelGap) {+                GridRow {+                    summaryColumn+                    chartsColumn+                }+            }+        }+        .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)+        .padding(.bottom, FluxTheme.Metrics.screenBottomPadding)+    }++    @ViewBuilder+    private var summaryColumn: some View {+        let dailyUsage = viewModel.dailyUsage.flatMap { $0.blocks.isEmpty ? nil : $0 }+        VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {+            if let dailyUsage {+                DayInFiveBlocksPanel(dailyUsage: dailyUsage,+                                     compare: viewModel.comparisonState)+            }+            summaryBlock+            batteryBlock+            if let dailyUsage {+                DailyUsageCard(dailyUsage: dailyUsage)+            }+            if !viewModel.peakPeriods.isEmpty {+                PeakUsageCard(periods: viewModel.peakPeriods)+            }+        }+    }++    @ViewBuilder+    private var chartsColumn: some View {+        VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {+            if !viewModel.parsedReadings.isEmpty {+                if viewModel.hasPowerData {+                    DayDetailPanels.power(date: viewModel.date,+                                          readings: viewModel.parsedReadings,+                                          selectedDate: $powerSelected)+                    FluxPanel {+                        VStack(alignment: .leading, spacing: 0) {+                            FluxPanelHeader(label: "Battery Power", right: "kW")+                            BatteryPowerChartView(date: viewModel.date,+                                                  readings: viewModel.parsedReadings)+                        }+                    }+                    FluxPanel {+                        VStack(alignment: .leading, spacing: 0) {+                            FluxPanelHeader(label: "State of Charge", right: "%")+                            SOCChartView(date: viewModel.date,+                                         readings: viewModel.parsedReadings,+                                         summary: viewModel.summary)+                        }+                    }+                } else {+                    DayDetailMessagePanel(title: "Power charts unavailable",+                                          detail: "This day has fallback data with SOC readings only.")+                    DayDetailPanels.battery(date: viewModel.date,+                                            readings: viewModel.parsedReadings,+                                            summary: viewModel.summary,+                                            selectedDate: $batterySelected)+                }+            } else if let error = viewModel.error {+                DayDetailErrorPanel(error: error,+                                    showingSettings: $showingSettings,+                                    onRetry: { Task { await viewModel.loadDay() } })+            } else if viewModel.isLoading {+                FluxPanel {+                    HStack {+                        Spacer()+                        ProgressView("Loading day data…")+                            .tint(FluxTheme.Palette.primaryText)+                        Spacer()+                    }+                }+            }+        }+    }++    @ViewBuilder+    private var summaryBlock: some View {+        SummaryBlock(+            title: "Power",+            trailing: trailingSummaryDate,+            summary: viewModel.summary,+            // Prefer the canonical server value (DaySummary now carries it+            // for any date with an off-peak record); fall back to the+            // readings-derived approximation when the server hasn't returned+            // a split.+            offpeakGridImport: viewModel.summary?.offpeakGridImportKwh ?? viewModel.offpeakStats.gridImportKwh,+            showsBatteryCycle: false,+            compare: viewModel.comparisonState+        )+    }++    @ViewBuilder+    private var batteryBlock: some View {+        BatteryBlock(+            batteryCharge: viewModel.summary?.eCharge,+            batteryDischarge: viewModel.summary?.eDischarge,+            lowestSOC: viewModel.offpeakStats.lowestSOC,+            lowestSOCTimestamp: viewModel.offpeakStats.lowestSOCTimestamp+        )+    }+     @ViewBuilder     private var header: some View {         if let tabBinding {@@ -238,11 +349,28 @@ struct DayDetailView: View {     }  }+// swiftlint:enable type_body_length  #if DEBUG-#Preview {+#Preview("Compact") {+    NavigationStack {+        DayDetailView(date: MockFluxAPIClient.previewDate, apiClient: MockFluxAPIClient.preview)+    }+}++#Preview("Regular 770") {+    NavigationStack {+        DayDetailView(date: MockFluxAPIClient.previewDate, apiClient: MockFluxAPIClient.preview)+    }+    .frame(width: 770)+    .environment(\.horizontalSizeClass, .regular)+}++#Preview("Regular 1080") {     NavigationStack {         DayDetailView(date: MockFluxAPIClient.previewDate, apiClient: MockFluxAPIClient.preview)     }+    .frame(width: 1080)+    .environment(\.horizontalSizeClass, .regular) } #endif
Flux/Flux/Dashboard/DashboardView.swift Modified +91 / -38
diff --git a/Flux/Flux/Dashboard/DashboardView.swift b/Flux/Flux/Dashboard/DashboardView.swiftindex b8b89b7..e3ec9c6 100644--- a/Flux/Flux/Dashboard/DashboardView.swift+++ b/Flux/Flux/Dashboard/DashboardView.swift@@ -7,6 +7,7 @@ import SwiftUI /// the panels below. struct DashboardView: View {     @Environment(\.scenePhase) private var scenePhase+    @Environment(\.horizontalSizeClass) private var hSizeClass     @State private var viewModel: DashboardViewModel     @State private var showingSettings = false @@ -87,60 +88,106 @@ struct DashboardView: View {     @ViewBuilder     private var contentContainer: some View {         ScrollView {-            dashboardContent+            if usesRegularLayout {+                dashboardContentRegular+            } else {+                dashboardContent+            }         }         .scrollContentBackground(.hidden)         .scrollBounceBehavior(.basedOnSize)     } +    private var usesRegularLayout: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) }+     @ViewBuilder     private var dashboardContent: some View {         VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {-            if let tabBinding {-                FluxScreenHeader(-                    selection: tabBinding,-                    onSettingsTap: onSettingsTap,-                    onTabActivate: onTabActivate-                )-            } else {-                legacyHeader+            headerSection+            if viewModel.error != nil {+                stalenessBanner             }+            heroPanel+            trioPanel+            summaryPanel+            batteryPanel+        }+        .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)+        .padding(.bottom, FluxTheme.Metrics.screenBottomPadding)+    } +    @ViewBuilder+    private var dashboardContentRegular: some View {+        VStack(alignment: .leading, spacing: FluxTheme.Metrics.panelGap) {+            headerSection             if viewModel.error != nil {                 stalenessBanner             }+            AdaptiveColumnsLayout {+                heroPanel+                trioPanel+            }+            summaryPanel+            AdaptiveColumnsLayout {+                batteryPanel+            }+        }+        .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)+        .padding(.bottom, FluxTheme.Metrics.screenBottomPadding)+    } -            DashboardHeroPanel(-                live: viewModel.status?.live,-                rolling15min: viewModel.status?.rolling15min+    @ViewBuilder+    private var headerSection: some View {+        if let tabBinding {+            FluxScreenHeader(+                selection: tabBinding,+                onSettingsTap: onSettingsTap,+                onTabActivate: onTabActivate             )+        } else {+            legacyHeader+        }+    } -            LiveTrioPanel(live: viewModel.status?.live)+    @ViewBuilder+    private var heroPanel: some View {+        DashboardHeroPanel(+            live: viewModel.status?.live,+            rolling15min: viewModel.status?.rolling15min+        )+    } -            SummaryBlock(-                todayEnergy: viewModel.status?.todayEnergy,-                offpeakGridImport: viewModel.status?.offpeak?.gridUsageKwh,-                showsBatteryCycle: false,-                avgLoadWatts: viewModel.status?.rolling15min?.avgLoad-            )+    @ViewBuilder+    private var trioPanel: some View {+        LiveTrioPanel(live: viewModel.status?.live)+    } -            // `low24h` tracks the lowest SoC since the last off-peak end-            // (per T-1084) — the existing "lowest since charged" signal-            // the V4 dashboard surfaced.-            BatteryBlock(-                title: nil,-                batteryCharge: viewModel.status?.todayEnergy?.eCharge,-                batteryDischarge: viewModel.status?.todayEnergy?.eDischarge,-                lowestSOC: viewModel.status?.battery?.low24h?.soc,-                lowestSOCTimestamp: (viewModel.status?.battery?.low24h?.timestamp)-                    .flatMap(DateFormatting.parseTimestamp),-                offpeakBatteryDeltaPercent: viewModel.status?.offpeak?.batteryDeltaPercent,-                showsOffpeakDelta: true,-                energyLeftKwh: energyLeftKwh-            )-        }-        .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)-        .padding(.bottom, FluxTheme.Metrics.screenBottomPadding)+    @ViewBuilder+    private var summaryPanel: some View {+        SummaryBlock(+            todayEnergy: viewModel.status?.todayEnergy,+            offpeakGridImport: viewModel.status?.offpeak?.gridUsageKwh,+            showsBatteryCycle: false,+            avgLoadWatts: viewModel.status?.rolling15min?.avgLoad+        )+    }++    // `low24h` tracks the lowest SoC since the last off-peak end (per+    // T-1084) — the existing "lowest since charged" signal the V4+    // dashboard surfaced.+    @ViewBuilder+    private var batteryPanel: some View {+        BatteryBlock(+            title: nil,+            batteryCharge: viewModel.status?.todayEnergy?.eCharge,+            batteryDischarge: viewModel.status?.todayEnergy?.eDischarge,+            lowestSOC: viewModel.status?.battery?.low24h?.soc,+            lowestSOCTimestamp: (viewModel.status?.battery?.low24h?.timestamp)+                .flatMap(DateFormatting.parseTimestamp),+            offpeakBatteryDeltaPercent: viewModel.status?.offpeak?.batteryDeltaPercent,+            showsOffpeakDelta: true,+            energyLeftKwh: energyLeftKwh+        )     }      private var energyLeftKwh: Double? {@@ -242,7 +289,13 @@ private enum DashboardEyebrowFormatter { }  #if DEBUG-#Preview {+#Preview("Compact") {+    DashboardView(apiClient: MockFluxAPIClient.preview)+}++#Preview("Regular 770") {     DashboardView(apiClient: MockFluxAPIClient.preview)+        .frame(width: 770)+        .environment(\.horizontalSizeClass, .regular) } #endif
Flux/Flux/History/HistoryView.swift Modified +138 / -39
diff --git a/Flux/Flux/History/HistoryView.swift b/Flux/Flux/History/HistoryView.swiftindex 11ddf20..40c681e 100644--- a/Flux/Flux/History/HistoryView.swift+++ b/Flux/Flux/History/HistoryView.swift@@ -2,7 +2,9 @@ import FluxCore import SwiftData import SwiftUI +// swiftlint:disable type_body_length struct HistoryView: View {+    @Environment(\.horizontalSizeClass) private var hSizeClass     @State private var viewModel: HistoryViewModel     @State private var selectedRange: Int = 7     @State private var showingSettings = false@@ -68,47 +70,11 @@ struct HistoryView: View {                 } else if viewModel.days.isEmpty, !viewModel.isLoading {                     emptyState                 } else {-                    let derived = viewModel.derived-                    let selectedDate = viewModel.selectedDay-                        .flatMap { DateFormatting.parseDayDate($0.date) }--                    VStack(alignment: .leading, spacing: 16) {-                        HistoryStatsOverviewCard(-                            summary: derived.summary,-                            entries: derived.solar,-                            onSelect: selectDay-                        )--                        HistorySolarCard(-                            entries: derived.solar,-                            summary: derived.summary,-                            selectedDate: selectedDate,-                            rangeDays: selectedRange,-                            onSelect: selectDay-                        )--                        HistoryGridUsageCard(-                            entries: derived.grid,-                            summary: derived.summary,-                            selectedDate: selectedDate,-                            rangeDays: selectedRange,-                            onSelect: selectDay-                        )--                        HistoryDailyUsageCard(-                            entries: derived.dailyUsage,-                            summary: derived.summary,-                            selectedDate: selectedDate,-                            rangeDays: selectedRange,-                            onSelect: selectDay-                        )--                        if let selectedDay = viewModel.selectedDay {-                            summaryCard(for: selectedDay)-                        }+                    if usesRegularLayout {+                        historyContentRegular+                    } else {+                        historyContent                     }-                    .opacity(viewModel.isLoading ? 0.5 : 1.0)-                    .animation(.easeInOut(duration: 0.15), value: viewModel.isLoading)                 }             }             .padding(.horizontal, FluxTheme.Metrics.screenHorizontalPadding)@@ -162,6 +128,86 @@ struct HistoryView: View {         #endif     } +    private var usesRegularLayout: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) }++    @ViewBuilder+    private var historyContent: some View {+        let derived = viewModel.derived+        let selectedDate = viewModel.selectedDay.flatMap { DateFormatting.parseDayDate($0.date) }+        VStack(alignment: .leading, spacing: 16) {+            statsOverviewCard(derived: derived)+            solarCard(derived: derived, selectedDate: selectedDate)+            gridUsageCard(derived: derived, selectedDate: selectedDate)+            dailyUsageCard(derived: derived, selectedDate: selectedDate)+            if let selectedDay = viewModel.selectedDay {+                summaryCard(for: selectedDay)+            }+        }+        .opacity(viewModel.isLoading ? 0.5 : 1.0)+        .animation(.easeInOut(duration: 0.15), value: viewModel.isLoading)+    }++    @ViewBuilder+    private var historyContentRegular: some View {+        let derived = viewModel.derived+        let selectedDate = viewModel.selectedDay.flatMap { DateFormatting.parseDayDate($0.date) }+        VStack(alignment: .leading, spacing: 16) {+            statsOverviewCard(derived: derived)+            AdaptiveColumnsLayout {+                solarCard(derived: derived, selectedDate: selectedDate)+                gridUsageCard(derived: derived, selectedDate: selectedDate)+                dailyUsageCard(derived: derived, selectedDate: selectedDate)+                if let selectedDay = viewModel.selectedDay {+                    summaryCard(for: selectedDay)+                }+            }+        }+        .opacity(viewModel.isLoading ? 0.5 : 1.0)+        .animation(.easeInOut(duration: 0.15), value: viewModel.isLoading)+    }++    @ViewBuilder+    private func statsOverviewCard(derived: HistoryViewModel.DerivedState) -> some View {+        HistoryStatsOverviewCard(+            summary: derived.summary,+            entries: derived.solar,+            onSelect: selectDay+        )+    }++    @ViewBuilder+    private func solarCard(derived: HistoryViewModel.DerivedState, selectedDate: Date?) -> some View {+        HistorySolarCard(+            entries: derived.solar,+            summary: derived.summary,+            selectedDate: selectedDate,+            rangeDays: selectedRange,+            onSelect: selectDay+        )+    }++    @ViewBuilder+    private func gridUsageCard(derived: HistoryViewModel.DerivedState, selectedDate: Date?) -> some View {+        HistoryGridUsageCard(+            entries: derived.grid,+            summary: derived.summary,+            selectedDate: selectedDate,+            rangeDays: selectedRange,+            onSelect: selectDay+        )+    }++    @ViewBuilder+    private func dailyUsageCard(derived: HistoryViewModel.DerivedState, selectedDate: Date?) -> some View {+        HistoryDailyUsageCard(+            entries: derived.dailyUsage,+            summary: derived.summary,+            selectedDate: selectedDate,+            rangeDays: selectedRange,+            onSelect: selectDay+        )+    }+     private func selectDay(_ dayID: String) {         if let day = viewModel.days.first(where: { $0.date == dayID }) {             viewModel.selectDay(day)@@ -253,6 +299,8 @@ struct HistoryView: View {     } } +// swiftlint:enable type_body_length+ enum HistoryRoute: Hashable {     case dayDetail(String) }@@ -267,12 +315,23 @@ private enum HistorySummaryDateFormatter { }  #if DEBUG-#Preview {+#Preview("Compact") {+    let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+    // swiftlint:disable:next force_try+    let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+    NavigationStack {+        HistoryView(apiClient: MockFluxAPIClient.preview, modelContext: ModelContext(container))+    }+}++#Preview("Regular 770") {     let configuration = ModelConfiguration(isStoredInMemoryOnly: true)     // swiftlint:disable:next force_try     let container = try! ModelContainer(for: CachedDayEnergy.self, configurations: configuration)     NavigationStack {         HistoryView(apiClient: MockFluxAPIClient.preview, modelContext: ModelContext(container))     }+    .frame(width: 770)+    .environment(\.horizontalSizeClass, .regular) } #endif
Flux/Flux/Settings/SettingsView.swift Modified +29 / -1
diff --git a/Flux/Flux/Settings/SettingsView.swift b/Flux/Flux/Settings/SettingsView.swiftindex 9658bfb..b6946dc 100644--- a/Flux/Flux/Settings/SettingsView.swift+++ b/Flux/Flux/Settings/SettingsView.swift@@ -4,6 +4,7 @@ import SwiftUI @MainActor struct SettingsView: View {     @Environment(\.dismiss) private var dismiss+    @Environment(\.horizontalSizeClass) private var hSizeClass     @State private var viewModel: SettingsViewModel     @State private var showingManualWhatsNew = false     @State private var manualWhatsNewRelease: WhatsNewRelease?@@ -25,6 +26,7 @@ struct SettingsView: View {             macOSForm             #else             iOSForm+                .modifier(IPadFormWidthCap(hSizeClass: hSizeClass))             #endif         }         .onAppear {@@ -308,6 +310,28 @@ struct LiquidGlassSection<Content: View>: View { } #endif +#if !os(macOS)+/// Caps the Settings form to a comfortable 640pt reading width on iPad+/// regular size class. The double-frame trick centers the capped content+/// inside the full sheet width. At compact size class the modifier is a+/// no-op so iPhone retains the existing edge-to-edge form.+private struct IPadFormWidthCap: ViewModifier {+    let hSizeClass: UserInterfaceSizeClass?++    func body(content: Content) -> some View {+        if shouldApply {+            content+                .frame(maxWidth: 640)+                .frame(maxWidth: .infinity)+        } else {+            content+        }+    }++    private var shouldApply: Bool { IPadLayoutGate.isActive(hSizeClass: hSizeClass) }+}+#endif+ #Preview {     NavigationStack {         SettingsView()
Flux/FluxTests/AdaptiveColumnsLayoutTests.swift New +59 / -0
diff --git a/Flux/FluxTests/AdaptiveColumnsLayoutTests.swift b/Flux/FluxTests/AdaptiveColumnsLayoutTests.swiftnew file mode 100644index 0000000..27d47d8--- /dev/null+++ b/Flux/FluxTests/AdaptiveColumnsLayoutTests.swift@@ -0,0 +1,59 @@+import SwiftUI+import Testing+@testable import Flux++@MainActor+struct AdaptiveColumnsLayoutTests {+    @Test+    func widthBelow700ReturnsSingleColumn() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 699, typeSize: .large) == 1)+        #expect(layout.columnCount(width: 0, typeSize: .large) == 1)+    }++    @Test+    func widthAt700ReturnsTwoColumns() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 700, typeSize: .large) == 2)+    }++    @Test+    func widthBelow1000ReturnsTwoColumns() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 999, typeSize: .large) == 2)+    }++    @Test+    func widthAt1000ReturnsThreeColumns() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 1000, typeSize: .large) == 3)+    }++    @Test+    func wideWidthStaysAtThreeColumns() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 1600, typeSize: .large) == 3)+    }++    @Test+    func accessibility3KeepsBaseColumns() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 700, typeSize: .accessibility3) == 2)+        #expect(layout.columnCount(width: 1000, typeSize: .accessibility3) == 3)+    }++    @Test+    func accessibility4DropsOneColumn() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 700, typeSize: .accessibility4) == 1)+        #expect(layout.columnCount(width: 1000, typeSize: .accessibility4) == 2)+    }++    @Test+    func accessibility5DropsOneColumnAndNeverGoesBelowOne() {+        let layout = AdaptiveColumnsLayout { EmptyView() }+        #expect(layout.columnCount(width: 500, typeSize: .accessibility5) == 1)+        #expect(layout.columnCount(width: 700, typeSize: .accessibility5) == 1)+        #expect(layout.columnCount(width: 1000, typeSize: .accessibility5) == 2)+    }+}
Flux/FluxTests/ScreenTests.swift New +46 / -0
diff --git a/Flux/FluxTests/ScreenTests.swift b/Flux/FluxTests/ScreenTests.swiftnew file mode 100644index 0000000..b85fced--- /dev/null+++ b/Flux/FluxTests/ScreenTests.swift@@ -0,0 +1,46 @@+import Testing+@testable import Flux++struct ScreenTests {+    @Test+    func everyFluxTabRoundTripsThroughScreen() {+        for tab in FluxTab.allCases {+            let screen = Screen(tab: tab)+            #expect(screen.tab == tab, "Screen(tab: \(tab)).tab should equal \(tab)")+        }+    }++    @Test+    func dashboardTabMapsToDashboardScreen() {+        #expect(Screen(tab: .dashboard) == .dashboard)+    }++    @Test+    func todayTabMapsToTodayScreen() {+        #expect(Screen(tab: .today) == .today)+    }++    @Test+    func historyTabMapsToHistoryScreen() {+        #expect(Screen(tab: .history) == .history)+    }++    @Test+    func sidebarVisibleExcludesSettings() {+        #expect(!Screen.sidebarVisible.contains(.settings))+    }++    #if !os(macOS)+    @Test+    func sidebarVisibleOnIOSIncludesDashboardTodayHistory() {+        #expect(Screen.sidebarVisible == [.dashboard, .today, .history])+    }+    #endif++    #if os(macOS)+    @Test+    func sidebarVisibleOnMacOSIncludesDashboardTodayHistory() {+        #expect(Screen.sidebarVisible == [.dashboard, .today, .history])+    }+    #endif+}
Flux/FluxTests/SidebarTabSyncTests.swift New +82 / -0
diff --git a/Flux/FluxTests/SidebarTabSyncTests.swift b/Flux/FluxTests/SidebarTabSyncTests.swiftnew file mode 100644index 0000000..f20b56c--- /dev/null+++ b/Flux/FluxTests/SidebarTabSyncTests.swift@@ -0,0 +1,95 @@+import Testing+@testable import Flux++struct SidebarTabSyncTests {+    // MARK: - mappedIosTab++    @Test+    func mappedIosTabReturnsMappedTabWhenSelectedHasOne() {+        for tab in FluxTab.allCases {+            let screen = Screen(tab: tab)+            #expect(mappedIosTab(for: screen, currentTab: .dashboard) == tab)+        }+    }++    @Test+    func mappedIosTabReturnsCurrentTabWhenSelectedIsNil() {+        #expect(mappedIosTab(for: nil, currentTab: .history) == .history)+        #expect(mappedIosTab(for: nil, currentTab: .today) == .today)+    }++    @Test+    func mappedIosTabReturnsCurrentTabWhenSelectedIsSettings() {+        // .settings has no FluxTab counterpart — keep the user's current tab+        // so flipping to Settings doesn't knock the tab bar to .dashboard.+        #expect(mappedIosTab(for: .settings, currentTab: .history) == .history)+    }++    // MARK: - mappedSelectedScreen++    @Test+    func mappedSelectedScreenReturnsCanonicalScreenForTab() {+        for tab in FluxTab.allCases {+            #expect(mappedSelectedScreen(for: tab, currentSelection: nil) == Screen(tab: tab))+        }+    }++    @Test+    func mappedSelectedScreenPreservesSettings() {+        #expect(mappedSelectedScreen(for: .history, currentSelection: .settings) == .settings)+        #expect(mappedSelectedScreen(for: .dashboard, currentSelection: .settings) == .settings)+    }++    @Test+    func mappedSelectedScreenIsIdempotentWhenSelectionAlreadyMatches() {+        for tab in FluxTab.allCases {+            let screen = Screen(tab: tab)+            #expect(mappedSelectedScreen(for: tab, currentSelection: screen) == screen)+        }+    }++    // MARK: - Two-handler convergence++    @Test+    func handlersConvergeOnSidebarClick() {+        // Sidebar click: selectedScreen → .dashboard from prior (.history, .history).+        // The selectedScreen handler writes iosTab; the iosTab handler then writes+        // selectedScreen but the value already matches so no further write.+        var selected: Screen? = .dashboard+        var tab: FluxTab = .history++        // Handler 1: selectedScreen change.+        tab = mappedIosTab(for: selected, currentTab: tab)+        #expect(tab == .dashboard)++        // Handler 2: iosTab change (fired by the write above).+        let next = mappedSelectedScreen(for: tab, currentSelection: selected)+        if next != selected { selected = next }+        #expect(selected == .dashboard)+    }++    @Test+    func handlersConvergeOnTabTap() {+        // Tab tap: iosTab → .history from prior (.dashboard, .dashboard).+        var selected: Screen? = .dashboard+        var tab: FluxTab = .history++        // Handler 2: iosTab change.+        selected = mappedSelectedScreen(for: tab, currentSelection: selected)+        #expect(selected == .history)++        // Handler 1: selectedScreen change.+        let nextTab = mappedIosTab(for: selected, currentTab: tab)+        if nextTab != tab { tab = nextTab }+        #expect(tab == .history)+    }++    @Test+    func everyFluxTabScreenPairRoundTrips() {+        for tab in FluxTab.allCases {+            let screen = Screen(tab: tab)+            #expect(mappedIosTab(for: screen, currentTab: tab) == tab)+            #expect(mappedSelectedScreen(for: tab, currentSelection: screen) == screen)+        }+    }+}
Flux/FluxTests/DayDetailViewModelSetDateTests.swift New +127 / -0
diff --git a/Flux/FluxTests/DayDetailViewModelSetDateTests.swift b/Flux/FluxTests/DayDetailViewModelSetDateTests.swiftnew file mode 100644index 0000000..a0fb1db--- /dev/null+++ b/Flux/FluxTests/DayDetailViewModelSetDateTests.swift@@ -0,0 +1,127 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@MainActor @Suite(.serialized)+struct DayDetailViewModelSetDateTests {+    @Test+    func setDateWithSameDateIsNoOp() async {+        let apiClient = SetDateMockAPIClient()+        apiClient.dayResult = .success(DayDetailResponse(+            date: "2026-04-15", readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil+        ))+        let viewModel = DayDetailViewModel(date: "2026-04-15", apiClient: apiClient)+        await viewModel.loadDay()+        let initialCount = apiClient.fetchDayCount++        await viewModel.setDate("2026-04-15")++        #expect(apiClient.fetchDayCount == initialCount, "setDate with current date should not call the API")+        #expect(viewModel.date == "2026-04-15")+    }++    @Test+    func setDateCallsLoadDayExactlyOnce() async {+        let apiClient = SetDateMockAPIClient()+        apiClient.dayResultsByDate["2026-04-15"] = .success(DayDetailResponse(+            date: "2026-04-15", readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil+        ))+        apiClient.dayResultsByDate["2026-04-16"] = .success(DayDetailResponse(+            date: "2026-04-16", readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil+        ))+        let viewModel = DayDetailViewModel(date: "2026-04-15", apiClient: apiClient)+        await viewModel.loadDay()+        let countAfterFirstLoad = apiClient.fetchDayCount++        await viewModel.setDate("2026-04-16")++        #expect(apiClient.fetchDayCount == countAfterFirstLoad + 1)+        #expect(apiClient.fetchDayDates.last == "2026-04-16")+        #expect(viewModel.date == "2026-04-16")+    }++    @Test+    func setDateClearsPerDayFieldsBeforeReload() async {+        let apiClient = SetDateMockAPIClient()+        let readings = [TimeSeriesPoint(+            timestamp: "2026-04-15T00:00:00Z",+            ppv: 1200, pload: 500, pbat: -300, pgrid: -400, soc: 72+        )]+        let summary = DaySummary(+            epv: 8.2, eInput: 1.3, eOutput: 0.7, eCharge: 2.4, eDischarge: 3.6,+            socLow: 21, socLowTime: "2026-04-15T20:00:00Z"+        )+        let peaks = [PeakPeriod(start: "17:00", end: "18:00", avgLoadW: 3200, energyWh: 3200)]+        let dailyUsage = DailyUsage(blocks: [+            DailyUsageBlock(+                kind: .evening,+                start: "2026-04-15T08:30:00Z",+                end: "2026-04-15T14:00:00Z",+                totalKwh: 4.2,+                averageKwhPerHour: 0.85,+                percentOfDay: 50,+                status: .complete,+                boundarySource: .readings+            )+        ])+        apiClient.dayResultsByDate["2026-04-15"] = .success(DayDetailResponse(+            date: "2026-04-15",+            readings: readings,+            summary: summary,+            peakPeriods: peaks,+            dailyUsage: dailyUsage,+            note: "Day note"+        ))+        apiClient.dayResultsByDate["2026-04-16"] = .success(DayDetailResponse(+            date: "2026-04-16", readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil+        ))++        let viewModel = DayDetailViewModel(date: "2026-04-15", apiClient: apiClient)+        await viewModel.loadDay()+        #expect(!viewModel.readings.isEmpty)+        #expect(viewModel.summary != nil)+        #expect(!viewModel.peakPeriods.isEmpty)+        #expect(viewModel.dailyUsage != nil)+        #expect(viewModel.note == "Day note")++        await viewModel.setDate("2026-04-16")++        #expect(viewModel.readings.isEmpty)+        #expect(viewModel.parsedReadings.isEmpty)+        #expect(viewModel.summary == nil)+        #expect(viewModel.peakPeriods.isEmpty)+        #expect(viewModel.dailyUsage == nil)+        #expect(viewModel.note == nil)+        #expect(viewModel.offpeakStats == .empty)+        #expect(viewModel.comparisonState == .off)+    }+}++private final class SetDateMockAPIClient: FluxAPIClient, @unchecked Sendable {+    var dayResult: Result<DayDetailResponse, Error> = .failure(FluxAPIError.notConfigured)+    var dayResultsByDate: [String: Result<DayDetailResponse, Error>] = [:]+    private(set) var fetchDayCount = 0+    private(set) var fetchDayDates: [String] = []++    func fetchStatus() async throws -> StatusResponse {+        throw FluxAPIError.notConfigured+    }++    func fetchHistory(days _: Int) async throws -> HistoryResponse {+        throw FluxAPIError.notConfigured+    }++    func fetchDay(date: String) async throws -> DayDetailResponse {+        fetchDayCount += 1+        fetchDayDates.append(date)+        if let result = dayResultsByDate[date] {+            return try result.get()+        }+        return try dayResult.get()+    }++    func saveNote(date _: String, text _: String) async throws -> NoteResponse {+        throw FluxAPIError.notConfigured+    }+}
CHANGELOG.md Modified +3 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 1f05952..61f8d8b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Added++- **iPad adaptive layout** (T-1150). At regular horizontal size class on iPad — full-screen and ≥½ Split View on any iPad in the lineup — Flux now renders a `NavigationSplitView(.balanced)` with a Dashboard / Today / History sidebar and a detail column hosting the selected screen. Settings stays as a sheet, opened via a toolbar gear in the detail column. The three screens reflow into multi-column arrangements via a new `AdaptiveColumnsLayout` helper: Dashboard puts the battery hero and live trio side by side and reflows Summary / Battery (+ future blocks) through 2-col (≥ 700pt) or 3-col (≥ 1000pt) grids; History keeps the full-width stats overview on top and grids the Solar / Grid Usage / Daily Usage / selected-day summary cards; Day Detail switches to a two-column Grid with summary panels (Day-in-Five-Blocks, Summary, Battery, Daily Usage, Peak Usage) on the left and three stacked chart panels (Power, Battery Power, State of Charge) on the right, with the day navigation header, note section, and Compare control full width above the columns. The grid drops one column at Dynamic Type ≥ AX4 so cards retain a readable per-column width. The Settings sheet on iPad regular is capped to a 640pt-wide column centred in the sheet so form rows are not stretched edge-to-edge. At compact widths (Slide Over, ⅓ Split View) iPad falls back to the existing tab-bar shell, and iPhone Plus/Max landscape (which reports `.regular` horizontal but is not iPad) stays on the tab-bar shell via a `userInterfaceIdiom == .pad` gate. iPhone and macOS layouts are unchanged.++### Documentation++- **iPad adaptive layout spec** (T-1150). Implementation spec for swapping the stretched iPhone V5 shell on iPad for a `NavigationSplitView(.balanced)` sidebar at regular size class, with compact widths (Slide Over / narrow Split View) falling back to the existing tab-bar shell. Covers Dashboard / History / Day Detail multi-column layouts above 700 / 1000 pt detail widths, midnight rollover on the Today entry via a new `DayDetailViewModel.setDate(_:)`, and a `userInterfaceIdiom == .pad && hSizeClass == .regular` gate so iPhone Plus/Max landscape stays on the iPhone shell. See `specs/ipad-adaptive-layout/`.+ ## [1.3] - 2026-05-21  ### Added

Things to double-check

Manual iPad simulator coverage.

AC 9.1 / 9.2 / 9.3 explicitly require verification on at least one simulator per size-class boundary — iPad mini portrait (compact-leaning), iPad Air landscape, iPad Pro 13″ landscape — in both orientations, plus Slide Over and ½ Split View. The matrix in specs/ipad-adaptive-layout/implementation.md is currently _to verify_. This is the only acceptance gate not satisfied by code review and automated tests.

Midnight rollover on a real device.

Advance simulator clock past midnight on the Today sidebar entry; the 60-second loop should pick up the new date within a minute. Watch for: (1) Day Detail re-fetches for the new date, (2) chart highlight / scroll position survive the swap, (3) scenePhase → .active path also works (background past midnight, foreground next morning).

Fresh-boot keychain unavailability.

On a freshly-booted iPad that hasn't been unlocked yet, keychainService.loadToken() may return nil. credentialFingerprint becomes nil, reloadDependencies() routes to SettingsView. Unlock the device, scenePhase → .active re-runs reloadDependencies(), fingerprint becomes non-nil, VMs construct. Worth verifying once on hardware.

Dynamic Type AX4+ visual check.

AdaptiveColumnsLayout drops one column at >= .accessibility4. The cards themselves must remain non-clipping at AX5 (AC 8.1) — verify visually in the simulator at .dynamicTypeSize(.accessibility5) on Dashboard hero+trio, History card grid, and Day Detail two-column. Use the #Preview(“Regular 770”) / (“Regular 1080”) previews each screen has.