diff --git a/Project.swift b/Project.swift index b9a41dfff..03769d3d8 100644 --- a/Project.swift +++ b/Project.swift @@ -770,7 +770,11 @@ let project = Project( name: "WhereUISnapshotTests", bundleIdSuffix: "whereui.snapshot", productDependency: "WhereUI", - sources: ["Where/WhereUI/SnapshotTests/**"], + sources: [ + "Where/WhereUI/SnapshotTests/**", + "Where/WhereUI/Tests/Support/AdaptivePaneLoadFixture.swift", + "Where/WhereUI/Tests/Support/TestStore.swift", + ], extraPackageProducts: ["SnapshotKitTesting"], environmentVariables: snapshotEnvironment, ), diff --git a/Shared/Inspector/SnapshotTests/__Snapshots__/InspectorSnapshotTests/inspectorSurfaces.SwiftData_iPhone_dark.png b/Shared/Inspector/SnapshotTests/__Snapshots__/InspectorSnapshotTests/inspectorSurfaces.SwiftData_iPhone_dark.png index 59de6e964..ba92613f5 100644 --- a/Shared/Inspector/SnapshotTests/__Snapshots__/InspectorSnapshotTests/inspectorSurfaces.SwiftData_iPhone_dark.png +++ b/Shared/Inspector/SnapshotTests/__Snapshots__/InspectorSnapshotTests/inspectorSurfaces.SwiftData_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09c9d0c095894dcfbc3001706e3937ed05e2d53cbb411988cf1f017c612d9dcc -size 147618 +oid sha256:fe78cdcc30b9006fc61ec64e83cc639b6aa417fd586bb60d2c1b9853f7d75d60 +size 129795 diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 0d2c31daa..b0083a79e 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -114,9 +114,9 @@ Layering, localization, preview, and testing conventions live in the feature `YearReportModel` scan result. Inject that scene as the Resolve model's `ResolutionSource`. Re-key on raw evidence changes; never gate refresh on aggregate-report equality. Schedule presentation deadlines only in the foreground. - Expose each calendar month as one accessibility element: regional totals in the label, chronological stay spans and dated badges in the value. Preserve planning qualifiers. Guard: `CalendarMonthAccessibilityTests`. -- Keep overview availability derived from the full-size calendar visibility rule in `CalendarPresentationModel`. Unavailable months keep their region and projection colors, and remain actionable for rejection feedback. Guard: `CalendarPresentationModelTests`. -- Inject the calendar zoom key at each entry point. Persist mode changes through `WherePreferences`; a nil key must not read or write preferences. Guard: `CalendarPresentationModelTests`. -- Animate calendar zoom in an overlay after the destination scroll position settles. Keep moving geometry outside the scroll-target layout and cancel it when its viewport or report changes. Reduce Motion uses a crossfade. Guards: `CalendarZoomTransitionModelTests`, `CalendarContentViewTests`. +- Keep overview availability derived from the full-size calendar visibility rule in `CalendarModel`. Unavailable months keep their region and projection colors, and remain actionable for rejection feedback. Guard: `CalendarModelTests`. +- Inject the calendar zoom key at each entry point. Persist mode changes through `CalendarModel` and the existing `WherePreferences` keys; a nil key must not read or write preferences. Guard: `CalendarModelTests`. +- Animate calendar zoom in an overlay after the destination scroll position settles. Keep moving geometry outside the scroll-target layout and cancel it when its viewport or report changes. Reduce Motion uses a crossfade. Guards: `CalendarZoomTransitionStateTests`, `CalendarContentViewTests`. - Keep calendar flight days and timeline flight events independent of review dismissal. Show uncertain arrival timing explicitly. Render supported travel periods separately from later ground observations. Split timeline stays only for presentation; preserve every region-day exactly once. @@ -160,9 +160,13 @@ Layering, localization, preview, and testing conventions live in the feature reveal stays covered when headless promotion coalesces or the first hold is interrupted, while warm resumes never replay it. - Measure calendar month sections, day rows, and Timeline rows eagerly so full-content measurement and scroll restoration use their actual heights. Guards: `CalendarContentViewSnapshotTests`, `PresenceTimelineListSnapshotTests`. +- Keep Your Year in one navigation stack with its bottom calendar/timeline selector at every size. Guard: `YearViewTests`. +- Show Locations selection indicators only when browsing and calendar panes are displayed together. Guard: `LocationsViewTestsDuo.selectedRegionIsIndicatedOnlyBesideItsCalendar`. - Keep navigation paths, search, sheets, and drafts in stable owning views above adaptive branches. Keep active drill-in stacks mounted while folding; defer tabletop composition until returning. - Read system reserved regions only in `AdaptivePaneGeometry`. Keep authored pane limits in the stylesheet and measured geometry in the adapter. Guard: `where.reserved_region_layout_ownership`. - Keep explicit reveal requests in the owning view, with typed request identities distinct from content identities. Use BroadwayUI scroll restoration for current-context anchors; scrolling must not select or move another pane. +- Keep rendered calendar anchors typed as `CalendarPresentationState.Target`, preserving month and estimate destinations. Detail owns the retained native scroll position; overview and tabletop must not overwrite it without an explicit reveal. +- Keep reusable pane content outside native navigation shells. Present planning sheets above changing branches. Embedded demo tools use `demoExitPresentation = .embedded`. - Keep synthetic-region injection under `#if DEBUG` and expose it through the Testing SPI. Transition tests retain one mounted container. - Keep itinerary persistence, forecast bounds, overlaps, and Home projections in WhereCore. `LocationForecastModel` mirrors one planning snapshot through store-change refreshes. @@ -296,6 +300,9 @@ Hosted tests that traverse SwiftUI accessibility must link and import `AccessibilitySnapshotParser` so a fresh simulator initializes its accessibility runtime. Guard: `CalendarContentViewTests`. +`Tests/Support/TestStore.swift` and `AdaptivePaneLoadFixture.swift` are shared by the unit and image bundles through `Project.swift`. +Keep support fixtures outside `SnapshotTests/`, whose Swift files each declare one matching suite. Use the store's read gates and failures for adaptive loading snapshots. Keep fixture setup and completion independent for each configuration. + `WhereStylesheetTests` pins every token default and trait-aware derivation. `WhereStylesheetEnvironmentTests` covers the `@Environment(\.stylesheet)` glue and `whereBroadwayRoot()` seeding, including the WhereWidgets path. diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 8f08ec61b..2b9109155 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -38,7 +38,7 @@ The calendar initially opens at full size. It remembers the last zoom level sepa `CalendarContentView` accepts an optional `CalendarZoomKey`; `nil` keeps zoom in memory without reading or writing preferences. Pinch inward or tap Show Year to see all twelve months in a three-column overview. Tap an available month or pinch outward over it to return to full size. The full-size estimate panel sits below the overview grid. Tapping it opens its position in the detailed calendar. -Visible months move and scale between their measured positions in each zoom level. The estimate panel moves at full size. `CalendarZoomTransitionModel` holds the outgoing tiles until the destination scroll position settles; `CalendarZoomOverlay` animates fixed-layout copies without changing the scroll layout. Reduce Motion uses a crossfade. +Visible months move and scale between their measured positions in each zoom level. The estimate panel moves at full size. `CalendarZoomTransitionState` holds the outgoing tiles until the destination scroll position settles; `CalendarZoomOverlay` animates fixed-layout copies without changing the scroll layout. Reduce Motion uses a crossfade. Gray month titles and day numbers mark months unavailable in the full-size view. Their region and projection colors remain visible. Activating one gives a light haptic and a brief shake. The overview uses stronger region fills, with extra opacity in dark mode. Future plans retain diagonal stripes; possible dates retain hollow dots and dashed outlines. Month cards are transparent in both zoom levels, so the screen background shows through. The current month uses an accent outline and title. @@ -69,6 +69,61 @@ An automatic check that can reuse Core's cache preserves an active manual check Demo mode hides this surface and performs no update checks. +`MainTabs` owns the report and its tab selection. Locations, Year, and Settings own +presentation state above adaptive layout branches. Their paths, search, and sheets +remain local to those screens. Active Locations drill-ins keep their native stack +mounted and defer tabletop composition until the user returns. + +An explicit BroadwayUI restoration scope retains each pane's native `ScrollPosition`. +Entries use the view type and a typed role; region/year changes replace the current +entry. There is no historical cache, persistence, or pixel-offset restoration. +The detail calendar retains typed month and forecast targets with their alignment. +Overview has a separate local position. Tabletop reads the selected month or detail +anchor without replacing a saved forecast target. +`CalendarModel` interprets available months and accesses existing zoom preferences. +View-owned helpers handle mode, gestures, rejection feedback, and transition geometry. +A report-year change waits for matching data before positioning. Stale visibility callbacks cannot replace the new year's anchor. +Calendar sections and day rows measure eagerly so offscreen estimates cannot move a restored month. +The timeline keeps its pinned overview outside the scrolling rows; large text places it in the content flow. +Timeline stays also measure eagerly so resizing cannot shift the content origin. +Timeline anchors refer to displayed stay segments or flight identities. +Switching between the calendar and timeline restores each view independently. + +On a wide window, Locations shows cards beside the selected region's calendar. +Without a region selection, the detail pane shows the year calendar. +Your Year uses one navigation stack and the bottom calendar/timeline selector at every window size. +Settings uses native list and detail navigation. +Settings keeps its native search field visible in the navigation drawer across compact and split layouts. +Narrow windows and accessibility text sizes use one pane. Locations marks the selected region only while its browsing and calendar panes are shown together. +Theme choices stack when the available pane cannot fit two readable card specimens. + +`AdaptivePaneGeometry` reads independent edge insets and the system's reserved regions. +`AdaptivePaneArrangement` combines that geometry with the stylesheet's pane limits. +In Locations, an active horizontal division places the selected month above the hinge and browsing controls below it. +A vertical division retains native browse/detail navigation. Native containers own system-bar placement. +The adapter uses container geometry, without device-name or hinge-angle checks. + +Locations owns an optional `CalendarMonthReveal` value. Month controls create a new +request identity even when the destination is unchanged. Calendar applies the request +once its content is ready and retains the consumed month for tabletop display. +Calendar detail scroll restoration remains authoritative; entering overview or +tabletop does not overwrite a saved forecast anchor. Timeline owns its typed row IDs +and restores browsing independently, without calendar-to-timeline selection machinery. +Settings uses native split navigation and the stylesheet's compact-layout policy; +it does not consult Locations' tabletop geometry. + +Planning sheets remain above changing containers, with one owner per destination. +The welcome and developer overlays share the geometry adapter to find clear space. +The DEBUG **Duo Layout Lab** retains one production demo world while its pose control +changes bounds, traits, and synthetic regions. Embedded demo tools leave dismissal +to their containing tool. + +Synthetic poses come from `SnapshotConfiguration.DuoPose`; see the measurement provenance in [SnapshotKit](../../Shared/SnapshotKit/README.md). Native runtime geometry validation remains pending. +Viewport shell captures supplement full-content captures of the shared scrolling panes. +Views apply their initial position once the corresponding content is ready. +Synthetic layouts do not validate system bars, camera activation, or physical folding. Those require native-device checks. +The reusable panes also support iPad layouts. There is no HomeView target. + - **`RootView`** — the app root: the typed launch plan (via [`LifecycleKit`](../../Shared/LifecycleKit), rendered by [`LifecycleKitUI`](../../Shared/LifecycleKitUI)'s container) gated in front of @@ -699,19 +754,3 @@ also avoids blank trailing space from lazy estimates in its bottom-anchored scro Developer Overlay snapshots retain their scene for native glass rendering. Detached windows produce material bands and clipping in the menu. The retained-scene cases keep their device traits, content clearance, and full-content sizing. - -## Adaptive pane foundations - -Stable destination views own navigation and presentation state. BroadwayUI retains -scroll positions for the current context only. Changing region or year discards -the old position. Explicit reveal requests belong to their consuming views; -ordinary scrolling does not select or move another pane. Synthetic pose geometry -and localized labels come from `SnapshotConfiguration.DuoPose`. - -`AdaptivePaneGeometry` maps safe areas and reserved regions into physical -container coordinates. `AdaptivePaneArrangement` applies the stylesheet's pane -limits. `AdaptivePaneContainer` hosts compact, columns, and tabletop arrangements. -The DEBUG Testing SPI supplies synthetic reserved regions to the same adapter. - -This foundation does not replace the current destination containers. The dependent -app integration installs view-owned state and presents these arrangements. diff --git a/Where/WhereUI/SnapshotTests/DuoLayoutLabViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/DuoLayoutLabViewSnapshotTests.swift new file mode 100644 index 000000000..8147d4988 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/DuoLayoutLabViewSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct DuoLayoutLabViewSnapshotTests { + @Test func duoLayoutLab() async { + await assertSnapshots(of: DuoLayoutLabView.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/LocationsViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/LocationsViewSnapshotTests.swift index 16bd0120e..e9dd070b2 100644 --- a/Where/WhereUI/SnapshotTests/LocationsViewSnapshotTests.swift +++ b/Where/WhereUI/SnapshotTests/LocationsViewSnapshotTests.swift @@ -1,10 +1,28 @@ import SnapshotKitTesting +import SwiftUI import Testing -@testable import WhereUI +@_spi(Testing) @testable import WhereUI @MainActor struct LocationsViewSnapshotTests { @Test func locations() async { await assertSnapshots(of: LocationsView.self) } + + @Test func adaptiveLoadStates() async throws { + for state in AdaptivePaneLoadFixture.State.allCases { + for pose in [SnapshotConfiguration.DuoPose.innerPortrait, .tabletop] { + let fixture = try await AdaptivePaneLoadFixture(state: state) + await assertSnapshots( + of: LocationsView(report: fixture.report) + .adaptivePaneReservedRegions(pose.regions) + .whereBroadwayRoot(), + named: "Adaptive-" + state.rawValue + "-" + pose.rawValue, + configurations: [SnapshotConfiguration(device: pose.frame)], + settle: .settledAtLeast(minDuration: 1), + ) + await fixture.finish() + } + } + } } diff --git a/Where/WhereUI/SnapshotTests/YearViewSnapshotTests.swift b/Where/WhereUI/SnapshotTests/YearViewSnapshotTests.swift index 0bb3d6d33..03ff12009 100644 --- a/Where/WhereUI/SnapshotTests/YearViewSnapshotTests.swift +++ b/Where/WhereUI/SnapshotTests/YearViewSnapshotTests.swift @@ -1,10 +1,28 @@ import SnapshotKitTesting +import SwiftUI import Testing -@testable import WhereUI +@_spi(Testing) @testable import WhereUI @MainActor struct YearViewSnapshotTests { @Test func year() async { await assertSnapshots(of: YearView.self) } + + @Test func adaptiveLoadStates() async throws { + for state in AdaptivePaneLoadFixture.State.allCases { + for pose in [SnapshotConfiguration.DuoPose.innerPortrait, .tabletop] { + let fixture = try await AdaptivePaneLoadFixture(state: state) + await assertSnapshots( + of: YearView(report: fixture.report) + .adaptivePaneReservedRegions(pose.regions) + .whereBroadwayRoot(), + named: "Adaptive-" + state.rawValue + "-" + pose.rawValue, + configurations: [SnapshotConfiguration(device: pose.frame)], + settle: .settledAtLeast(minDuration: 1), + ) + await fixture.finish() + } + } + } } diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.HistoryColumn_historyColumn.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.HistoryColumn_historyColumn.png new file mode 100644 index 000000000..10dd22948 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/calendarContent.HistoryColumn_historyColumn.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da09278c1d6d2b52ee9e6e6643fcf304cb5471614bbc13904ce63de2a7fb024d +size 1772507 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone.png index b74268d0c..8349f73fb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a2e64e94bc70d3c3dc81b0c14a2cc5f101faace8ef7792c993e3b85670d678b -size 430194 +oid sha256:cd65da3e3f6b54c692498b78e5ea15015d3c36d442f66366753ec63790a848f0 +size 464359 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone_dark.png index 13e15ffe7..dcfe0cb05 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuBottomTrailing_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e574d2318939c58669917d565a1e78d0d8cf59d89d61f5d7fe998d6067645a7 -size 339077 +oid sha256:68eb28273e9b93041a7fcf2ef929c3fae17bffbf6f3a099bdf23bdd3dfd806bc +size 362092 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuTopLeading_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuTopLeading_iPhone.png index 355f50682..d7e33ea69 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuTopLeading_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.MenuTopLeading_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a49087f387a62dd51d76ba837b7f07c10dd01f6f99cbfd2c7460d26fbc4d1362 -size 431882 +oid sha256:b5817ef4d57a04951eb11d2c892723590c80a7e2e4a0aa28628cd597cae2b5e2 +size 466276 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png index fb0ff4571..af3d3e66b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DeveloperOverlaySnapshotTests/developerOverlay.SelectedTool_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ad660fed56810947ac18399a308695fd6ef5eb06046290787f8ff051ba5a19d0 -size 176273 +oid sha256:9b50f950544ba23eb768cf7df0f46b56e4ad4da5eb7833b4d5092c5c7e091d6a +size 175860 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DuoLayoutLabViewSnapshotTests/duoLayoutLab.outerPortrait_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DuoLayoutLabViewSnapshotTests/duoLayoutLab.outerPortrait_iPad.png new file mode 100644 index 000000000..0e7703ac8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DuoLayoutLabViewSnapshotTests/duoLayoutLab.outerPortrait_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28ada323688f575febe9528d4292ad5f305b3544260c7d822014ba0a0ef831a0 +size 1456189 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DuoLayoutLabViewSnapshotTests/duoLayoutLab.tabletop_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DuoLayoutLabViewSnapshotTests/duoLayoutLab.tabletop_iPad.png new file mode 100644 index 000000000..112481b92 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DuoLayoutLabViewSnapshotTests/duoLayoutLab.tabletop_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:893bde30e63fddfa20a98b34bb3a837b6248349580f75e1415769efad9f13b66 +size 2583864 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..75e6f4acc --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:540d5c92ecde3f08188285cdedd4855cee63efd22397cd449447f1ad9914e6a1 +size 770995 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..998b4ea60 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:026c50e4c41e07db45230efaa2b0c99db230a430cb97dafbd00c7ecfa1a6d694 +size 501500 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..b3bd6ba67 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84faf3a5121ffe6e379b457a8fa8ef7724c176080610888eb028a51e9f259b95 +size 424287 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..ec68fa377 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74b6e8b788ad43f9853887289fb5ad958da79f1a40a8abc28280caeb6baeb6b1 +size 447244 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..be5819d98 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf5eda368453d24723c1fc83a76bca0b3c7540d0a53b488894d173582296f44d +size 692694 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..055125e11 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30595c1c21e3371b423c768475bfae5cbd48b3594a8ccc9e40421b7b384bd8ca +size 730843 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.BrowseColumn_browseColumn.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.BrowseColumn_browseColumn.png new file mode 100644 index 000000000..66d029bca --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.BrowseColumn_browseColumn.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d583262af0c8d4ab1263fc39044cec013d7e7d6466f69497325b2cf053326f74 +size 1383579 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ReturnedFromCalendar_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ReturnedFromCalendar_iPhone.png new file mode 100644 index 000000000..7c3520a2f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ReturnedFromCalendar_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2870e83f37c110da02b88f880b88af61f19d56f170c804fcf511391a7a6772c6 +size 2073994 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ReturnedFromCalendar_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ReturnedFromCalendar_iPhone_dark.png new file mode 100644 index 000000000..ed62276d3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.ReturnedFromCalendar_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6fbe412066cf59a14b484e8102d240024d090161b01045ba6157ece9d8f333d +size 1994083 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-book-Selected_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-book-Selected_duoInnerLandscape.png new file mode 100644 index 000000000..30b64fcaf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-book-Selected_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cea5710b0856817e0695e8ad2271b01b50fcd49bfea210a661e6b2090d2ed8dd +size 3325913 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-book_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-book_duoInnerLandscape.png new file mode 100644 index 000000000..15aff5013 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-book_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3ee222f788c1da3eaf2edcad0982546974d0f45522ffdca0ab49bd4ad7d88e9 +size 4032340 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerLandscape_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerLandscape_duoInnerLandscape.png new file mode 100644 index 000000000..d195a07dc --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerLandscape_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0399f104345fe4f0356e170609504f34d1a29ba7dd8dcb7335c015209a101819 +size 4032352 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait-Selected_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait-Selected_duoInnerPortrait.png new file mode 100644 index 000000000..41dedf6fa --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait-Selected_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97f20d2a12b608f2199c3add311b056c1207cf2fc870adab795aea9ac783d6b6 +size 3234158 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..888d05165 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80f8ef6b3412b4c65ac6cd3a0df6929ae01079acd79d72e2f13bd8ab754fad40 +size 3525556 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_accessibility.png new file mode 100644 index 000000000..676e4ee9a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67dacac08164dfba2b92a014e1fac355c735fe2b436d686566e0203cc78b1d94 +size 3572559 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_dark.png new file mode 100644 index 000000000..22d23f382 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ae9dc0f925201b2ae1c219387b25543ee0f524080f86ac69a15e0e18c3d159f +size 3123234 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-narrow_narrowWindow.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-narrow_narrowWindow.png new file mode 100644 index 000000000..a222c5559 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-narrow_narrowWindow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b159193223a7158a0ef9b8835af65181b8844dc991198a5a5061ebbced14d510 +size 1770395 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-outerLandscape_duoOuterLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-outerLandscape_duoOuterLandscape.png new file mode 100644 index 000000000..8aceeaba2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-outerLandscape_duoOuterLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:360c781ec71559a20796b0fddeda6cee2cb293a9e29d0320ea859d947616a42e +size 2551980 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-outerPortrait_duoOuterPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-outerPortrait_duoOuterPortrait.png new file mode 100644 index 000000000..e82c8d0d0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-outerPortrait_duoOuterPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14718aec7d6971df5effea7a85829558aad63a07e013b3ac690b417ce053ff1d +size 2667818 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-tabletop-Selected_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-tabletop-Selected_duoInnerPortrait.png new file mode 100644 index 000000000..31a4649b7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-tabletop-Selected_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d87ff2ca38a907bc440b4c857b8fc72593694cbf72b88923cd1a166b142afe5 +size 2114502 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..c4c9487d2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Locations-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73c211cfef4b9724996a07d637b4f3e1ded5bfd7f275b1134861f56fc617272d +size 2140306 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-book_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-book_duoInnerLandscape.png new file mode 100644 index 000000000..2c1d0afd5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-book_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74482b80ae5441e89a61ebda287b65436c66b72dc2da57c5956d5e2267e6d218 +size 308675 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerLandscape_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerLandscape_duoInnerLandscape.png new file mode 100644 index 000000000..2c1d0afd5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerLandscape_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74482b80ae5441e89a61ebda287b65436c66b72dc2da57c5956d5e2267e6d218 +size 308675 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait-Selected_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait-Selected_duoInnerPortrait.png new file mode 100644 index 000000000..2ed02f8c3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait-Selected_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31208ae28db42f55bc1f195ddc583d653aad54675207d5adc5c65d0037377038 +size 1103517 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..a032f60ae --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1dc4ea0021823b8af5520cef2c853142afc6f46fe03cec5e16040ebe12110e2a +size 344555 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_accessibility.png new file mode 100644 index 000000000..4d4bedc87 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547a43da5afc03fe52c63fbbdcdce47a190472458b0ff5288835cbc107c27439 +size 761059 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_dark.png new file mode 100644 index 000000000..f17d66e27 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9809a56d548b66c8b690dd9f93d3d784a470e408dae7b914ec5527ef666cd30b +size 1015025 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-narrow_narrowWindow.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-narrow_narrowWindow.png new file mode 100644 index 000000000..ff1057928 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-narrow_narrowWindow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:862686df2ac36b2414bb12c3f5a709aa905c1add38e04b0041101fdf083dd848 +size 160662 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-outerLandscape_duoOuterLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-outerLandscape_duoOuterLandscape.png new file mode 100644 index 000000000..2e9c7162e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-outerLandscape_duoOuterLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36db0a534d2d04f15e49c4305f429a3f26ff40163a52a98e6611209b2d1814b5 +size 155481 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-outerPortrait_duoOuterPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-outerPortrait_duoOuterPortrait.png new file mode 100644 index 000000000..8ca88b4d6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-outerPortrait_duoOuterPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c7bc376fc86a0cfd9e04a48ae05088a6201d6ef4b43076cb6e611270b7730b0 +size 190202 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..a032f60ae --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Settings-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1dc4ea0021823b8af5520cef2c853142afc6f46fe03cec5e16040ebe12110e2a +size 344555 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-book-Timeline_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-book-Timeline_duoInnerLandscape.png new file mode 100644 index 000000000..c2598e784 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-book-Timeline_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71d3d229119f5c58327d5f5ee3d7e53e793ee5935ed9d5c9a4cc8d4fb5785258 +size 2646461 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-book_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-book_duoInnerLandscape.png new file mode 100644 index 000000000..9627e8e3e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-book_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23b616d5d790add70a8f384b3c48fc99245de2ceb2a4d2cefe81af1fd45e7218 +size 2968589 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerLandscape_duoInnerLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerLandscape_duoInnerLandscape.png new file mode 100644 index 000000000..28cb94c95 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerLandscape_duoInnerLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b556cfbe7c7c30fbc301a235df0e1f413bd2a3f61d4824348e36389c286fa16d +size 2968594 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait-Timeline_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait-Timeline_duoInnerPortrait.png new file mode 100644 index 000000000..d619d3939 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait-Timeline_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3e2c11c820c06f1c2b7d2ac658226b4bbcbf5e205e725892728494ee11388d7 +size 2538795 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..4f983f48f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9414de886c4b6593194a071ea8533a0cbee43843a6626c8803d7f1418b3095ba +size 2616476 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_accessibility.png new file mode 100644 index 000000000..eedf55ec3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2221141eeafbd5eb025788777fe207df5782dc203ecdfdcde3a08039ce265ed +size 3170831 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_dark.png new file mode 100644 index 000000000..611dc0e5d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc59076e69e6e546b2cf2d6e4295c74c1eb53ed713067a24a070ff139aeed0ee +size 1858680 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-narrow_narrowWindow.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-narrow_narrowWindow.png new file mode 100644 index 000000000..aff7fccac --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-narrow_narrowWindow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ecf2a577a673859d9b1a3a628d19ceead50078da4ec8eb6b6ada64a2fa036a8 +size 1152201 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-outerLandscape_duoOuterLandscape.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-outerLandscape_duoOuterLandscape.png new file mode 100644 index 000000000..ffb33d73b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-outerLandscape_duoOuterLandscape.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ccbf93ebfe512bd93e4d497d950d50b86b966d171dd19a72880d409f08bf257 +size 1636022 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-outerPortrait_duoOuterPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-outerPortrait_duoOuterPortrait.png new file mode 100644 index 000000000..7efd3c927 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-outerPortrait_duoOuterPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20a3d90820d9f5d18f7841c60ea104289c807c49f2a4196fe01981704b201584 +size 1399384 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-tabletop-Timeline_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-tabletop-Timeline_duoInnerPortrait.png new file mode 100644 index 000000000..d619d3939 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-tabletop-Timeline_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3e2c11c820c06f1c2b7d2ac658226b4bbcbf5e205e725892728494ee11388d7 +size 2538795 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..4f983f48f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.Adaptive-Year-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9414de886c4b6593194a071ea8533a0cbee43843a6626c8803d7f1418b3095ba +size 2616476 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.UpdateAvailable_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.UpdateAvailable_iPhone_accessibility.png index 0c55de518..35bb942c4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.UpdateAvailable_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.UpdateAvailable_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59919eaf8812f40f7ddbe1be755d4fed2925c164d7369d296f64c16b22d3635d -size 387029 +oid sha256:bcdf6b1b52c22f06ae1272ac3e84e94aa73c7ba5678bddc6bdaff88394281a40 +size 373080 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png index 03bf55f38..8d8c77676 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f2c75f9d9691694f1f96e884c643f39adb72348ca9616a4edf5e8121c6ec6b3 -size 422258 +oid sha256:42debe38ee3ef2129735deade42c660ce22b3268e9e0c59cfe75b9991c334ee2 +size 395931 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png index faaae2697..a52b7f152 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf0323f162f5f5982ebde1b406ecc5d53bcae2cd4397d74543c03aeb5e5fdc35 -size 419032 +oid sha256:e7b4649c306908618b4dc9755397214d12ceb5375625cc08b03775d565646516 +size 390782 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_ax5.png new file mode 100644 index 000000000..fd0aafaf6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b6c00ae3b9f3fdc19324ad1545ad47de0b3236067394d8cf15029920e864f2b +size 4704095 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_contrast.png new file mode 100644 index 000000000..a15fc5e41 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db8a654b860e1135c9500b3e9e859b78f20fd7cb4630cc7ea89066527428b8fc +size 3471453 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_rtl.png new file mode 100644 index 000000000..b477e6f9c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Locations-innerPortrait_duoInnerPortrait_rtl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b44e434f36aaac5c03d787dceeee1d3c8a32d80aac951539b68b5cf5898aecc5 +size 3278655 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_ax5.png new file mode 100644 index 000000000..5f8769165 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bbf69b85d1e34f56a33c3a3396975def13ea23e80e6c48fa76ee8dc67d9de61 +size 528324 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_contrast.png new file mode 100644 index 000000000..d4949db2f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa02a69b6f52f86018f05b6cd4cea7a8a18e3b6c9df1c05aa223a93ad5ef4bb6 +size 335855 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_rtl.png new file mode 100644 index 000000000..fd10ef1f2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Settings-innerPortrait_duoInnerPortrait_rtl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b584a172288a2e92b0ca3633acb7d56b54443c60814ad9feaba3bcb18e59d1f +size 347013 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_ax5.png new file mode 100644 index 000000000..f40168279 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee252bdcaf761913ea18a31df54d1282a9c85ca7d643d688047bc452ee4a53dd +size 3740858 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_contrast.png new file mode 100644 index 000000000..ffe627554 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67f348f5500ee298dd479ebc09753813d7356cae5f8c60b5115d43e133b4e1ef +size 2596126 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_rtl.png new file mode 100644 index 000000000..1b5b328f5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/other/mainTabs.Adaptive-Year-innerPortrait_duoInnerPortrait_rtl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa3a0f86c167d2f4ae72eb11bfa7b510cc945ea730219a1700fbd909ee54ce93 +size 2614252 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPad_contrast.png index b34bbcb63..be6230552 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d950d5a24b606cdde550402b5429a022db65baa54a9db1d5d321d0fed01f4e36 -size 3050937 +oid sha256:6ea05c75b3a6496f395ab767ed5bd9c5951d0470e98cdc9427bc754d3576e435 +size 3050930 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPhone_contrast.png index a1cf95e16..fb60b7c82 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.Flights_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1aa4dbab16800f27a6dfc2ecd21a7064cd888228c0f8bbbb024338cd4c3896fa -size 1714447 +oid sha256:8eb3f60198d091a53e2e69899010f3b4f2135c89ce9bf0c059f48d6948d24328 +size 1714459 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPad_contrast.png index 39b883d9e..61879056b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f8029fe31d39ec7ce70ce58a3a8daa0248eddbdb6206b56f3cf8dbeb201cacd -size 2922689 +oid sha256:de3839262b0a6970850f0c517e8d32db9a0a82c59bd5e7313f28c3cebb7185bc +size 2922772 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPhone_contrast.png index 8a0d0c9b8..813b68ed6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/other/presenceTimeline.WithData_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98e38d1c592e6bc81f6d91f3a03e0fb3ac7054abdb6fe4518836a7d7bc074b15 -size 1610886 +oid sha256:e8a0c287ec439af9a7c0867a4b3c472e5cbeb90e7cf82f60b4966945db75cf5a +size 1610873 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad.png index e30975536..e3d6f2659 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9bfdf458053294ca4f26b36cc1402e492c7886f14581fe86518c78863b54a032 -size 3027404 +oid sha256:119e739eda0dc465bf4ac58de2750d51f498d3878a2a83dd5e31a86b54e53de1 +size 3027408 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_accessibility.png index 36919e8c1..16fbc24a5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eabd492a7a8fb91a847108e55d19e6c687ffe5567d00bff1b3979305991d3541 -size 3318082 +oid sha256:8fe773648053a7ec40dd4ef4f104d57ecc3e56d001ff6ba5674bc971c2442896 +size 3318068 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_dark.png index 81cecc7ba..f32ede18d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4b7f097056e57410341ed4520637ca43a7325087172b7c453044e922dc7a278 -size 1792927 +oid sha256:33b76e85ec575be97e3697f0ff98441375787079066a23a6614c41303f3b095b +size 1792910 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone.png index 487bf88ae..f492af261 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16d35380ba1174758a6941cbc1ebdb954825fc04cb4fe47f7083a610512bd268 +oid sha256:d29230d1bab14583ce0bb3ca80a03960e691cb7855849bcf3a7a01392a3a4fe0 size 1702919 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_accessibility.png index 442431a5e..7d88d1209 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e8e0e0d8a1478b2f9da1b85be7ea6a1c151a9f2c9e4cfcc789f094f11008f2d -size 2027515 +oid sha256:96d2f6954497d42f0bb5abddbe3691d7bdeb896d90e8dcc176b51d5d4c38c1ab +size 2027499 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_dark.png index 6bcf159c2..60df465c3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.Flights_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9057d8a5d345012119ea71cb62824ec6ccb1ca7e8432fd64189886fff067cd7 -size 1032034 +oid sha256:46dc7f946b356c85e5df964f8e01044ad94f1f989a483a68318c8597ef99e7cd +size 1032025 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png index b2f4e3659..c3e389226 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9c794f1096bb5d93696e6c01e192b3da8a1680c4ca5166f91237e65559b0dd8 -size 1495404 +oid sha256:9e3289d5f1d3eb8a93ce8b384c894c1693b7357c17d2fe50750217fae9912e7d +size 1455084 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png index 5f775af64..7e1c2a8f6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InitialBottom_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01bea07555c94c7fee2c3ef5d01fdc62dd29bb554bef0b411e4fb219ff928a69 -size 824692 +oid sha256:987a752f7be2c495daca25c392489481df85b0365b450d1e01d17195dfc06626 +size 778534 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone.png index 277dc5874..8feb2105b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e99f7095c5f2565bb36c97dd99f624e9b88f73ed33c7a1d35494e21eaf9d55ea -size 1483859 +oid sha256:f46d247be96047afa8bb160e07f483b630a4afb3cc50f2f9198f95e9d036775d +size 1483877 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone_dark.png index 566f5cf6c..e9e5ddbd8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.InterruptedStay_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84816e98ac349e6177a3acb61bbe39f5db0b990972bde81169a53e0c3c6a6ded -size 837667 +oid sha256:bce0344484811686bc1e587e1d579c75f3b14993b215b7ca6b423125fce9a7fd +size 837675 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png index 0f4aecc3c..7e8a4acc3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5a142c1056b2e26fcd1f7335cc8e3cd51a812f930df65704668f2782572a6fe -size 1576984 +oid sha256:c8f29a82ebb2a96d01cbdd04bf7cfc4eeaef2b08166116b6aa6d45d188ece607 +size 1576982 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png index ef865fecf..abf690dba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStayAfterRecordingGap_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d3e48678b1578e2533f845086a303a04b173abee4be423228341f2ad4d3d7ff -size 873758 +oid sha256:9eb9d1cd52030f7365bb260eb9d9fc1d090032dc9632131a0c3694455d4d1986 +size 873756 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png index 2f6c8b0c3..0010f4161 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.PlannedStay_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb858be37ed69bb205e0134bbf409858d60c5006934cb44fa164154e45af65f2 -size 1768882 +oid sha256:b2a6c1fd9873d2300bb8ad3c7a8a3c2b400d1790816616293186daeda1e52de0 +size 1771581 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.UncertainArrival_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.UncertainArrival_iPhone_accessibility.png index b81ca4155..4450a0622 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.UncertainArrival_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.UncertainArrival_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:350bdba3097af42f1a953dae3b1e444ea8dea35b309913b9ec9237498dd6ae09 -size 1973767 +oid sha256:ea10c5dd2e9d24f47cc420b1e1b442ac601ab8fa69f641559fe54fd7bee2e5ec +size 1974410 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png index 4f609a091..1e6322c8b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16c793c2766be9b78fac3965ebe870d554c980d3f30727c4892cf90a154f6cc5 -size 2901823 +oid sha256:c9b63a09207d095938f8658c7c9ee7ba3c7b51d1dfe2487ccc982f0d72372612 +size 2901853 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png index 06dc96e5b..9dd6bef63 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a6c71f95192699a82f315d0e6357842269397118362f1bae7f4023484f0f3c0 -size 3009692 +oid sha256:01bedc713c743764f1f32a0374169f8e807f2fd85ad911f5c8d5331d0b443943 +size 3008657 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png index 3b8284e85..610ae959c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e0284b69392009cc74d9935741a94f4a1311a01b22908845e64c1bed2b47c1e -size 1658798 +oid sha256:39da7ec2ae4c92c05b93a51a3db4f6ff39977408e29d45e0618ef19826cd0e87 +size 1658951 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png index dce8624d2..60fbf1f4b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67d170e9d53450ba0732a797d78474827f7200c9c9818a237ce469f388748dc3 -size 1597879 +oid sha256:356ada7d6580af9760bc644eb129dd374838fe0262c457830d2e039e2f2ed6f2 +size 1597890 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png index 6d492b00f..cce59c08d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:513d58bb5bf968a62ccba15c55d99a3ad8f30aeaa33d091486e3fca8ee79d6cb -size 1806776 +oid sha256:146ac8f0625a03a7eee1656addc0b511e5b2fc0fc6458cc83b4328aecf291c17 +size 1806812 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png index 2919cd0e6..c73bbbf46 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/PresenceTimelineListSnapshotTests/presenceTimeline.WithData_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:980dbf7b0d8c0958d84532d251720053eacf9e2c369cda390098693110e90ac4 -size 922326 +oid sha256:513784303e7e6a82d7a0c0c0c05580c7d115b08d10189a16cde335c547175a2c +size 922354 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_ax5.png index ed90c61ef..a88a42ed6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bd590e0a06c4b5f7736f2489c97607710b7a40c1802f8dd8f0407ead919fb39 -size 963567 +oid sha256:23696cf62d615196a71dea5ff9e6c4456d58fb5a6fabae5f03923e265486c1bd +size 990569 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_contrast.png index 5b6bd1dcb..dd5f3d8b3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfce2458b9e8c49f427096b873e481719d0a391d92339034f1b30111071a9a37 -size 528211 +oid sha256:0697e9e047571b806ed0a72111fe576f611e00539e657377146ef5047dd7a994 +size 527376 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_ax5.png index 31e3a3c4f..fd191bef9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09b5807781503d5af954df40f95ab06c0a45a7c856f024fa62eb143913e0c037 -size 1031761 +oid sha256:2acda7a9eb46a0aafd2eb115583a408064130ca9bc0a22eca6433d35505b922e +size 1032560 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_contrast.png index 2ad1ee04c..fbd5dc56c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6dfd903d562d0eae0355071bae3245168f71cbc9eadd4dd7da27e3681757968e -size 421981 +oid sha256:2d44aa731e1ee52f8a1dde0865860d6fb6b7fb284029e6609fa04b02ab76a461 +size 409131 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_rtl.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_rtl.png index 315f37a6b..1a6e3529e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_rtl.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/other/settings.Default_iPhone_rtl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3420350b480da92c9b6540650cbe992b10cddd8300c91054f5b395290c4af1f3 -size 418966 +oid sha256:3769b2edef4ade5389c040dd6e23139c7bbd1eb77de264de974d5297bddf93e1 +size 401388 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.BrowseColumn_browseColumn.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.BrowseColumn_browseColumn.png new file mode 100644 index 000000000..2cadba2e4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.BrowseColumn_browseColumn.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65ed3ae599f1f7a6078fc4636a70f13946860876644e29377916dbce516c1f59 +size 385281 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png index 2a2b43e6a..4cd25e700 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:581eef6897996d6d16b50ae05bba536cc999f24c7cc85f3a6e8d2659a859e4ca -size 522711 +oid sha256:a6f0c6f5e7abfcabb49a248555e62055630e81db876708b2c2c5c2c50b01ae47 +size 519682 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png index 2deb37985..49ebb5f33 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8af0ca7c044e15042bb9e27505091066dcef7538accfb2d34c424b80bc49fcf -size 876101 +oid sha256:e5727d3ba2326bb44cea8f8b77f770f34ee83680c3050100b746208b382ff6a4 +size 900953 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png index c6c6513df..5555040a9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ed4b39b7bd9d313346848bec18b9c34aeec511a836d1b62fd694e5982233fba -size 546878 +oid sha256:063f3a2dd6b11469f0a9f103ad53b2d01d21068fffb4cbd86fd76ee1e1779260 +size 550394 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png index 515bb2608..765fe97c2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bd99b69e5bf9defac6493e19a6faa39a03abfbf0f37a3f3a128bb19ba423718 -size 414697 +oid sha256:6689bca2aecc1d227a4eb30bfbdedaa14d033c7142cd00feb8ee116d4c590ba4 +size 399591 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png index 7d250229d..1ffe24952 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f2eace564f8093f3d9ddbedd7bb04ebb61f609acafb6101bbdf9d08457935be -size 792144 +oid sha256:e013a674b9788b8414912fde19accfe20500f1b03ee6798ad8e4a238eae5def7 +size 780743 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png index 81b8a21dd..ae3cf5ab6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4938a113a28c767aa698a50750033675dcf50ae95c014aff738a01bdde45b7ff -size 437503 +oid sha256:f97dac4bc74fafc4f7c5e2bbde284078d3a98c0b8fcb0f830d0bd0cf66d20126 +size 430752 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png index ad3873c26..201b9bcf8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13ee37e1123c6e1c2aa7f9d9c0632f9f85253ff65167993fb28f6ecf55d12d65 -size 366253 +oid sha256:4c820ec0190b16825eb636a603b989d4a5b1ef44c466513a5d2ea10f1e81b926 +size 352643 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png index 385661682..054a52fc5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.DemoMode_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2ad63c374042a7d0dd26f187b135ca512071ee44a5e2547e0f07e0acae399783 -size 376925 +oid sha256:918f51681114b7214a416aa83afcfd169fbb9aac991d13f485956045013d17ec +size 369238 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone.png index f992f7320..67e89a55e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d5efd46a7820a80fad778b61fb770e2550ffb1e201a41ede46df0d3a3839a6c -size 437383 +oid sha256:98b5c2a4b2a172a89195352a849f014d1510b4e03818204b01457f3740d60697 +size 420266 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone_dark.png index 4747beb56..279c3066c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.LocalBuild_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8566e3de3f5a8863af41e895f41dfe024d3cd2d1db882864b56940b58c4f4f06 -size 461359 +oid sha256:f75ef9161aebee763f75f822ab2deed15bfb7fbdd92f75994fe927c1ab5bf2dc +size 454089 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone.png index 805a84612..2c5c19d68 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cb874334f833302309e631b2ae6bc5d12f415131e598ea9e084ab48e7b13cba -size 493754 +oid sha256:19aff267cbf12c3447dd390017296b6988dfe1779cc98286b7b0aac6ac0d8d57 +size 478938 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone_dark.png index 935d29820..1e5fc856b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.RecordingConfigurationWarning_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:db322126514fe42e4e67860ae0fb35ea7aa69db31dd6d2588b0d2e5e24a4dfe6 -size 521633 +oid sha256:bcdf0d7307107be7dd1616b75f8d4fecf5a28414116b57179c791549613bbfb6 +size 517649 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone.png index f39d28baa..838112acd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4c9455de51eea89a5b0e9f96f0c918c4c66958a5d55f5a0e598309ecab9cdfa -size 443009 +oid sha256:2b3380c0f75fedb3f7d31d293443937569dd8ec2c5fb5df6bb5cbe9a4c78b7ec +size 427076 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone_dark.png index 50564248a..a74d39b75 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/SettingsViewSnapshotTests/settings.UpdateAvailable_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5935a989d3dba0cef0684f94a37e6ddd28240da4b4eeee2fba7b4ec3e86a2d1b -size 467100 +oid sha256:a3cef6c7b7763c74b3f4930187f5eaa07a78662c35ab2e49307b45290d7ddcdc +size 461420 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..b34d3ba00 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb69591909f3d791c2a346a5b3c98dceee0c95a5b03067be05ddaa7296a71a21 +size 497750 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..b34d3ba00 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-empty-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb69591909f3d791c2a346a5b3c98dceee0c95a5b03067be05ddaa7296a71a21 +size 497750 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..dc96ca488 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db53cf16f21409a327164922bf8d67a6db4cc2f8dcb61369a7aeb071ad13aa98 +size 182481 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..dc96ca488 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-failed-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db53cf16f21409a327164922bf8d67a6db4cc2f8dcb61369a7aeb071ad13aa98 +size 182481 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-innerPortrait_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-innerPortrait_duoInnerPortrait.png new file mode 100644 index 000000000..11b3d95ef --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-innerPortrait_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:399d9e353f3b4ea65bf488e4b869518c2d92090f636ce259ad4455f714b8a2cc +size 323895 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-tabletop_duoInnerPortrait.png b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-tabletop_duoInnerPortrait.png new file mode 100644 index 000000000..11b3d95ef --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/YearViewSnapshotTests/adaptiveLoadStates.Adaptive-loading-tabletop_duoInnerPortrait.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:399d9e353f3b4ea65bf488e4b869518c2d92090f636ce259ad4455f714b8a2cc +size 323895 diff --git a/Where/WhereUI/Sources/Developer/DeveloperDestination.swift b/Where/WhereUI/Sources/Developer/DeveloperDestination.swift index 5fac751d6..109961976 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperDestination.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperDestination.swift @@ -37,6 +37,7 @@ .tool(.logs), .tool(.openSpans), .flyover, + .tool(.duoLayoutLab), .tool(.regionMap), .tool(.crashTesting), ] diff --git a/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift b/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift index cc7abc891..69bbf330e 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift @@ -22,10 +22,9 @@ /// /// Attached once at ``RootView`` and compiled out of release (`#if DEBUG`). struct DeveloperOverlay: View { - /// The logged-in tab bar's height, measured by `MainTabs` and threaded in - /// via `RootView`, so the resting button clears the floating tab bar - /// without hardcoding its height. Zero when logged out. - private let tabBarInset: CGFloat + /// Native bar clearance measured on each edge by the active tab. + /// RootView passes it to this sibling overlay. Zero when logged out. + private let tabBarInset: EdgeInsets @State private var model: DeveloperOverlayModel @State private var dragOffset: CGSize = .zero @@ -43,24 +42,35 @@ @State private var windowResize: CGSize = .zero @Environment(\.stylesheet) private var stylesheet + // Gesture points and reserved-region frames use physical container coordinates. + @Environment(\.layoutDirection) private var layoutDirection @Environment(WhereDeveloperLaunchController.self) private var launchController: WhereDeveloperLaunchController? - init(tabBarInset: CGFloat = 0) { + init(tabBarInset: EdgeInsets = EdgeInsets()) { self.tabBarInset = tabBarInset _model = State(initialValue: DeveloperOverlayModel()) } - init(tabBarInset: CGFloat = 0, model: DeveloperOverlayModel) { + init(tabBarInset: EdgeInsets = EdgeInsets(), model: DeveloperOverlayModel) { self.tabBarInset = tabBarInset _model = State(initialValue: model) } var body: some View { - GeometryReader { proxy in + AdaptivePaneGeometryReader { geometry in + let leadingIsLeft = layoutDirection == .leftToRight + let preferred = CGPoint( + x: model.corner.isLeading == leadingIsLeft ? geometry.contentBounds + .minX : geometry.contentBounds.maxX, + y: model.corner.isTop ? geometry.contentBounds.minY : geometry.contentBounds + .maxY, + ) + let area = geometry.unobstructedBounds(near: preferred) + let container = area.size let style = stylesheet.developerOverlay - let anchor = anchorPoint(for: model.corner, in: proxy.size) - let menuFrame = menuFrame(anchor: anchor, in: proxy.size) + let anchor = anchorPoint(for: model.corner, in: container) + let menuFrame = menuFrame(anchor: anchor, in: container) ZStack { if model.presentation.isFullScreen { @@ -94,7 +104,7 @@ panel( tool: tool, isFullScreen: model.presentation.isFullScreen, - in: proxy, + in: container, ) } @@ -109,12 +119,14 @@ y: anchor.y + dragOffset.height, ) .simultaneousGesture( - dragGesture(in: proxy), + dragGesture(in: container), isEnabled: model.presentation == .collapsed, ) .transition(.scale.combined(with: .opacity)) } } + .frame(width: area.width, height: area.height) + .position(x: area.midX, y: area.midY) .animation(style.presentationAnimation, value: model.presentation) .accessibilityElement(children: .contain) .accessibilityAddTraits( @@ -124,9 +136,10 @@ // can inset the app content behind it. .preference( key: DeveloperOverlayInsetKey.self, - value: appContentInsets(in: proxy.size), + value: appContentInsets(in: geometry.contentBounds.size, area: area), ) } + .padding(tabBarInset) .fullScreenCover(isPresented: $isPresentingFlyover) { WhereFlyoverPresentationView() } @@ -185,21 +198,25 @@ } } - private func dragGesture(in proxy: GeometryProxy) -> some Gesture { + private func dragGesture(in container: CGSize) -> some Gesture { DragGesture(minimumDistance: 8) .onChanged { value in isDraggingButton = true dragOffset = value.translation } .onEnded { value in - let anchor = anchorPoint(for: model.corner, in: proxy.size) + let anchor = anchorPoint(for: model.corner, in: container) let dropPoint = CGPoint( x: anchor.x + value.translation.width, y: anchor.y + value.translation.height, ) let newCorner = DeveloperOverlayModel.nearestCorner( - to: dropPoint, - in: proxy.size, + to: CGPoint( + x: layoutDirection == .leftToRight ? dropPoint.x : container + .width - dropPoint.x, + y: dropPoint.y, + ), + in: container, ) withAnimation(stylesheet.developerOverlay.presentationAnimation) { model.setCorner(newCorner) @@ -214,10 +231,7 @@ } } - /// Resting center for the button in a given corner. `size` is already the - /// safe-area region (the `GeometryReader` respects the safe area), so the - /// only extra offset is the measured tab-bar height on the bottom corners - /// so the button clears the floating tab bar when logged in. + /// The container already excludes the window and tab bar's independent edges. private func anchorPoint( for corner: DeveloperOverlayModel.Corner, in size: CGSize, @@ -225,10 +239,12 @@ let halfWidth = buttonSize.width / 2 let halfHeight = buttonSize.height / 2 let edgeInset = stylesheet.developerOverlay.edgeInset - let leadingX = edgeInset + halfWidth - let trailingX = size.width - edgeInset - halfWidth + let leftX = edgeInset + halfWidth + let rightX = size.width - edgeInset - halfWidth + let leadingX = layoutDirection == .leftToRight ? leftX : rightX + let trailingX = layoutDirection == .leftToRight ? rightX : leftX let topY = edgeInset + halfHeight - let bottomY = size.height - tabBarInset - edgeInset - halfHeight + let bottomY = size.height - edgeInset - halfHeight switch corner { case .topLeading: return CGPoint(x: leadingX, y: topY) case .topTrailing: return CGPoint(x: trailingX, y: topY) @@ -254,7 +270,7 @@ endY = anchor.y - buttonHalfHeight - style.menu.launcherSpacing } let height = max(endY - startY, 0) - let minX = model.corner.isLeading + let minX = model.corner.isLeading == (layoutDirection == .leftToRight) ? style.edgeInset : container.width - style.edgeInset - menuWidth return CGRect(x: minX, y: startY, width: menuWidth, height: height) @@ -263,13 +279,13 @@ private func panel( tool: DeveloperTool, isFullScreen: Bool, - in proxy: GeometryProxy, + in container: CGSize, ) -> some View { let style = stylesheet.developerOverlay - let layout = displayedLayout(in: proxy.size) - let size = isFullScreen ? fullScreenSize(in: proxy.size) : layout.size + let layout = displayedLayout(in: container) + let size = isFullScreen ? fullScreenSize(in: container) : layout.size let center = isFullScreen - ? CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2) + ? CGPoint(x: container.width / 2, y: container.height / 2) : layout.center let shape = RoundedRectangle(cornerRadius: style.panel.cornerRadius) return DeveloperSurface( @@ -282,10 +298,10 @@ withAnimation(style.presentationAnimation) { model.closeTool() } }, onMove: { translation, ended in - moveWindow(by: translation, ended: ended, in: proxy.size) + moveWindow(by: translation, ended: ended, in: container) }, onResize: { translation, ended in - resizeWindow(by: translation, ended: ended, in: proxy.size) + resizeWindow(by: translation, ended: ended, in: container) }, ) .frame(width: size.width, height: size.height) @@ -387,11 +403,14 @@ /// app content clear of it. Zero unless floating; the docking math + cap /// live in ``DeveloperOverlayModel/contentInsets(for:in:edgeTolerance:)``, /// fed the on-screen (clamped) `displayedLayout`. - private func appContentInsets(in container: CGSize) -> EdgeInsets { + private func appContentInsets(in container: CGSize, area: CGRect) -> EdgeInsets { guard case .floating = model.presentation else { return EdgeInsets() } let style = stylesheet.developerOverlay + var layout = displayedLayout(in: area.size) + layout.center.x += area.minX + layout.center.y += area.minY return DeveloperOverlayModel.contentInsets( - for: displayedLayout(in: container), + for: layout, in: container, edgeTolerance: style.edgeInset, style: style.floatingWindow, diff --git a/Where/WhereUI/Sources/Developer/DeveloperTabBarInset.swift b/Where/WhereUI/Sources/Developer/DeveloperTabBarInset.swift index 8e04ab950..5c64e67f8 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperTabBarInset.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperTabBarInset.swift @@ -1,7 +1,7 @@ import SwiftUI extension View { - /// Report the logged-in tab bar's height up the tree so the DEBUG developer + /// Report the logged-in tab bar's edge insets up the tree so the DEBUG developer /// overlay can rest its collapsed button clear of the floating tab bar, /// measured from the live UI instead of a hardcoded guess. Applied to each /// tab's content in `MainTabs`. A no-op in release, where the overlay (and the @@ -18,19 +18,20 @@ extension View { #if DEBUG import UIKit - /// Carries the floating tab bar's height from `MainTabs` up to ``RootView``, + /// Carries the floating tab bar's edge insets from `MainTabs` up to ``RootView``, /// which hands it to the sibling ``DeveloperOverlay``. /// - /// A tab's content receives a bottom safe-area inset that spans the home - /// indicator *and* the floating tab bar; subtracting the window's own bottom - /// inset (the home indicator alone) leaves just the bar's height. Reduced with - /// `max` so whichever tab is on screen wins. When logged out there's no tab - /// bar in the tree, so the value stays at its `0` default. + /// Subtract the attached window's system insets independently on every edge. + /// Reduce each edge with `max` so the visible tab wins. Logged-out scenes report zero. struct DeveloperTabBarInsetKey: PreferenceKey { - static let defaultValue: CGFloat = 0 + static let defaultValue = EdgeInsets() - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) + static func reduce(value: inout EdgeInsets, nextValue: () -> EdgeInsets) { + let next = nextValue() + value.top = max(value.top, next.top) + value.leading = max(value.leading, next.leading) + value.bottom = max(value.bottom, next.bottom) + value.trailing = max(value.trailing, next.trailing) } } @@ -53,27 +54,106 @@ extension View { } private struct DeveloperTabBarInsetReporter: ViewModifier { + @State private var windowInsets = UIEdgeInsets.zero + /// The attached window supplies physical insets; convert them using the live direction. + @Environment(\.layoutDirection) private var layoutDirection + func body(content: Content) -> some View { content.background { GeometryReader { proxy in - Color.clear.preference( - key: DeveloperTabBarInsetKey.self, - value: max(0, proxy.safeAreaInsets.bottom - Self.windowBottomInset), - ) + Color.clear + .background { + DeveloperWindowInsetsReader { windowInsets = $0 } + } + .preference( + key: DeveloperTabBarInsetKey.self, + value: DeveloperBarInsets.additional( + content: proxy.safeAreaInsets, + window: windowInsets, + direction: layoutDirection, + ), + ) } } } + } + + /// Subtract only this host window's system edges, including a side-mounted status bar. + enum DeveloperBarInsets { + static func additional( + content: EdgeInsets, + window: UIEdgeInsets, + direction: LayoutDirection, + ) -> EdgeInsets { + EdgeInsets( + top: max(0, content.top - window.top), + leading: max( + 0, + content.leading - (direction == .leftToRight ? window.left : window.right), + ), + bottom: max(0, content.bottom - window.bottom), + trailing: max( + 0, + content.trailing - (direction == .leftToRight ? window.right : window.left), + ), + ) + } + } + + /// Reads the window containing this reporter. It never searches other scenes or screens. + struct DeveloperWindowInsetsReader: UIViewRepresentable { + let changed: (UIEdgeInsets) -> Void + + func makeUIView(context _: Context) -> InsetView { + InsetView() + } + + func updateUIView(_ view: InsetView, context _: Context) { + view.changed = changed + view.scheduleUpdate() + } + + static func dismantleUIView(_ view: InsetView, coordinator _: Void) { + view.pending?.cancel() + view.pending = nil + view.changed = nil + } + + final class InsetView: UIView { + var changed: ((UIEdgeInsets) -> Void)? + var pending: Task? + private var reported: UIEdgeInsets? + + override func didMoveToWindow() { + super.didMoveToWindow() + scheduleUpdate() + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + scheduleUpdate() + } - /// The window's own bottom safe-area inset (the home indicator), which the - /// tab content's inset sits on top of. Read from the key window because the - /// reporter lives inside the tab bar's inset and can't see the bare window - /// inset itself. - @MainActor private static var windowBottomInset: CGFloat { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first { $0.isKeyWindow }? - .safeAreaInsets.bottom ?? 0 + override func layoutSubviews() { + super.layoutSubviews() + scheduleUpdate() + } + + func scheduleUpdate() { + guard changed != nil, window != nil, pending == nil else { return } + pending = Task { @MainActor [weak self] in + // Dismantling already clears the cancelled task's handle. Do + // not let it clear a newer update if this view is reused. + guard !Task.isCancelled, let self else { return } + defer { pending = nil } + // UIKit can restore transient geometry before this deferred + // delivery runs. Publish the current window, not the sample + // that originally requested an update. + guard let insets = window?.safeAreaInsets, reported != insets else { return } + reported = insets + changed?(insets) + } + } } } #endif diff --git a/Where/WhereUI/Sources/Developer/DeveloperTool.swift b/Where/WhereUI/Sources/Developer/DeveloperTool.swift index 12c613a50..6b1808d54 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperTool.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperTool.swift @@ -12,6 +12,7 @@ case logs case openSpans case regionMap + case duoLayoutLab var id: Self { self @@ -27,6 +28,8 @@ String(localized: .developerOpenSpansLink) case .regionMap: String(localized: .developerRegionMapLink) + case .duoLayoutLab: + String(localized: .developerDuoTitle) } } @@ -36,6 +39,7 @@ case .logs: .ladybug case .openSpans: .timer case .regionMap: .map + case .duoLayoutLab: .rectangleSplit2x1 } } } diff --git a/Where/WhereUI/Sources/Developer/DeveloperToolView.swift b/Where/WhereUI/Sources/Developer/DeveloperToolView.swift index 63c5f2495..c456076df 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperToolView.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperToolView.swift @@ -20,49 +20,59 @@ import SFSafeSymbols @Environment(WhereModel.self) private var model: WhereModel? var body: some View { - NavigationStack { - switch tool { - case .crashTesting: - DeveloperCrashTestingView() + if tool == .duoLayoutLab { + toolContent + } else { + NavigationStack { toolContent } + } + } + + @ViewBuilder + private var toolContent: some View { + switch tool { + case .crashTesting: + DeveloperCrashTestingView() + + case .logs: + switch model?.logStoreState { + case let .ready(store): + PeriscopeViewer( + store: store, + title: String(localized: .developerLogsTitle), + ) - case .logs: - switch model?.logStoreState { - case let .ready(store): - PeriscopeViewer( - store: store, - title: String(localized: .developerLogsTitle), - ) + case .opening: + ContentUnavailableView( + String(localized: .developerLoggingOpeningTitle), + systemSymbol: tool.systemSymbol, + description: Text(String( + localized: .developerLoggingOpeningDescription, + )), + ) + .navigationTitle(tool.title) + .navigationBarTitleDisplayMode(.inline) - case .opening: - ContentUnavailableView( - String(localized: .developerLoggingOpeningTitle), - systemSymbol: tool.systemSymbol, - description: Text(String( - localized: .developerLoggingOpeningDescription, - )), - ) - .navigationTitle(tool.title) - .navigationBarTitleDisplayMode(.inline) + case let .failed(description): + ContentUnavailableView( + String(localized: .developerLoggingFailedTitle), + systemSymbol: .exclamationmarkTriangle, + description: Text(description), + ) + .navigationTitle(tool.title) + .navigationBarTitleDisplayMode(.inline) - case let .failed(description): - ContentUnavailableView( - String(localized: .developerLoggingFailedTitle), - systemSymbol: .exclamationmarkTriangle, - description: Text(description), - ) - .navigationTitle(tool.title) - .navigationBarTitleDisplayMode(.inline) + case .unavailable, nil: + DeveloperToolUnavailableView(tool: tool) + } - case .unavailable, nil: - DeveloperToolUnavailableView(tool: tool) - } + case .openSpans: + OpenSpansView(system: .shared) - case .openSpans: - OpenSpansView(system: .shared) + case .regionMap: + RegionMapView() - case .regionMap: - RegionMapView() - } + case .duoLayoutLab: + DuoLayoutLabView() } } } diff --git a/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutLabModel.swift b/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutLabModel.swift new file mode 100644 index 000000000..9d8a3feb4 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutLabModel.swift @@ -0,0 +1,37 @@ +#if DEBUG + import Observation + + /// Builds one separate demo world and retains its scene through every synthetic pose. + @MainActor @Observable + final class DuoLayoutLabModel { + enum State { + case idle + case loading + case loaded(WhereFlyoverWorld) + case failed(Error) + } + + private(set) var state = State.idle + private(set) var request = 0 + + func load() async { + guard case .idle = state else { return } + state = .loading + do { + let world = try await WhereFlyoverWorld.build() + try Task.checkCancellation() + state = .loaded(world) + } catch is CancellationError { + state = .idle + } catch { + state = .failed(error) + } + } + + func retry() { + guard case .failed = state else { return } + state = .idle + request += 1 + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutLabView.swift b/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutLabView.swift new file mode 100644 index 000000000..1e12d9d11 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutLabView.swift @@ -0,0 +1,135 @@ +#if DEBUG + import RegionKit + import SnapshotKit + import SwiftUI + + /// A DEBUG-only mounted production scene. Pose controls change geometry, never its models. + struct DuoLayoutLabView: View { + @State private var model: DuoLayoutLabModel + @State private var pose: SnapshotConfiguration.DuoPose + private let initialRegion: Region? + + init( + model: DuoLayoutLabModel = DuoLayoutLabModel(), + pose: SnapshotConfiguration.DuoPose = .outerPortrait, + initialRegion: Region? = nil, + ) { + _model = State(initialValue: model) + _pose = State(initialValue: pose) + self.initialRegion = initialRegion + } + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + VStack(spacing: stylesheet.spacing.medium) { + Text(String(localized: .developerDuoTitle)) + .font(.headline) + Picker(String(localized: .developerDuoPose), selection: $pose) { + ForEach(SnapshotConfiguration.DuoPose.allCases) { pose in + Text(pose.title).tag(pose) + } + } + .pickerStyle(.menu) + Group { + switch model.state { + case .idle, .loading: + ProgressView(String(localized: .primaryLoading)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .loaded(world): + preview(world: world) + case let .failed(error): + ContentUnavailableView { + Label( + String(localized: .commonLoadErrorTitle), + systemSymbol: .exclamationmarkTriangle, + ) + } description: { + Text(error.localizedDescription) + } actions: { + Button(String(localized: .developerDuoRetry), action: model.retry) + } + } + } + Text(String(localized: .developerDuoNote)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(stylesheet.spacing.medium) + .background(.background) + .task(id: model.request) { await model.load() } + } + + private func preview(world: WhereFlyoverWorld) -> some View { + GeometryReader { proxy in + let insets = pose.frame.safeAreaInsets + let scale = min( + 1, + min(proxy.size.width / pose.size.width, proxy.size.height / pose.size.height), + ) + MainTabs(world: world, region: initialRegion) + .environment(world.model) + .environment(world.session) + .environment(\.isInDemoMode, true) + .environment(\.demoExitPresentation, .embedded) + .safeAreaPadding(EdgeInsets( + top: insets.top, + leading: insets.leading, + bottom: insets.bottom, + trailing: insets.trailing, + )) + .frame(width: pose.size.width, height: pose.size.height) + .background(.background) + .adaptivePaneReservedRegions(pose.regions) + .whereBroadwayRoot() + .snapshotTraits(SnapshotConfiguration(device: pose.frame)) + .frame(width: pose.size.width, height: pose.size.height) + .scaleEffect(scale, anchor: .top) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) + .clipped() + } + } + } + + extension DuoLayoutLabView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + [SnapshotConfiguration.DuoPose.outerPortrait, .tabletop].map { pose in + let fixture = DuoLayoutSnapshotFixture(tab: .locations, selection: .region) + return whereSnapshot( + name: pose.rawValue, + configurations: [SnapshotConfiguration(device: .viewport( + name: "iPad", + size: CGSize(width: 834, height: 1194), + layoutTraits: .init( + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + interfaceIdiom: .pad, + ), + scenePolicy: .retained, + ))], + onReadyToSnapshot: { await fixture.prepare() }, + ) { + DuoLayoutLabView( + model: fixture.model, + pose: pose, + initialRegion: .california, + ) + .environment(\.regionOutlinePathCache, fixture.artwork) + } + } + } + } + + #Preview { DuoLayoutLabView.snapshotPreviews } + + extension DuoLayoutLabView: WhereFlyoverProviding { + static let flyoverData = WhereFlyoverData.hosted( + DuoLayoutLabView.self, + title: "Duo Layout Lab", + navigationContainer: .none, + ) { _ in + DuoLayoutLabView() + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutSnapshotFixture.swift b/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutSnapshotFixture.swift new file mode 100644 index 000000000..54521fc6c --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DuoLayout/DuoLayoutSnapshotFixture.swift @@ -0,0 +1,106 @@ +#if DEBUG + import SnapshotKit + import SwiftUI + import WhereCore + + /// Each capture configuration gets its own demo world, navigation, and artwork readiness. + @MainActor + final class DuoLayoutSnapshotFixture { + static let browseColumn = SnapshotConfiguration.Frame.fullContent( + name: "browseColumn", + width: WhereStylesheet.default.adaptivePane.minimumControlsWidth, + minimumHeight: 669, + layoutTraits: .init( + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + interfaceIdiom: .phone, + ), + ) + static let historyColumn = SnapshotConfiguration.Frame.fullContent( + name: "historyColumn", + width: WhereStylesheet.default.adaptivePane.minimumDetailWidth, + minimumHeight: 669, + layoutTraits: .init( + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + interfaceIdiom: .phone, + ), + ) + + enum Selection: Equatable { + case none + case region + case timeline + case settings + } + + let model = DuoLayoutLabModel() + let artwork = RegionOutlinePathCache() + let tab: MainTabSelection.TabID + let selection: Selection + + init(tab: MainTabSelection.TabID, selection: Selection) { + self.tab = tab + self.selection = selection + } + + func prepare() async { + await model.load() + while case .loading = model.state { + guard !Task.isCancelled else { return } + await Task.yield() + } + guard case let .loaded(world) = model.state else { return } + for region in LocationsBackgroundArtwork.regions(in: world.report.ranking) { + _ = await artwork.path(for: region, resolution: .small) + _ = await artwork.path(for: region, resolution: .medium) + _ = await artwork.path(for: region, resolution: .micro) + } + } + } + + #Preview { + DuoLayoutSnapshotScene( + fixture: DuoLayoutSnapshotFixture(tab: .locations, selection: .region), + pose: .innerPortrait, + ) + .whereBroadwayRoot() + .snapshotTraits(SnapshotConfiguration(device: .duoInnerPortrait)) + } + + struct DuoLayoutSnapshotScene: View { + let fixture: DuoLayoutSnapshotFixture + let pose: SnapshotConfiguration.DuoPose + + var body: some View { + Group { + switch fixture.model.state { + case .idle, .loading: + ProgressView(String(localized: .primaryLoading)) + case let .loaded(world): + MainTabs( + world: world, + tab: fixture.tab, + region: fixture.selection == .region ? .california : nil, + settingsRoute: fixture + .selection == .settings ? SettingsRoute(.appearance) : nil, + yearMode: fixture.selection == .timeline ? .timeline : .calendar, + ) + .environment(world.model) + .environment(world.session) + .environment(\.isInDemoMode, true) + .environment(\.demoExitPresentation, .embedded) + .environment(\.regionOutlinePathCache, fixture.artwork) + case let .failed(error): + ContentUnavailableView( + String(localized: .commonLoadErrorTitle), + systemSymbol: .exclamationmarkTriangle, + description: Text(error.localizedDescription), + ) + } + } + .adaptivePaneReservedRegions(pose.regions) + .task { await fixture.prepare() } + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift index cdb86dc58..39cc54fb4 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverCatalog.swift @@ -120,6 +120,7 @@ WhereFlyoverLogView.flyoverData, OpenSpansView.flyoverData, RegionMapView.flyoverData, + DuoLayoutLabView.flyoverData, ] } diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverHost.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverHost.swift index 322e77a77..054ced184 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverHost.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverHost.swift @@ -11,6 +11,7 @@ .environment(world.model) .environment(world.session) .environment(\.isInDemoMode, true) + .environment(\.demoExitPresentation, .embedded) .whereBroadwayRoot(regionStyles: world.session.regionStyles) } } diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index fde47cbfb..e293b8f76 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -1,6 +1,7 @@ #if DEBUG @_spi(Testing) import InstallerKitUI #endif +import BroadwayUI import InstallerKit import RegionKit import SFSafeSymbols @@ -21,8 +22,7 @@ import SwiftUI /// checked wiring); the always-on `WhereSession` coordinator stays in the /// environment. struct MainTabs: View { - /// Identity for the tab-bar selection. - private typealias TabID = MainTabSelection.TabID + typealias TabID = MainTabSelection.TabID private struct WelcomeTaskID: Hashable { let isActive: Bool @@ -34,6 +34,9 @@ struct MainTabs: View { @State private var welcome: LocationWelcomeModel @State private var planningDestination: PlannedStaysDestination? @State private var selection = MainTabSelection() + private var initialRegion: Region? + private var initialSettingsRoute: SettingsRoute? + private var initialYearMode: YearMode = .calendar @Environment(UpdateModel.self) private var updates: UpdateModel? @Environment(\.isInDemoMode) private var isInDemoMode @Environment(\.scenePhase) private var scenePhase @@ -72,8 +75,12 @@ struct MainTabs: View { systemSymbol: .locationFill, value: TabID.locations, ) { - LocationsView(report: report, openElsewhere: { selection.selected = .elsewhere }) - .reportingDeveloperTabBarInset() + LocationsView( + report: report, + initialRegion: initialRegion, + openElsewhere: { selection.selected = .elsewhere }, + ) + .reportingDeveloperTabBarInset() } if report.hasElsewhere { @@ -88,13 +95,17 @@ struct MainTabs: View { } Tab(String(localized: .tabYear), systemSymbol: .calendar, value: TabID.year) { - YearView(report: report) + YearView(report: report, initialMode: initialYearMode) .reportingDeveloperTabBarInset() } Tab(value: TabID.settings) { - SettingsView(report: report, recordingWarning: recordingWarning) - .reportingDeveloperTabBarInset() + SettingsView( + report: report, + recordingWarning: recordingWarning, + initialRoute: initialSettingsRoute, + ) + .reportingDeveloperTabBarInset() } label: { RecordingConfigurationWarningTabLabel( model: recordingWarning, @@ -105,6 +116,7 @@ struct MainTabs: View { .badge((recordingWarning.isPresented ? 1 : 0) + ((!isInDemoMode && updates?.needsAttention == true) ? 1 : 0)) } + .bScrollRestorationScope() .accessibilityHidden(welcomePresentation != nil) .onChange(of: report.hasElsewhere, initial: true) { _, hasElsewhere in selection.reconcile(hasElsewhere: hasElsewhere) @@ -208,6 +220,29 @@ struct MainTabs: View { _welcome = State(initialValue: welcome) _selection = State(initialValue: MainTabSelection(selected: selection)) } + + /// Retains the production shell around a separately built in-memory lab world. + init( + world: WhereFlyoverWorld, + tab: TabID = .locations, + region: Region? = nil, + settingsRoute: SettingsRoute? = nil, + yearMode: YearMode = .calendar, + ) { + self.init( + session: world.session, + report: world.report, + welcome: LocationWelcomeModel( + services: world.scope.services, + preferences: world.scope.preferences, + now: world.session.now, + ), + selection: tab, + ) + initialRegion = region + initialSettingsRoute = settingsRoute + initialYearMode = yearMode + } #endif } @@ -227,7 +262,7 @@ struct MainTabs: View { configurations: [configuration], settle: .settledAtLeast(minDuration: 1.0), ) - } + [elsewhereSnapshot(), elsewhereSnapshot(theme: .alternate)] + } + [elsewhereSnapshot(), elsewhereSnapshot(theme: .alternate)] + adaptiveSnapshots } private static func elsewhereSnapshot(theme: WhereTheme = .standard) -> SnapshotCase { @@ -311,6 +346,94 @@ struct MainTabs: View { } } + /// Viewport composition supplements the full-content Locations, calendar, timeline, + /// and Settings matrices. Synthetic regions exercise the production layout policy. + private static var adaptiveSnapshots: [SnapshotCase] { + let tabs: [MainTabSelection.TabID] = [.locations, .year, .settings] + var cases = tabs.flatMap { tab in + SnapshotConfiguration.DuoPose.allCases.map { pose in + adaptiveSnapshot( + tab: tab, + pose: pose, + configuration: SnapshotConfiguration(device: pose.frame), + selection: .none, + ) + } + } + for tab in tabs { + let pose = SnapshotConfiguration.DuoPose.innerPortrait + let configurations = [ + SnapshotConfiguration(colorScheme: .dark, device: pose.frame), + SnapshotConfiguration(dynamicType: .accessibility5, device: pose.frame), + SnapshotConfiguration(layoutDirection: .rightToLeft, device: pose.frame), + SnapshotConfiguration(contrast: .increased, device: pose.frame), + SnapshotConfiguration(device: pose.frame, snapshotType: .accessibility), + ] + cases += configurations.map { configuration in + adaptiveSnapshot( + tab: tab, + pose: pose, + configuration: configuration, + selection: .none, + ) + } + } + for pose in [SnapshotConfiguration.DuoPose.innerPortrait, .tabletop, .book] { + cases.append(adaptiveSnapshot( + tab: .locations, + pose: pose, + configuration: SnapshotConfiguration(device: pose.frame), + selection: .region, + )) + cases.append(adaptiveSnapshot( + tab: .year, + pose: pose, + configuration: SnapshotConfiguration(device: pose.frame), + selection: .timeline, + )) + } + cases.append(adaptiveSnapshot( + tab: .settings, + pose: .innerPortrait, + configuration: SnapshotConfiguration(device: .duoInnerPortrait), + selection: .settings, + )) + return cases + } + + private static func adaptiveSnapshot( + tab: MainTabSelection.TabID, + pose: SnapshotConfiguration.DuoPose, + configuration: SnapshotConfiguration, + selection: DuoLayoutSnapshotFixture.Selection, + ) -> SnapshotCase { + let tabName = switch tab { + case .locations: "Locations" + case .elsewhere: "Elsewhere" + case .year: "Year" + case .settings: "Settings" + } + let fixture = DuoLayoutSnapshotFixture(tab: tab, selection: selection) + let selectionName = switch selection { + case .none: "" + case .region, .settings: "-Selected" + case .timeline: "-Timeline" + } + return whereSnapshot( + name: "Adaptive-" + tabName + "-" + pose.rawValue + selectionName, + configurations: [configuration], + // Native glass can retain its light material after the async + // shell appears; match the measured Appearance/About floor. + settle: .settledAtLeast(minDuration: 1.5), + onReadyToSnapshot: { + await fixture.prepare() + + }, + ) { + DuoLayoutSnapshotScene(fixture: fixture, pose: pose) + } + } + private static func snapshotCases( configurations: [SnapshotConfiguration], settle: SnapshotSettle, diff --git a/Where/WhereUI/Sources/Model/CalendarModel.swift b/Where/WhereUI/Sources/Model/CalendarModel.swift new file mode 100644 index 000000000..3aed17fcc --- /dev/null +++ b/Where/WhereUI/Sources/Model/CalendarModel.swift @@ -0,0 +1,91 @@ +import Foundation +import RegionKit +import WhereCore + +/// Interprets calendar data and reads/writes the existing per-calendar zoom preference. +@MainActor +struct CalendarModel { + let report: YearReportModel + let persistenceKey: CalendarZoomKey? + + func isOverview(fallback: Bool) -> Bool { + persistenceKey.flatMap { report.preferences.calendarOverview(forKey: $0) } ?? fallback + } + + func setOverview(_ value: Bool) { + guard let persistenceKey else { return } + report.preferences.setCalendarOverview(value, forKey: persistenceKey) + } + + /// Inputs that invalidate a cached month grid. + struct LoadID: Equatable { + let report: YearReport + let missingDayKeys: Set + let evidenceDayKeys: Set + let flightDayKeys: Set + let referenceDay: Date + let focusedRegion: Region? + } + + func loadID(for yearReport: YearReport, focusedRegion: Region?) -> LoadID { + LoadID( + report: yearReport, + missingDayKeys: report.missingDayKeys, + evidenceDayKeys: report.evidenceDayKeys, + flightDayKeys: report.flightDayKeys, + referenceDay: report.calendar.startOfDay(for: report.referenceDate), + focusedRegion: focusedRegion, + ) + } + + func months( + from yearReport: YearReport, + focusedRegion: Region?, + ) -> Result<[CalendarMonth], Error> { + Result { + try yearReport.calendarMonths( + calendar: report.calendar, + referenceDate: report.referenceDate, + missingDates: report.missingDayKeys, + evidenceDays: report.evidenceDayKeys, + flightDays: report.flightDayKeys, + focusedRegion: focusedRegion, + ) + } + } + + /// The months to show in chronological order. Future months are omitted + /// unless a planned stay reaches into them; a past year shows the full year. + static func visibleMonths( + _ months: [CalendarMonth], + report: YearReportModel, + focusedRegion: Region?, + ) -> [CalendarMonth] { + guard + let currentMonthStart = report.calendar + .dateInterval(of: .month, for: report.referenceDate)? + .start + else { + return months + } + let lastPlannedDay = report.showsEstimatedTimeAndPlanning + ? report.forecasts.plannedIntervals(intersecting: report.selectedYear) + .filter { focusedRegion == nil || $0.region == focusedRegion } + .map(\.end).max() + : nil + let showsHome = report.showsEstimatedTimeAndPlanning + && report.forecasts.planning.homeRegion != nil + && (focusedRegion == nil || report.forecasts.planning.homeRegion == focusedRegion) + let finalDay = showsHome ? CalendarDay.lastDay(ofYear: report.selectedYear) : lastPlannedDay + if finalDay == nil, + report.selectedYear > report.calendar.component(.year, from: report.referenceDate) + { + // The year selector retains this selection after plans are hidden or deleted. + return months + } + let lastShownMonth = finalDay.map { + CalendarDay(year: $0.year, month: $0.month, day: 1).startOfDay(in: report.calendar) + }.map { max(currentMonthStart, $0) } ?? currentMonthStart + return months.filter { $0.startOfMonth <= lastShownMonth } + } +} diff --git a/Where/WhereUI/Sources/Model/CalendarPresentationModel.swift b/Where/WhereUI/Sources/Model/CalendarPresentationModel.swift deleted file mode 100644 index c4f023a59..000000000 --- a/Where/WhereUI/Sources/Model/CalendarPresentationModel.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -import Observation -import RegionKit -import WhereCore - -/// Owns calendar zoom intent and restores only destinations present in the detailed calendar. -@MainActor -@Observable -final class CalendarPresentationModel { - enum Mode: Hashable { - case detail, overview - } - - enum Target: Hashable { - case month(CalendarDay) - case estimate(CalendarDay) - - var month: CalendarDay { - switch self { - case let .month(month), let .estimate(month): month - } - } - } - - // Ratios relative to the start of one completed pinch. Small motions do not switch modes. - private static let overviewThreshold: CGFloat = 0.8 - private static let detailThreshold: CGFloat = 1.2 - - private(set) var mode: Mode { - didSet { - guard oldValue != mode, let persistenceKey else { return } - preferences.setCalendarOverview(mode == .overview, forKey: persistenceKey) - } - } - - private(set) var target: Target? - private(set) var year: Int? - private(set) var availableMonths: Set = [] - private(set) var estimateMonth: CalendarDay? - private(set) var rejectedTarget: Target? - private(set) var rejectionCount = 0 - - private let preferences: WherePreferences - private let persistenceKey: CalendarZoomKey? - - init(mode: Mode, preferences: WherePreferences, persistenceKey: CalendarZoomKey?) { - self.preferences = preferences - self.persistenceKey = persistenceKey - self.mode = persistenceKey.flatMap { preferences.calendarOverview(forKey: $0) } - .map { $0 ? .overview : .detail } ?? mode - } - - func reconcile( - year: Int, - availableMonths: [CalendarDay], - currentMonth: CalendarDay?, - estimateMonth: CalendarDay?, - ) { - let changedYear = self.year != year - self.year = year - self.availableMonths = Set(availableMonths) - self.estimateMonth = estimateMonth - if changedYear || target.map({ !isAvailable($0) }) != false { - target = (currentMonth.flatMap { self.availableMonths.contains($0) ? $0 : nil } - ?? availableMonths.last).map(Target.month) - } - if changedYear { rejectedTarget = nil } - } - - func isAvailable(_ target: Target) -> Bool { - switch target { - case let .month(month): availableMonths.contains(month) - case let .estimate(month): estimateMonth == month - } - } - - func remember(_ target: Target) { - guard mode == .detail, isAvailable(target) else { return } - self.target = target - } - - @discardableResult - func expand(_ destination: Target?) -> Bool { - guard let destination = destination ?? target else { return false } - guard isAvailable(destination) else { - rejectedTarget = destination - rejectionCount += 1 - return false - } - target = destination - mode = .detail - return true - } - - func toggle() { - switch mode { - case .detail: mode = .overview - case .overview: expand(nil) - } - } - - func finishPinch(magnification: CGFloat, destination: Target?) { - switch mode { - case .detail: - if magnification <= Self.overviewThreshold { mode = .overview } - case .overview: - if magnification >= Self.detailThreshold { expand(destination) } - } - } - - /// The months to show in chronological order. Future months are omitted - /// unless a planned stay reaches into them; a past year shows the full year. - static func visibleMonths( - _ months: [CalendarMonth], - report: YearReportModel, - focusedRegion: Region?, - ) -> [CalendarMonth] { - guard - let currentMonthStart = report.calendar - .dateInterval(of: .month, for: report.referenceDate)? - .start - else { - return months - } - let lastPlannedDay = report.showsEstimatedTimeAndPlanning - ? report.forecasts.plannedIntervals(intersecting: report.selectedYear) - .filter { focusedRegion == nil || $0.region == focusedRegion } - .map(\.end).max() - : nil - let showsHome = report.showsEstimatedTimeAndPlanning - && report.forecasts.planning.homeRegion != nil - && (focusedRegion == nil || report.forecasts.planning.homeRegion == focusedRegion) - let finalDay = showsHome ? CalendarDay.lastDay(ofYear: report.selectedYear) : lastPlannedDay - if finalDay == nil, - report.selectedYear > report.calendar.component(.year, from: report.referenceDate) - { - // The year selector retains this selection after plans are hidden or deleted. - return months - } - let lastShownMonth = finalDay.map { - CalendarDay(year: $0.year, month: $0.month, day: 1).startOfDay(in: report.calendar) - }.map { max(currentMonthStart, $0) } ?? currentMonthStart - return months.filter { $0.startOfMonth <= lastShownMonth } - } -} diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index bf0f8bb1a..4cc803523 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -1,3 +1,4 @@ +import BroadwayUI import PeriscopeCore import RegionKit import SFSafeSymbols @@ -11,6 +12,20 @@ import SwiftUI /// navigation: the Your Year tab embeds it inline, and the Locations tab pushes /// it — region-focused, with a title — as the zoom destination of a tapped card. struct CalendarContentView: View { + enum Presentation: Equatable { case year, month } + @Binding private var monthReveal: CalendarMonthReveal? + private let allowsMonthSelection: Bool + + let displayMode: Presentation + enum ScrollRole: Hashable { case standalone, locations, year } + struct ScrollContext: Hashable { let region: Region?; let year: Int } + private let model: CalendarModel + private let suppliedPlanningDestination: Binding? + @State private var localPlanningDestination: PlannedStaysDestination? + private var planningDestination: Binding { + suppliedPlanningDestination ?? $localPlanningDestination + } + /// When set, the day grid only shows dots for this region (so it reads as /// "just the days I spent here"); the per-month footer still lists every /// region. `nil` shows every region's dots. @@ -20,43 +35,73 @@ struct CalendarContentView: View { @Environment(\.stylesheet) private var stylesheet @State private var monthsLoad: Result<[CalendarMonth], Error>? - @State private var planningDestination: PlannedStaysDestination? - @State private var presentation: CalendarPresentationModel - @AccessibilityFocusState private var focusedTarget: CalendarPresentationModel.Target? - @State private var zoom = CalendarZoomTransitionModel() + @State private var localPresentation: CalendarPresentationState + private let suppliedPresentation: CalendarPresentationState? + private var presentation: CalendarPresentationState { + suppliedPresentation ?? localPresentation + } + + @AccessibilityFocusState private var focusedTarget: CalendarPresentationState.Target? + @State private var zoom = CalendarZoomTransitionState() + @BScrollPosition< + CalendarPresentationState.Target, + CalendarContentView.ScrollContext + > private var detailPosition: ScrollPosition + @State private var overviewPosition = ScrollPosition( + idType: CalendarPresentationState.Target.self, + edge: .top, + ) + @State private var tabletopPosition = ScrollPosition( + idType: CalendarPresentationState.Target.self, + edge: .top, + ) + private var detailTarget: CalendarPresentationState + .Target? + { + detailPosition.viewID(type: CalendarPresentationState.Target.self) + } + + private var calendarPosition: Binding { + if displayMode == .month { return $tabletopPosition } + return presentation.mode == .overview ? $overviewPosition : $detailPosition + } + @State private var scrollOffset = CGPoint.zero /// A nil persistence key keeps zoom session-only, including in previews. init( focusedRegion: Region? = nil, report: YearReportModel, - initialMode: CalendarPresentationModel.Mode = .detail, + monthReveal: Binding? = nil, + presentationState: CalendarPresentationState? = nil, + presentation: Presentation = .year, + initialMode: CalendarPresentationState.Mode = .detail, persistenceKey: CalendarZoomKey? = nil, + scrollRole: ScrollRole = .standalone, + planningDestination: Binding? = nil, ) { self.focusedRegion = focusedRegion self.report = report - _presentation = State(initialValue: CalendarPresentationModel( - mode: initialMode, - preferences: report.preferences, - persistenceKey: persistenceKey, - )) + _monthReveal = monthReveal ?? .constant(nil) + allowsMonthSelection = monthReveal != nil + suppliedPresentation = presentationState + displayMode = presentation + suppliedPlanningDestination = planningDestination + let model = CalendarModel(report: report, persistenceKey: persistenceKey) + self.model = model + _localPresentation = + State(initialValue: CalendarPresentationState(mode: model + .isOverview(fallback: initialMode == .overview) ? .overview : .detail)) + _detailPosition = BScrollPosition( + Self.self, + role: scrollRole, + context: { ScrollContext(region: focusedRegion, year: report.selectedYear) }, + idType: CalendarPresentationState.Target.self, + ) } - @State private var scrollPosition = ScrollPosition(idType: CalendarPresentationModel.Target - .self) - private static let logger = WhereLog.session(CalendarViewLog.self) - /// Inputs that invalidate a cached month grid. - private struct CalendarLoadID: Equatable { - let report: YearReport - let missingDayKeys: Set - let evidenceDayKeys: Set - let flightDayKeys: Set - let referenceDay: Date - let focusedRegion: Region? - } - var body: some View { Group { if let yearReport = report.report { @@ -70,9 +115,9 @@ struct CalendarContentView: View { AppIconLoadingView(caption: String(localized: .primaryLoading)) } } - .task(id: calendarLoadID(report: yearReport)) { + .task(id: model.loadID(for: yearReport, focusedRegion: focusedRegion)) { zoom.cancel() - let result = loadCalendarMonths(from: yearReport) + let result = model.months(from: yearReport, focusedRegion: focusedRegion) guard !Task.isCancelled else { return } monthsLoad = result } @@ -109,23 +154,28 @@ struct CalendarContentView: View { // Log View Mode: reveal an inspect badge for this calendar's events. A // no-op in release. .debugLogInspectable(WhereLog.session(CalendarViewLog.self)) - .sheet(item: $planningDestination) { destination in - PlannedStaysDestinationView(destination: destination, report: report) - } + .modifier(PlanningPresentation( + report: report, + destination: planningDestination, + isEnabled: suppliedPlanningDestination == nil, + )) + .toolbar(displayMode == .month ? .hidden : .automatic, for: .navigationBar) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - changeZoom { presentation.toggle() } - } label: { - Label( - presentation - .mode == .detail ? String(localized: .calendarShowYear) : - String(localized: .calendarShowMonths), - systemSymbol: presentation - .mode == .detail ? .squareGrid3x3 : .rectangleGrid1x2, - ) + if displayMode == .year { + ToolbarItem(placement: .topBarTrailing) { + Button { + changeZoom { toggleZoom() } + } label: { + Label( + presentation + .mode == .detail ? String(localized: .calendarShowYear) : + String(localized: .calendarShowMonths), + systemSymbol: presentation + .mode == .detail ? .squareGrid3x3 : .rectangleGrid1x2, + ) + } + .disabled(monthsLoad == nil || zoom.phase != nil) } - .disabled(monthsLoad == nil || zoom.phase != nil) } if report.showsEstimatedTimeAndPlanning { ToolbarItem(placement: .topBarTrailing) { @@ -133,40 +183,17 @@ struct CalendarContentView: View { String(localized: .plannedStaysTitle), systemSymbol: .calendarBadgeClock, ) { - planningDestination = .list + planningDestination.wrappedValue = .list } } } } .onChange(of: report.selectedYear) { zoom.cancel() } + .onChange(of: presentation.mode) { model.setOverview(presentation.mode == .overview) } .onChange(of: stylesheet.calendar.overview.usesSpatialMotion) { zoom.cancel() } .onDisappear { zoom.cancel() } } - private func calendarLoadID(report yearReport: YearReport) -> CalendarLoadID { - CalendarLoadID( - report: yearReport, - missingDayKeys: report.missingDayKeys, - evidenceDayKeys: report.evidenceDayKeys, - flightDayKeys: report.flightDayKeys, - referenceDay: report.calendar.startOfDay(for: report.referenceDate), - focusedRegion: focusedRegion, - ) - } - - private func loadCalendarMonths(from yearReport: YearReport) -> Result<[CalendarMonth], Error> { - Result { - try yearReport.calendarMonths( - calendar: report.calendar, - referenceDate: report.referenceDate, - missingDates: report.missingDayKeys, - evidenceDays: report.evidenceDayKeys, - flightDays: report.flightDayKeys, - focusedRegion: focusedRegion, - ) - } - } - private func calendarLayoutError(_ error: Error) -> some View { ContentUnavailableView { Label(String(localized: .commonLoadErrorTitle), systemSymbol: .exclamationmarkIcloud) @@ -181,14 +208,19 @@ struct CalendarContentView: View { } private func calendarContent(months: [CalendarMonth]) -> some View { - let visibleMonths = CalendarPresentationModel.visibleMonths( + let availableMonths = CalendarModel.visibleMonths( months, report: report, focusedRegion: focusedRegion, ) let currentMonth = months.first(where: \.isCurrentMonth) let estimateMonth = months.first(where: showsForecast(after:)) - let overview = presentation.mode == .overview + let visibleMonths = displayMode == .year ? availableMonths : months.filter { + monthKey($0) == + (selectedMonth ?? detailTarget?.month ?? currentMonth + .map(monthKey) ?? availableMonths.last.map(monthKey)) + } + let overview = displayMode == .year && presentation.mode == .overview return ScrollView { Group { @@ -221,17 +253,17 @@ struct CalendarContentView: View { } .id(target(for: month)) .accessibilityFocused($focusedTarget, equals: target(for: month)) - if showsForecast(after: month) { + if displayMode == .year, showsForecast(after: month) { LocationForecastPanel( forecasts: calendarForecasts, microprintRegions: report.ranking.primary.map(\.region), homeRegion: report.forecasts.planning.homeRegion, - planningAction: { planningDestination = .list }, + planningAction: { planningDestination.wrappedValue = .list }, ) .anchorPreference(key: CalendarZoomFrames.self, value: .bounds) { [.detail: [.tile(.estimate(monthKey(month))): $0]] } - .id(CalendarPresentationModel.Target.estimate(monthKey(month))) + .id(CalendarPresentationState.Target.estimate(monthKey(month))) .accessibilityFocused( $focusedTarget, equals: .estimate(monthKey(month)), @@ -250,9 +282,10 @@ struct CalendarContentView: View { frames[overview ? .overview : .detail, default: [:]][.content] = content } } + // Each mode keeps its own binding while outgoing content finishes its transition. + .scrollPosition(calendarPosition) .id(presentation.mode) .transition(stylesheet.calendar.overview.transition) - .scrollPosition($scrollPosition) .onScrollGeometryChange(for: CGPoint.self) { CGPoint( x: $0.contentOffset.x + $0.contentInsets.leading, @@ -261,10 +294,6 @@ struct CalendarContentView: View { } action: { _, value in scrollOffset = value } - .onScrollTargetVisibilityChange(idType: CalendarPresentationModel.Target.self) { targets in - if zoom.phase == nil, scrollPosition.isPositionedByUser, - let first = targets.first { presentation.remember(first) } - } .simultaneousGesture(MagnifyGesture().onEnded { value in let viewportSize = zoom.measurement?.viewport.size ?? .zero let point = CGPoint( @@ -275,8 +304,9 @@ struct CalendarContentView: View { .first(where: { $0.key.month.year == report.selectedYear && $0.value.contains(point) })?.key + guard displayMode == .year else { return } changeZoom { - presentation.finishPinch( + finishPinch( magnification: value.magnification, destination: destination, ) @@ -290,49 +320,52 @@ struct CalendarContentView: View { .task(id: CalendarLayoutID( year: report.selectedYear, mode: presentation.mode, - availableMonths: visibleMonths.map(monthKey), + displayMode: displayMode, + availableMonths: availableMonths.map(monthKey), estimateMonth: estimateMonth.map(monthKey), )) { guard !Task.isCancelled else { return } - let isInitial = presentation.year == nil presentation.reconcile( year: report.selectedYear, - availableMonths: visibleMonths.map(monthKey), - currentMonth: currentMonth.map(monthKey), + availableMonths: (displayMode == .month ? months : availableMonths).map(monthKey), estimateMonth: estimateMonth.map(monthKey), ) - if !overview, let target = presentation.target { - let initialTarget = isInitial ? estimateMonth - .map { CalendarPresentationModel.Target.estimate(monthKey($0)) } ?? target : - target - if zoom.phase == nil { - scrollPosition.scrollTo(id: initialTarget, anchor: isInitial ? .bottom : .top) - if !isInitial { focusedTarget = target } + let initialTarget = estimateMonth + .map { CalendarPresentationState.Target.estimate(monthKey($0)) } + ?? (currentMonth ?? availableMonths.last) + .map { CalendarPresentationState.Target.month(monthKey($0)) } + if let initialTarget { + _detailPosition.initialize(id: initialTarget, anchor: .bottom) + if let target = detailTarget, !presentation.isAvailable(target) { + _detailPosition.scrollTo(id: initialTarget, anchor: .bottom) } - } else { - scrollPosition.scrollTo(edge: .top) } - } - .overlayPreferenceValue(CalendarZoomFrames.self) { anchors in - GeometryReader { proxy in - let measured = CalendarZoomFrames.measure( - anchors[overview ? .overview : .detail] ?? [:], - in: proxy, - mode: overview ? .overview : .detail, - scrollOffset: scrollOffset, + if applyReveal() { return } + if displayMode == .year, presentation.mode == .detail, let target = detailTarget { + // A new native scroll view must receive the command after its content mounts. + _detailPosition.scrollTo(id: target, anchor: _detailPosition.anchor ?? .top) + } + if displayMode == .month, let month = selectedMonth ?? detailTarget?.month { + tabletopPosition.scrollTo( + id: CalendarPresentationState.Target.month(month), + anchor: .top, ) - Color.clear - .onChange(of: measured, initial: true) { _, value in - zoom.updateMeasurement(value) - if case .preparing = zoom.phase, - let value, value.mode == .detail, - let target = presentation.target, value.frames[target] != nil - { - scrollPosition.scrollTo(id: target, anchor: .top) - } - } } - .allowsHitTesting(false) + } + .onChange(of: monthReveal?.id, initial: true) { _ = applyReveal() } + .overlayPreferenceValue(CalendarZoomFrames.self) { anchors in + Color.clear + .onGeometryChange(for: CalendarZoomTransitionState.Measurement?.self) { proxy in + CalendarZoomFrames.measure( + anchors[overview ? .overview : .detail] ?? [:], + in: proxy, + mode: overview ? .overview : .detail, + scrollOffset: scrollOffset, + ) + } action: { value in + zoom.updateMeasurement(value) + } + .allowsHitTesting(false) } .allowsHitTesting(zoom.phase == nil) .accessibilityHidden(zoom.phase != nil) @@ -342,16 +375,76 @@ struct CalendarContentView: View { zoomTile(target, mode: mode, months: months) }, completion: { id in guard zoom.finish(id) else { return } - if presentation.mode == .detail { focusedTarget = presentation.target } + if presentation.mode == .detail { focusedTarget = detailTarget } }) .background(stylesheet.calendar.overview.zoomBackground) } } } + @discardableResult + private func applyReveal() -> Bool { + guard let request = monthReveal, request.isPending, + request.month.year == report.selectedYear, + presentation.year == report.selectedYear, + presentation.isAvailable(.month(request.month)) + else { return false } + zoom.cancel() + let target = CalendarPresentationState.Target.month(request.month) + if displayMode == .year { _ = presentation.expand(target) } + _detailPosition.scrollTo(id: target, anchor: .top) + tabletopPosition.scrollTo(id: target, anchor: .top) + monthReveal?.isPending = false + return true + } + + private var selectedMonth: CalendarDay? { + guard let month = monthReveal?.month, month.year == report.selectedYear else { return nil } + return month + } + + private var selectedInterval: ClosedRange? { + guard let month = selectedMonth, + let interval = report.calendar.dateInterval( + of: .month, + for: month.startOfDay(in: report.calendar), + ) + else { return nil } + return month ... CalendarDay(from: interval.end, in: report.calendar).adding(days: -1) + } + + private func expand(_ target: CalendarPresentationState.Target) { + guard presentation.expand(target) else { return } + _detailPosition.scrollTo(id: target, anchor: .top) + } + + private func toggleZoom() { + switch presentation.mode { + case .detail: presentation.mode = .overview + case .overview: + guard let target = detailTarget else { return } + _ = presentation.expand(target) + } + } + + private func finishPinch( + magnification: CGFloat, + destination: CalendarPresentationState.Target?, + ) { + switch presentation.mode { + case .detail: + if magnification <= CalendarPresentationState + .overviewThreshold { presentation.mode = .overview } + case .overview: + if magnification >= CalendarPresentationState.detailThreshold, + let destination { expand(destination) } + } + } + private struct CalendarLayoutID: Equatable { let year: Int - let mode: CalendarPresentationModel.Mode + let mode: CalendarPresentationState.Mode + let displayMode: Presentation let availableMonths: [CalendarDay] let estimateMonth: CalendarDay? } @@ -371,30 +464,27 @@ struct CalendarContentView: View { if stylesheet.calendar.overview.usesSpatialMotion, let source, source.mode == previousMode { - zoom.begin(from: source, to: presentation.mode, focus: presentation.target) + zoom.begin( + from: source, + to: presentation.mode, + focus: detailTarget, + alignment: _detailPosition.anchor ?? .top, + ) } zoom.updateMeasurement(nil) - switch presentation.mode { - case .overview: - scrollPosition = ScrollPosition( - idType: CalendarPresentationModel.Target.self, - edge: .top, - ) - case .detail: - if zoom.phase != nil { - scrollPosition = ScrollPosition(idType: CalendarPresentationModel.Target - .self) - } else if let target = presentation.target { - scrollPosition = ScrollPosition(id: target, anchor: .top) - } + if presentation.mode == .overview { + overviewPosition = ScrollPosition( + idType: CalendarPresentationState.Target.self, + edge: .top, + ) } } } @ViewBuilder private func zoomTile( - _ target: CalendarPresentationModel.Target, - mode: CalendarPresentationModel.Mode, + _ target: CalendarPresentationState.Target, + mode: CalendarPresentationState.Mode, months: [CalendarMonth], ) -> some View { switch target { @@ -411,7 +501,8 @@ struct CalendarContentView: View { forecasts: calendarForecasts, microprintRegions: report.ranking.primary.map(\.region), homeRegion: report.forecasts.planning.homeRegion, - planningAction: mode == .detail ? { planningDestination = .list } : nil, + planningAction: mode == .detail ? { planningDestination.wrappedValue = .list } : + nil, ) } } @@ -420,7 +511,7 @@ struct CalendarContentView: View { CalendarDay(year: month.year, month: month.month, day: 1) } - private func target(for month: CalendarMonth) -> CalendarPresentationModel.Target { + private func target(for month: CalendarMonth) -> CalendarPresentationState.Target { .month(monthKey(month)) } @@ -438,12 +529,17 @@ struct CalendarContentView: View { .plannedRegionSummaries(in: month) : [], compact: compact, isAvailable: isAvailable, + displayMode: displayMode, + selectedInterval: selectedInterval, + selectMonth: compact || !allowsMonthSelection ? nil : { + monthReveal = CalendarMonthReveal(month: monthKey(month)) + }, ) } private func overviewMonth(_ month: CalendarMonth, available: Bool) -> some View { let destination = target(for: month) - return Button { changeZoom { presentation.expand(destination) } } label: { + return Button { changeZoom { expand(destination) } } label: { monthView(month, compact: true, isAvailable: available) } .buttonStyle(.plain) @@ -459,8 +555,8 @@ struct CalendarContentView: View { } private func overviewEstimate(_ month: CalendarMonth) -> some View { - let destination = CalendarPresentationModel.Target.estimate(monthKey(month)) - return Button { changeZoom { presentation.expand(destination) } } label: { + let destination = CalendarPresentationState.Target.estimate(monthKey(month)) + return Button { changeZoom { expand(destination) } } label: { LocationForecastPanel( forecasts: calendarForecasts, microprintRegions: report.ranking.primary.map(\.region), @@ -494,6 +590,21 @@ struct CalendarContentView: View { } } +/// Adds default activation while preserving the month card's full accessibility frame. +private struct MonthSelectionAccessibility: ViewModifier { + let action: (() -> Void)? + + func body(content: Content) -> some View { + if let action { + content + .accessibilityAddTraits(.isButton) + .accessibilityAction { action() } + } else { + content + } + } +} + /// One month section: weekday header row, a day grid, and a footer tallying the /// days spent in each region that month. private struct MonthGridView: View { @@ -506,6 +617,10 @@ private struct MonthGridView: View { var compact = false var isAvailable = true + var displayMode: CalendarContentView.Presentation = .year + var selectedInterval: ClosedRange? + var selectMonth: (() -> Void)? + @Environment(\.stylesheet) private var stylesheet private var calendar: WhereStylesheet.CalendarStyle { @@ -548,17 +663,68 @@ private struct MonthGridView: View { var body: some View { let monthAccessibility = accessibility - VStack(alignment: .leading, spacing: calendar.month.sectionSpacing) { - Text(monthName) - .font(compact ? .system(size: calendar.overview.titleFontSize, weight: .semibold) : - .title.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(isAvailable ? card.foreground : calendar.overview - .unavailableForeground) + Group { + if displayMode == .month { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: calendar.month.summarySpacing) { + VStack(alignment: .leading, spacing: calendar.month.sectionSpacing) { + monthHeading; monthSummary + } + .frame(width: calendar.month.summaryWidth) + dayGrid.frame(minWidth: calendar.month.minimumGridWidth) + } + VStack(alignment: .leading, spacing: calendar.month.sectionSpacing) { + monthHeading; dayGrid; monthSummary + } + } + } else { + VStack(alignment: .leading, spacing: calendar.month.sectionSpacing) { + monthHeading; dayGrid; monthSummary + } + } + } + .padding(calendar.month.padding) + .foregroundStyle(card.foreground) + .background { + // Transparent month cards share the screen background. The current + // month stands out through its accent text and heavier border. + cardShape + .fill(card.fill) + .overlay { + cardShape.strokeBorder(card.border, lineWidth: card.borderWidth) + } + } + .contentShape(cardShape) + .accessibilityElement(children: .ignore) + .accessibilityLabel(monthAccessibility.label) + .accessibilityValue(monthAccessibility.value) + .modifier(MonthSelectionAccessibility(action: selectMonth)) + } - dayGrid + private var monthHeading: some View { + Group { + if let selectMonth { Button(monthName, action: selectMonth).buttonStyle(.plain) } + else { Text(monthName) } + } + .font(compact ? .system(size: calendar.overview.titleFontSize, weight: .semibold) : + .title.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(isAvailable ? card.foreground : calendar.overview + .unavailableForeground) + } - if !compact, !month.regionTotals.isEmpty { + private var monthSummary: some View { + Group { + if displayMode == .month, let focusedRegion { + MonthFooter( + totals: [month.regionTotals + .first { $0.region == focusedRegion } ?? RegionDayTally( + region: focusedRegion, + days: 0, + )], + focusedRegion: focusedRegion, + ) + } else if !compact, !month.regionTotals.isEmpty { MonthFooter(totals: month.regionTotals, focusedRegion: focusedRegion) } ForEach( @@ -589,21 +755,6 @@ private struct MonthGridView: View { .foregroundStyle(.secondary) } } - .padding(calendar.month.padding) - .foregroundStyle(card.foreground) - .background { - // Transparent month cards share the screen background. The current - // month stands out through its accent text and heavier border. - cardShape - .fill(card.fill) - .overlay { - cardShape.strokeBorder(card.border, lineWidth: card.borderWidth) - } - } - .contentShape(cardShape) - .accessibilityElement(children: .ignore) - .accessibilityLabel(monthAccessibility.label) - .accessibilityValue(monthAccessibility.value) } /// A month has at most six weeks. Eager rows keep its intrinsic height exact @@ -614,7 +765,8 @@ private struct MonthGridView: View { .weekdayCount return Grid( horizontalSpacing: calendar.month.gridSpacing, - verticalSpacing: calendar.month.gridSpacing, + verticalSpacing: displayMode == .month ? calendar.month.tabletopGridSpacing : calendar + .month.gridSpacing, ) { GridRow { ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in @@ -637,6 +789,10 @@ private struct MonthGridView: View { compact: compact, isAvailable: isAvailable, ) + .presenceSelection(selectedInterval?.contains(CalendarDay( + from: month.days[index].date, + in: dateCalendar, + )) == true) } else { Color.clear .frame(height: calendar.day.minHeight) @@ -1032,6 +1188,15 @@ private struct DayCell: View { #if DEBUG extension CalendarContentView: SnapshotProviding { static var snapshots: [SnapshotCase] { + whereSnapshot( + name: "HistoryColumn", + configurations: [SnapshotConfiguration(device: DuoLayoutSnapshotFixture + .historyColumn)], + ) { + NavigationStack { + CalendarContentView(report: PreviewSupport.loadedYearReportModel()) + } + } whereSnapshot( name: "FlightAndEvidenceBadges", configurations: .componentRepresentative, diff --git a/Where/WhereUI/Sources/Primary/CalendarMonthReveal.swift b/Where/WhereUI/Sources/Primary/CalendarMonthReveal.swift new file mode 100644 index 000000000..665ed91bc --- /dev/null +++ b/Where/WhereUI/Sources/Primary/CalendarMonthReveal.swift @@ -0,0 +1,15 @@ +import Foundation +import WhereCore + +/// An explicit month command. Repeating a destination creates a new request identity. +/// Keep the consumed request to display the selected month without replaying it on remount. +struct CalendarMonthReveal: Identifiable { + struct ID: Hashable { private let value = UUID() } + let id = ID() + let month: CalendarDay + var isPending = true + + init(month: CalendarDay) { + self.month = CalendarDay(year: month.year, month: month.month, day: 1) + } +} diff --git a/Where/WhereUI/Sources/Primary/CalendarPresentationState.swift b/Where/WhereUI/Sources/Primary/CalendarPresentationState.swift new file mode 100644 index 000000000..05124a7a0 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/CalendarPresentationState.swift @@ -0,0 +1,57 @@ +import Foundation +import Observation +import WhereCore + +/// View-owned zoom and rejection feedback. The native detail position owns restoration. +@MainActor @Observable +final class CalendarPresentationState { + enum Mode: Hashable { case detail, overview } + enum Target: Hashable { + case month(CalendarDay) + case estimate(CalendarDay) + var month: CalendarDay { + switch self { + case let .month(month), let .estimate(month): month + } + } + } + + // Completed pinch ratios relative to the start of the gesture. + static let overviewThreshold: CGFloat = 0.8 + static let detailThreshold: CGFloat = 1.2 + var mode: Mode + private(set) var year: Int? + private(set) var availableMonths: Set = [] + private(set) var estimateMonth: CalendarDay? + private(set) var rejectedTarget: Target? + private(set) var rejectionCount = 0 + + init(mode: Mode) { + self.mode = mode + } + + func reconcile(year: Int, availableMonths: [CalendarDay], estimateMonth: CalendarDay?) { + if self.year != year { rejectedTarget = nil } + self.year = year + self.availableMonths = Set(availableMonths) + self.estimateMonth = estimateMonth + } + + func isAvailable(_ target: Target) -> Bool { + switch target { + case let .month(month): availableMonths.contains(month) + case let .estimate(month): estimateMonth == month + } + } + + func expand(_ destination: Target?) -> Bool { + guard let destination else { return false } + guard isAvailable(destination) else { + rejectedTarget = destination + rejectionCount += 1 + return false + } + mode = .detail + return true + } +} diff --git a/Where/WhereUI/Sources/Primary/CalendarZoomOverlay.swift b/Where/WhereUI/Sources/Primary/CalendarZoomOverlay.swift index 13e835ca4..0affab29f 100644 --- a/Where/WhereUI/Sources/Primary/CalendarZoomOverlay.swift +++ b/Where/WhereUI/Sources/Primary/CalendarZoomOverlay.swift @@ -2,16 +2,16 @@ import SwiftUI /// Moves fixed-layout copies of the visible tiles while the real scroll view stays positioned. struct CalendarZoomOverlay: View { - let phase: CalendarZoomTransitionModel.Phase - let content: (CalendarPresentationModel.Target, CalendarPresentationModel.Mode) -> TileContent - let completion: (CalendarZoomTransitionModel.ID) -> Void + let phase: CalendarZoomTransitionState.Phase + let content: (CalendarPresentationState.Target, CalendarPresentationState.Mode) -> TileContent + let completion: (CalendarZoomTransitionState.ID) -> Void var body: some View { ZStack(alignment: .topLeading) { switch phase { case let .preparing(source): ForEach( - source.visibleFrames.keys.sorted(by: CalendarZoomTransitionModel.ordered), + source.visibleFrames.keys.sorted(by: CalendarZoomTransitionState.ordered), id: \.self, ) { target in if let frame = source.visibleFrames[target] { @@ -33,9 +33,9 @@ struct CalendarZoomOverlay: View { } private struct CalendarZoomFlight: View { - let plan: CalendarZoomTransitionModel.Plan - let content: (CalendarPresentationModel.Target, CalendarPresentationModel.Mode) -> TileContent - let completion: (CalendarZoomTransitionModel.ID) -> Void + let plan: CalendarZoomTransitionState.Plan + let content: (CalendarPresentationState.Target, CalendarPresentationState.Mode) -> TileContent + let completion: (CalendarZoomTransitionState.ID) -> Void @Environment(\.stylesheet) private var stylesheet @State private var arrived = false @@ -91,7 +91,7 @@ private struct CalendarZoomFlight: View { } } - private func cornerRadius(for target: CalendarPresentationModel.Target) -> CGFloat { + private func cornerRadius(for target: CalendarPresentationState.Target) -> CGFloat { switch target { case .month: let mode = arrived ? plan.source.destination : plan.source.measurement.mode @@ -102,7 +102,7 @@ private struct CalendarZoomFlight: View { } } - private func backgroundOpacity(for visibility: CalendarZoomTransitionModel.Tile.Visibility) + private func backgroundOpacity(for visibility: CalendarZoomTransitionState.Tile.Visibility) -> Double { switch visibility { @@ -114,8 +114,8 @@ private struct CalendarZoomFlight: View { } struct CalendarZoomFrames: PreferenceKey { - typealias Mode = CalendarPresentationModel.Mode - typealias Target = CalendarPresentationModel.Target + typealias Mode = CalendarPresentationState.Mode + typealias Target = CalendarPresentationState.Target enum Frame: Hashable { case content case tile(Target) @@ -128,7 +128,7 @@ struct CalendarZoomFrames: PreferenceKey { in proxy: GeometryProxy, mode: Mode, scrollOffset: CGPoint, - ) -> CalendarZoomTransitionModel.Measurement? { + ) -> CalendarZoomTransitionState.Measurement? { guard let content = anchors[.content] else { return nil } let contentFrame = proxy[content] // Resolve anchors relative to their content, then apply the current scroll diff --git a/Where/WhereUI/Sources/Model/CalendarZoomTransitionModel.swift b/Where/WhereUI/Sources/Primary/CalendarZoomTransitionState.swift similarity index 92% rename from Where/WhereUI/Sources/Model/CalendarZoomTransitionModel.swift rename to Where/WhereUI/Sources/Primary/CalendarZoomTransitionState.swift index cf64e07b6..eea1bf6d1 100644 --- a/Where/WhereUI/Sources/Model/CalendarZoomTransitionModel.swift +++ b/Where/WhereUI/Sources/Primary/CalendarZoomTransitionState.swift @@ -5,9 +5,9 @@ import SwiftUI /// Matching uses viewport rectangles, so moving tiles cannot alter scroll-target layout. @MainActor @Observable -final class CalendarZoomTransitionModel { - typealias Mode = CalendarPresentationModel.Mode - typealias Target = CalendarPresentationModel.Target +final class CalendarZoomTransitionState { + typealias Mode = CalendarPresentationState.Mode + typealias Target = CalendarPresentationState.Target /// Points: ignore subpixel rounding in viewport sizes and scroll-edge alignment. private nonisolated static let geometryTolerance: CGFloat = 1 @@ -26,6 +26,7 @@ final class CalendarZoomTransitionModel { let measurement: Measurement let destination: Mode let focus: Target? + let alignment: UnitPoint var visibleFrames: [Target: CGRect] { measurement.frames.filter { $0.value.intersects(measurement.viewport) } @@ -76,7 +77,12 @@ final class CalendarZoomTransitionModel { if let measurement { resolve(destination: measurement) } } - func begin(from measurement: Measurement, to destination: Mode, focus: Target?) { + func begin( + from measurement: Measurement, + to destination: Mode, + focus: Target?, + alignment: UnitPoint = .top, + ) { guard phase == nil, measurement.mode != destination, measurement.frames.values.contains(where: { $0.intersects(measurement.viewport) }) else { return } @@ -86,6 +92,7 @@ final class CalendarZoomTransitionModel { measurement: measurement, destination: destination, focus: focus, + alignment: alignment, )) } @@ -109,7 +116,12 @@ final class CalendarZoomTransitionModel { case .detail: guard let focus = source.focus, let frame = destination.frames[focus], frame.intersects(destination.viewport), - abs(frame.minY) < Self.geometryTolerance + abs(frame + .minY - + (destination.viewport + .minY + (destination.viewport.height - frame.height) * source + .alignment.y)) < + Self.geometryTolerance || destination.content.maxY <= destination.viewport.maxY + Self .geometryTolerance else { return } diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift index 8292ac616..590535d4c 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift @@ -22,7 +22,11 @@ struct LocationWelcomeOverlay: View { .transition(.opacity) .zIndex(0) - GeometryReader { proxy in + AdaptivePaneGeometryReader { geometry in + let area = geometry.unobstructedBounds(near: CGPoint( + x: geometry.contentBounds.midX, + y: geometry.contentBounds.maxY, + )) ScrollView { RegionWelcomeCard( presentation: presentation, @@ -32,10 +36,12 @@ struct LocationWelcomeOverlay: View { .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, stylesheet.spacing.xxxLarge) .padding(.vertical, stylesheet.spacing.xxxLarge) - .frame(maxWidth: .infinity, minHeight: proxy.size.height) + .frame(maxWidth: .infinity, minHeight: area.height) .accessibilityFocused($isCardFocused) } .scrollBounceBehavior(.basedOnSize) + .frame(width: area.width, height: area.height) + .position(x: area.midX, y: area.midY) } .contentShape(Rectangle()) .accessibilityAddTraits(.isModal) diff --git a/Where/WhereUI/Sources/Primary/LocationsCardsView.swift b/Where/WhereUI/Sources/Primary/LocationsCardsView.swift index fab7e938d..f8f7a7567 100644 --- a/Where/WhereUI/Sources/Primary/LocationsCardsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsCardsView.swift @@ -1,3 +1,4 @@ +import BroadwayUI import RegionKit import SwiftUI import WhereCore @@ -12,6 +13,42 @@ struct LocationsCardsView: View { let isCardSurfaceUncovered: Bool @Binding var isCardSurfaceVisible: Bool let planningAction: () -> Void + @Bindable var navigation: LocationsView.Navigation + let cardVariant: WhereStylesheet.CardStyle.Variant + let showsSelection: Bool + + @BScrollPosition private var position: ScrollPosition + enum ScrollRole: Hashable { case browse } + + init( + report: YearReportModel, + cardPresentation: LocationCardsPresentationModel, + tilt: TiltProvider, + calendarTransition: Namespace.ID, + isCardSurfaceUncovered: Bool, + isCardSurfaceVisible: Binding, + planningAction: @escaping () -> Void, + navigation: LocationsView.Navigation, + cardVariant: WhereStylesheet.CardStyle.Variant, + showsSelection: Bool, + ) { + self.report = report + self.cardPresentation = cardPresentation + self.tilt = tilt + self.calendarTransition = calendarTransition + self.isCardSurfaceUncovered = isCardSurfaceUncovered + _isCardSurfaceVisible = isCardSurfaceVisible + self.planningAction = planningAction + self.navigation = navigation + self.cardVariant = cardVariant + self.showsSelection = showsSelection + _position = BScrollPosition( + Self.self, + role: ScrollRole.browse, + context: { report.selectedYear }, + idType: Region.self, + ) + } @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles @@ -23,7 +60,7 @@ struct LocationsCardsView: View { // a short card list retains its centered presentation. return ScrollView { VStack(spacing: stylesheet.spacing.xxLarge) { - LocationsFlightNotice(report: report) + LocationsFlightNotice(report: report, openReview: { navigation.open(.flight($0)) }) LocationCardRankingStack( spacing: stylesheet.spacing.xxLarge, @@ -31,11 +68,12 @@ struct LocationsCardsView: View { motion: stylesheet.locationCardStack.overtake, ) { ForEach(presentedCards) { item in - NavigationLink { - calendarDestination(item.region) + Button { + navigation.select(region: item.region) } label: { RegionSummaryCard( regionDays: item, + variant: cardVariant, interactive: true, yearLength: report.daysInSelectedYear, estimatedDays: estimatedDays(for: item.region), @@ -47,7 +85,7 @@ struct LocationsCardsView: View { recordedPointsID: report.primaryRegionLocations?.id, ) } - .buttonStyle(stylesheet.card.regular.buttonStyle( + .buttonStyle(stylesheet.card[cardVariant].buttonStyle( tint: regionStyles.style(for: item.region).tint, )) // The card is the zoom source: tapping it expands the @@ -60,7 +98,7 @@ struct LocationsCardsView: View { id: item.region, in: calendarTransition, ) { source in - let card = stylesheet.card.regular + let card = stylesheet.card[cardVariant] let tint = regionStyles.style(for: item.region).tint let elevation = card.transitionElevation(tint: tint) return source @@ -87,15 +125,27 @@ struct LocationsCardsView: View { motion: stylesheet.locationCardStack.overtake, ) .locationCardRankingRegion(item.region) + .id(item.region) + .presenceSelection( + showsSelection && navigation.selectedRegion == item.region, + cornerRadius: stylesheet.card[cardVariant].cornerRadius, + ) } } + .scrollTargetLayout() + LocationsEstimatePanel(report: report, planningAction: planningAction) } .padding() .frame(maxWidth: .infinity) } - .defaultScrollAnchor(report.liveFlightReview == nil ? .center : .top) + .defaultScrollAnchor(.top, for: .initialOffset) + .defaultScrollAnchor(report.liveFlightReview == nil ? .center : .top, for: .alignment) + .scrollPosition($position) + .onChange(of: report.selectedYear, initial: true) { + _position.initialize(edge: .top) + } .scrollBounceBehavior(.basedOnSize) // The card normally stays clipped to the scrolling viewport. Reveal // overflow only while the authored arc, scale, and rotation need it. @@ -118,18 +168,6 @@ struct LocationsCardsView: View { guard report.showsEstimatedTimeAndPlanning else { return nil } return report.forecasts.forecast(for: region, report: report.report)?.estimatedTotalDays } - - /// The region's calendar, pushed as a nested view. It's the zoom - /// destination: the tapped card expands into it via matched geometry, and the - /// stack's back gesture collapses it again. - private func calendarDestination(_ region: Region) -> some View { - CalendarContentView(focusedRegion: region, report: report, persistenceKey: .region(region)) - .navigationTitle( - WhereFormat.calendarRegionTitle(region: region, year: report.selectedYear), - ) - .navigationBarTitleDisplayMode(.inline) - .navigationTransition(.zoom(sourceID: region, in: calendarTransition)) - } } #if DEBUG diff --git a/Where/WhereUI/Sources/Primary/LocationsFlightNotice.swift b/Where/WhereUI/Sources/Primary/LocationsFlightNotice.swift index d21e74bec..d8e97dfac 100644 --- a/Where/WhereUI/Sources/Primary/LocationsFlightNotice.swift +++ b/Where/WhereUI/Sources/Primary/LocationsFlightNotice.swift @@ -1,20 +1,26 @@ import SwiftUI +import WhereCore /// Keeps the flight link and its destination behind one small value boundary. struct LocationsFlightNotice: View { let report: YearReportModel + var openReview: ((GPSCorrectionReview) -> Void)? var body: some View { if let review = report.liveFlightReview { - NavigationLink { - FlightDayDetailView(review: review, report: report) - } label: { - FlightStatusBanner( - review: review, - deviceLabel: report.liveFlightAssessment(in: review) - .map(report.flightDeviceLabel), - flight: report.liveFlightAssessment(in: review), - ) + let banner = FlightStatusBanner( + review: review, + deviceLabel: report.liveFlightAssessment(in: review).map(report.flightDeviceLabel), + flight: report.liveFlightAssessment(in: review), + ) + Group { + if let openReview { + Button { openReview(review) } label: { banner } + } else { + NavigationLink { FlightDayDetailView(review: review, report: report) } label: { + banner + } + } } .buttonStyle(.plain) .accessibilityHint(String(localized: .flightStatusOpenReview)) diff --git a/Where/WhereUI/Sources/Primary/LocationsStateView.swift b/Where/WhereUI/Sources/Primary/LocationsStateView.swift index 4d2e897f6..42b3505a8 100644 --- a/Where/WhereUI/Sources/Primary/LocationsStateView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsStateView.swift @@ -1,10 +1,12 @@ import SwiftUI +import WhereCore /// Bounds loading, failure, and empty-state construction separately from the /// navigation shell. Keep the state payload behind its own view boundary. struct LocationsStateView: View { let report: YearReportModel let planningAction: () -> Void + var openReview: ((GPSCorrectionReview) -> Void)? var openElsewhere: (() -> Void)? @Environment(\.stylesheet.spacing) private var spacing @@ -12,7 +14,7 @@ struct LocationsStateView: View { var body: some View { ScrollView { VStack(spacing: spacing.xxLarge) { - LocationsFlightNotice(report: report) + LocationsFlightNotice(report: report, openReview: openReview) LocationsStateContent( report: report, planningAction: planningAction, diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index cdd2aae5b..180b4682e 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -1,4 +1,5 @@ import BroadwayUI +import Observation import PeriscopeCore import RegionKit import SFSafeSymbols @@ -6,14 +7,274 @@ import SnapshotKit import SwiftUI import WhereCore -/// Locations tab: configured primary regions with recorded days in the selected -/// year, ranked by day count, plus planning and data-resolution actions. +/// Stable destination ownership around native browse/detail and current-history tabletop layouts. struct LocationsView: View { + @MainActor @Observable + final class Navigation { + enum Destination: Hashable { + case flight(GPSCorrectionReview) + } + + var selectedRegion: Region? + var compactColumn = NavigationSplitViewColumn.sidebar + var browsePath = NavigationPath() + var detailPath = NavigationPath() + + var showingResolution = false + var planningDestination: PlannedStaysDestination? + + func select(region: Region) { + guard selectedRegion != region || compactColumn != .detail else { return } + if selectedRegion != region { detailPath = NavigationPath() } + selectedRegion = region + compactColumn = .detail + } + + func open(_ destination: Destination) { + browsePath.append(destination) + compactColumn = .sidebar + } + } + + let report: YearReportModel + var openElsewhere: (() -> Void)? + @State private var navigation: LocationsView.Navigation + @State private var monthReveal: CalendarMonthReveal? + @State private var calendarPresentation: CalendarPresentationState + @State private var cardPresentation: LocationCardsPresentationModel + @State private var tilt = TiltProvider() + @Namespace private var calendarTransition + @Environment(\.stylesheet) private var stylesheet + + init( + report: YearReportModel, + navigation: LocationsView.Navigation? = nil, + initialRegion: Region? = nil, + openElsewhere: (() -> Void)? = nil, + ) { + self.report = report + self.openElsewhere = openElsewhere + let navigation = navigation ?? LocationsView.Navigation() + if let initialRegion { navigation.select(region: initialRegion) } + _navigation = State(initialValue: navigation) + _calendarPresentation = State(initialValue: CalendarPresentationState(mode: CalendarModel( + report: report, + persistenceKey: navigation.selectedRegion.map(CalendarZoomKey.region), + ).isOverview(fallback: false) ? .overview : .detail)) + _cardPresentation = State(initialValue: LocationCardsPresentationModel( + preferences: report.preferences, + year: report.selectedYear, + )) + } + + var body: some View { + @Bindable var navigation = navigation + AdaptivePaneContainer { arrangement in + if let panes = tabletopPanes(for: arrangement) { + TabletopPaneLayout(panes: panes) { + calendar(presentation: .month, usesZoomTransition: false) + } controls: { + NavigationStack { + browseContent + .safeAreaInset(edge: .top, spacing: 0) { + MonthNavigationControls( + report: report, + monthReveal: $monthReveal, + region: navigation.selectedRegion, + scrollRole: .locations, + ) + } + } + } + } else { + NavigationSplitView( + columnVisibility: .constant(.all), + preferredCompactColumn: $navigation.compactColumn, + ) { + LocationsBrowsePane( + report: report, + navigation: navigation, + cardPresentation: cardPresentation, + tilt: tilt, + calendarTransition: calendarTransition, + cardVariant: arrangement == .compact ? .regular : .column, + showsSelection: arrangement != .compact, + openElsewhere: openElsewhere, + ) + .navigationSplitViewColumnWidth( + min: stylesheet.adaptivePane.minimumControlsWidth, + ideal: stylesheet.adaptivePane.minimumControlsWidth, + ) + } detail: { + calendar( + presentation: .year, + usesZoomTransition: arrangement == .compact, + ) + .navigationSplitViewColumnWidth( + min: stylesheet.adaptivePane.minimumDetailWidth, + ideal: stylesheet.adaptivePane.minimumDetailWidth, + ) + } + .navigationSplitViewStyle(.balanced) + .environment(\.horizontalSizeClass, arrangement == .compact ? .compact : .regular) + .bHorizontalSizeClass(arrangement == .compact ? .compact : .regular) + } + } + .sheet(isPresented: $navigation.showingResolution) { + ResolutionView(report: report) + } + .modifier(PlanningPresentation( + report: report, + destination: $navigation.planningDestination, + )) + .onChange(of: navigation.selectedRegion) { + monthReveal = nil + calendarPresentation = CalendarPresentationState(mode: CalendarModel( + report: report, + persistenceKey: navigation.selectedRegion.map(CalendarZoomKey.region), + ).isOverview(fallback: false) ? .overview : .detail) + } + .onChange(of: report.selectedYear) { monthReveal = nil } + } + + private var browseContent: some View { + LocationsContent( + report: report, + navigation: navigation, + cardPresentation: cardPresentation, + tilt: tilt, + calendarTransition: calendarTransition, + showsSelection: true, + openElsewhere: openElsewhere, + ) + } + + /// Drill-ins keep their native stack mounted while their local editors are open. + /// The custom tabletop composition belongs to the top-level history surface. + private func tabletopPanes(for arrangement: AdaptivePaneArrangement) + -> AdaptivePaneArrangement.Panes? + { + guard navigation.browsePath.isEmpty, navigation.detailPath.isEmpty, + case let .tabletop(panes) = arrangement else { return nil } + return panes + } + + private func calendar( + presentation: CalendarContentView.Presentation, + usesZoomTransition: Bool, + ) -> some View { + @Bindable var navigation = navigation + return NavigationStack(path: $navigation.detailPath) { + CalendarContentView( + focusedRegion: navigation.selectedRegion, + report: report, + monthReveal: $monthReveal, + presentationState: calendarPresentation, + presentation: presentation, + persistenceKey: navigation.selectedRegion.map(CalendarZoomKey.region), + scrollRole: .locations, + planningDestination: $navigation.planningDestination, + ) + .id(navigation.selectedRegion) + .navigationTitle(navigation.selectedRegion.map { + WhereFormat.calendarRegionTitle(region: $0, year: report.selectedYear) + } ?? String(localized: .primaryCalendar)) + .navigationBarTitleDisplayMode(.inline) + } + .modifier(RegionCalendarTransition( + region: usesZoomTransition ? navigation.selectedRegion : nil, + namespace: calendarTransition, + )) + } +} + +/// Native split navigation uses the same card zoom when its detail collapses to a push. +private struct RegionCalendarTransition: ViewModifier { + let region: Region? + let namespace: Namespace.ID + + func body(content: Content) -> some View { + if let region { + content.navigationTransition(.zoom(sourceID: region, in: namespace)) + } else { + content + } + } +} + +/// The stable browse stack. Pushed flows retain their native navigation and draft state. +struct LocationsBrowsePane: View { + let report: YearReportModel + var openElsewhere: (() -> Void)? + @State private var navigation: LocationsView.Navigation + @State private var cardPresentation: LocationCardsPresentationModel + @State private var tilt: TiltProvider + @Namespace private var localTransition + private let transition: Namespace.ID? + let cardVariant: WhereStylesheet.CardStyle.Variant + let showsSelection: Bool + + init( + report: YearReportModel, + navigation: LocationsView.Navigation? = nil, + cardPresentation: LocationCardsPresentationModel? = nil, + tilt: TiltProvider? = nil, + calendarTransition: Namespace.ID? = nil, + cardVariant: WhereStylesheet.CardStyle.Variant = .regular, + showsSelection: Bool = false, + openElsewhere: (() -> Void)? = nil, + ) { + self.report = report + self.openElsewhere = openElsewhere + _navigation = State(initialValue: navigation ?? LocationsView.Navigation()) + _cardPresentation = State(initialValue: cardPresentation ?? LocationCardsPresentationModel( + preferences: report.preferences, + year: report.selectedYear, + )) + _tilt = State(initialValue: tilt ?? TiltProvider()) + transition = calendarTransition + self.cardVariant = cardVariant + self.showsSelection = showsSelection + } + + var body: some View { + @Bindable var navigation = navigation + NavigationStack(path: $navigation.browsePath) { + LocationsContent( + report: report, + navigation: navigation, + cardPresentation: cardPresentation, + tilt: tilt, + calendarTransition: transition ?? localTransition, + cardVariant: cardVariant, + showsSelection: showsSelection, + openElsewhere: openElsewhere, + ) + .navigationDestination(for: LocationsView.Navigation.Destination + .self) + { destination in + switch destination { + case let .flight(review): FlightDayDetailView( + review: review, + report: report, + ) + } + } + } + } +} + +/// Locations tab: the regions you spend the most days in for the selected year, +/// shown as prominent Liquid Glass cards, with the Elsewhere summary folded in +/// at the bottom (only when there are secondary regions), plus toolbar actions +/// for planned stays and any data issues that need resolution. +struct LocationsContent: View { let report: YearReportModel var openElsewhere: (() -> Void)? + let cardVariant: WhereStylesheet.CardStyle.Variant + let showsSelection: Bool - @State private var showingResolution = false - @State private var planningDestination: PlannedStaysDestination? + @State private var navigation: LocationsView.Navigation @State private var isCardSurfaceVisible = false @State private var cardPresentation: LocationCardsPresentationModel @@ -24,72 +285,78 @@ struct LocationsView: View { /// Pairs a tapped card with its pushed calendar so the navigation uses a /// matched-geometry zoom (the card expands into the calendar) rather than a /// plain slide. - @Namespace private var calendarTransition + @Namespace private var localCalendarTransition + private let transition: Namespace.ID? + private var calendarTransition: Namespace.ID { + transition ?? localCalendarTransition + } @Environment(\.stylesheet.card.regular.surface) private var cardSurface private var isCardSurfaceUncovered: Bool { isCardSurfaceVisible - && !showingResolution - && planningDestination == nil + && !navigation.showingResolution + && navigation.planningDestination == nil } - init(report: YearReportModel, openElsewhere: (() -> Void)? = nil) { + init( + report: YearReportModel, + navigation: LocationsView.Navigation? = nil, + cardPresentation: LocationCardsPresentationModel? = nil, + tilt: TiltProvider? = nil, + calendarTransition: Namespace.ID? = nil, + cardVariant: WhereStylesheet.CardStyle.Variant = .regular, + showsSelection: Bool = false, + openElsewhere: (() -> Void)? = nil, + ) { + _navigation = State(initialValue: navigation ?? LocationsView.Navigation()) self.report = report self.openElsewhere = openElsewhere - _cardPresentation = State(initialValue: LocationCardsPresentationModel( + self.cardVariant = cardVariant + self.showsSelection = showsSelection + transition = calendarTransition + _tilt = State(initialValue: tilt ?? TiltProvider()) + _cardPresentation = State(initialValue: cardPresentation ?? LocationCardsPresentationModel( preferences: report.preferences, year: report.selectedYear, )) } var body: some View { - NavigationStack { - screen - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { - LocationsBackground(regions: backgroundRegions) - .ignoresSafeArea() - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - // Pending reviews remain accessible without increasing - // the actionable issue badge. - if report.dataIssueCount > 0 || report.hasCorrectionReviews { - Button { - showingResolution = true - } label: { - ResolveToolbarLabel(count: report.dataIssueCount) - } - .accessibilityIdentifier("where_resolution_button") + @Bindable var navigation = navigation + screen + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + LocationsBackground(regions: backgroundRegions) + .ignoresSafeArea() + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + // Pending reviews remain accessible without increasing + // the actionable issue badge. + if report.dataIssueCount > 0 || report.hasCorrectionReviews { + Button { + navigation.showingResolution = true + } label: { + ResolveToolbarLabel(count: report.dataIssueCount) } + .accessibilityIdentifier("where_resolution_button") + } - if showsPlanningMenu { - Button( - String(localized: .plannedStaysTitle), - systemSymbol: .calendarBadgeClock, - ) { - planningDestination = .list - } - .accessibilityIdentifier("where_planning_menu") + if showsPlanningMenu { + Button( + String(localized: .plannedStaysTitle), + systemSymbol: .calendarBadgeClock, + ) { + navigation.planningDestination = .list } + .accessibilityIdentifier("where_planning_menu") } } - } - .observesTilt( - tilt, - enabled: isCardSurfaceUncovered && cardSurface.usesReflection, - ) - .sheet(isPresented: $showingResolution) { - ResolutionView(report: report) - } - .sheet(item: $planningDestination) { destination in - PlannedStaysDestinationView(destination: destination, report: report) - } - // Log View Mode: reveal an inspect badge for the year-report events - // backing this screen. A no-op in release. - .debugLogInspectable(WhereLog.session(YearReportModelLog.self)) + } + .observesTilt(tilt, enabled: isCardSurfaceUncovered && cardSurface.usesReflection) + .debugLogInspectable(WhereLog.session(YearReportModelLog.self)) } @ViewBuilder @@ -99,6 +366,7 @@ struct LocationsView: View { LocationsStateView( report: report, planningAction: openPlanning, + openReview: { navigation.open(.flight($0)) }, openElsewhere: openElsewhere, ) case .idle, .loaded, .loading: @@ -106,6 +374,7 @@ struct LocationsView: View { LocationsStateView( report: report, planningAction: openPlanning, + openReview: { navigation.open(.flight($0)) }, openElsewhere: openElsewhere, ) } else { @@ -117,6 +386,9 @@ struct LocationsView: View { isCardSurfaceUncovered: isCardSurfaceUncovered, isCardSurfaceVisible: $isCardSurfaceVisible, planningAction: openPlanning, + navigation: navigation, + cardVariant: cardVariant, + showsSelection: showsSelection, ) } } @@ -136,7 +408,7 @@ struct LocationsView: View { } private func openPlanning() { - planningDestination = .list + navigation.planningDestination = .list } } @@ -185,6 +457,19 @@ private struct ResolveToolbarLabel: View { reduceTransparency: true, ) } + artworkSnapshot( + name: "BrowseColumn", + report: PreviewSupport.loadedYearReportModel(), + configurations: [SnapshotConfiguration(device: DuoLayoutSnapshotFixture + .browseColumn)], + cardVariant: .column, + ) + artworkSnapshot( + name: "ReturnedFromCalendar", + report: PreviewSupport.loadedYearReportModel(), + configurations: .fullContentPhoneLightDark, + selectedRegion: .california, + ) artworkSnapshot( name: "Itinerary", report: PreviewSupport.itineraryYearReportModel(), @@ -208,7 +493,7 @@ private struct ResolveToolbarLabel: View { measurementReadiness: .immediate, settle: .settledAtLeast(minDuration: 1.0), ) { - LocationsView(report: PreviewSupport.plannedStayYearReportModel()) + LocationsBrowsePane(report: PreviewSupport.plannedStayYearReportModel()) } whereSnapshot( name: "ForecastsHidden", @@ -216,7 +501,7 @@ private struct ResolveToolbarLabel: View { measurementReadiness: .immediate, settle: .settledAtLeast(minDuration: 1.0), ) { - LocationsView(report: forecastsHiddenReport()) + LocationsBrowsePane(report: forecastsHiddenReport()) } whereSnapshot( name: "Empty", @@ -224,7 +509,7 @@ private struct ResolveToolbarLabel: View { measurementReadiness: .immediate, settle: .settledAtLeast(minDuration: 1.0), ) { - LocationsView(report: PreviewSupport.emptyYearReportModel()) + LocationsBrowsePane(report: PreviewSupport.emptyYearReportModel()) } whereSnapshot( name: "MissingDays", @@ -232,7 +517,7 @@ private struct ResolveToolbarLabel: View { measurementReadiness: .immediate, settle: .settledAtLeast(minDuration: 1.0), ) { - LocationsView(report: PreviewSupport.missingDaysYearReportModel()) + LocationsBrowsePane(report: PreviewSupport.missingDaysYearReportModel()) } whereSnapshot( name: "ElsewhereOnly", @@ -260,7 +545,7 @@ private struct ResolveToolbarLabel: View { measurementReadiness: .immediate, settle: .settledAtLeast(minDuration: 1.0), ) { - LocationsView( + LocationsBrowsePane( report: PreviewSupport.loadedYearReportModelWithLocationDotsHidden(), ) } @@ -281,6 +566,9 @@ private struct ResolveToolbarLabel: View { configurations: [SnapshotConfiguration], theme: WhereTheme = .standard, reduceTransparency: Bool = false, + cardVariant: WhereStylesheet.CardStyle.Variant = .regular, + selectedRegion: Region? = nil, + openElsewhere _: (() -> Void)? = nil, ) -> SnapshotCase { let cache = RegionOutlinePathCache() let regions = LocationsBackgroundArtwork.regions(in: report.ranking) @@ -300,15 +588,21 @@ private struct ResolveToolbarLabel: View { } }, ) { - LocationsView(report: report) - .environment(\.regionOutlinePathCache, cache) - .bTraitOverrides { traits, overrides in - if reduceTransparency { - var accessibility = traits.accessibility - accessibility.isReduceTransparencyEnabled = true - overrides.accessibility = accessibility - } + let navigation = LocationsView.Navigation() + let _ = navigation.selectedRegion = selectedRegion + LocationsBrowsePane( + report: report, + navigation: navigation, + cardVariant: cardVariant, + ) + .environment(\.regionOutlinePathCache, cache) + .bTraitOverrides { traits, overrides in + if reduceTransparency { + var accessibility = traits.accessibility + accessibility.isReduceTransparencyEnabled = true + overrides.accessibility = accessibility } + } } } diff --git a/Where/WhereUI/Sources/Primary/MonthNavigationControls.swift b/Where/WhereUI/Sources/Primary/MonthNavigationControls.swift new file mode 100644 index 000000000..5efb64ea9 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/MonthNavigationControls.swift @@ -0,0 +1,100 @@ +import BroadwayUI +import RegionKit +import SFSafeSymbols +import SwiftUI +import WhereCore + +/// Explicit month browsing controls for the Locations tabletop calendar. +struct MonthNavigationControls: View { + let report: YearReportModel + @Binding var monthReveal: CalendarMonthReveal? + @BScrollPosition< + CalendarPresentationState.Target, + CalendarContentView.ScrollContext + > private var position: ScrollPosition + init( + report: YearReportModel, + monthReveal: Binding, + region: Region? = nil, + scrollRole: CalendarContentView.ScrollRole = .year, + ) { + self.report = report + _monthReveal = monthReveal + _position = BScrollPosition( + CalendarContentView.self, + role: scrollRole, + context: { CalendarContentView.ScrollContext(region: region, year: report.selectedYear) + }, + idType: CalendarPresentationState.Target.self, + ) + } + + @Environment(\.stylesheet) private var stylesheet + + private var month: CalendarDay { + if let day = monthReveal?.month ?? position + .viewID(type: CalendarPresentationState.Target.self)?.month + { + return CalendarDay(year: day.year, month: day.month, day: 1) + } + let today = CalendarDay(from: report.referenceDate, in: report.calendar) + return CalendarDay( + year: report.selectedYear, + month: today.year == report.selectedYear ? today.month : 12, + day: 1, + ) + } + + var body: some View { + HStack { + Button(String(localized: .adaptiveMonthPrevious), systemSymbol: .chevronBackward) { + select(month.month - 1) + } + .disabled(month.month == 1) + .labelStyle(.iconOnly) + Spacer(minLength: stylesheet.spacing.small) + Menu { + ForEach(1 ... 12, id: \.self) { candidate in + Button(title(for: candidate)) { select(candidate) } + } + } label: { + Text(title(for: month.month)) + .font(.headline) + } + .accessibilityLabel(String(localized: .adaptiveMonthPicker)) + .accessibilityValue(title(for: month.month)) + Spacer(minLength: stylesheet.spacing.small) + Button(String(localized: .adaptiveMonthNext), systemSymbol: .chevronForward) { + select(month.month + 1) + } + .disabled(month.month == 12) + .labelStyle(.iconOnly) + } + .padding(stylesheet.spacing.medium) + .background(.bar) + } + + private func title(for month: Int) -> String { + CalendarDay(year: report.selectedYear, month: month, day: 1) + .startOfDay(in: report.calendar).formatted(Date.FormatStyle( + calendar: report.calendar, + timeZone: report.calendar.timeZone, + ).month(.wide).year()) + } + + private func select(_ month: Int) { + monthReveal = CalendarMonthReveal( + month: CalendarDay(year: report.selectedYear, month: month, day: 1), + ) + } +} + +#if DEBUG + #Preview { + MonthNavigationControls( + report: PreviewSupport.loadedYearReportModel(), + monthReveal: .constant(nil), + ) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift index b74dea145..72015ccf1 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineList.swift @@ -1,3 +1,4 @@ +import BroadwayUI import RegionKit import SFSafeSymbols import SnapshotKit @@ -6,7 +7,6 @@ import WhereCore #if DEBUG import BroadwayCore - import BroadwayUI #endif /// A chronological list of continuous stays (`RegionStint`s) for the selected @@ -18,11 +18,46 @@ struct PresenceTimelineList: View { case excerpt } + enum RowID: Hashable { + case recorded(RegionStint.ID) + case flight(FlightAssessment.ID) + case planned(PlanningTimelineItem.ID) + + init(_ item: PresenceJourneyItem) { + self = switch item { + case let .stay(stint): .recorded(stint.id) + case let .flight(flight): .flight(flight.id) + } + } + } + let report: YearReportModel - var presentation: Presentation = .full + let presentation: Presentation + private let suppliedPlanningDestination: Binding? + @State private var localPlanningDestination: PlannedStaysDestination? + @BScrollPosition private var position: ScrollPosition + enum ScrollRole: Hashable { case timeline } + private var planningDestination: Binding { + suppliedPlanningDestination ?? $localPlanningDestination + } @Environment(\.stylesheet) private var stylesheet - @State private var planningDestination: PlannedStaysDestination? + + init( + report: YearReportModel, + presentation: Presentation = .full, + planningDestination: Binding? = nil, + ) { + self.report = report + self.presentation = presentation + suppliedPlanningDestination = planningDestination + _position = BScrollPosition( + Self.self, + role: ScrollRole.timeline, + context: { report.selectedYear }, + idType: RowID.self, + ) + } var body: some View { let yearReport = report.report @@ -41,6 +76,8 @@ struct PresenceTimelineList: View { today: report.forecasts.today, ) : [] + let rowIDs = recordedItems.map(RowID.init) + + plannedItems.map { .planned($0.id) } Group { if presentation == .excerpt { @@ -74,41 +111,10 @@ struct PresenceTimelineList: View { Text(String(localized: .timelineEmptyDescription)) } } else { - let pinsOverview = stylesheet.timeline.overview.pinsToViewport - - ScrollView { - // Measure the complete year before expanding a bottom-anchored viewport. - // Lazy estimates can leave blank space below the final forecast. - VStack(spacing: stylesheet.spacing.large) { - if !pinsOverview { - YearRibbon( - days: yearReport?.days ?? [], - year: report.selectedYear, - calendar: report.calendar, - ) - } - - VStack(spacing: 0) { - journeyRows(recordedItems: recordedItems, plannedItems: plannedItems) - } + let pinsOverview = presentation == .full && stylesheet.timeline.overview + .pinsToViewport - if showsForecast { - LocationForecastPanel( - forecasts: timelineForecasts, - microprintRegions: report.ranking.primary.map(\.region), - homeRegion: report.forecasts.planning.homeRegion, - planningAction: { planningDestination = .list }, - ) - } - } - .padding(.horizontal, stylesheet.spacing.xxLarge) - .padding(.top, pinsOverview ? 0 : stylesheet.spacing.large) - .padding(.bottom, stylesheet.spacing.large) - } - .safeAreaInset( - edge: .top, - spacing: 0, - ) { + VStack(spacing: 0) { if pinsOverview { YearRibbon( days: yearReport?.days ?? [], @@ -116,31 +122,89 @@ struct PresenceTimelineList: View { calendar: report.calendar, ) .padding(.horizontal, stylesheet.spacing.xxLarge) - .padding(.top, stylesheet.spacing.large) + .padding(.vertical, stylesheet.spacing.large) + } + ScrollView { + // Measure this year's overview, stays, and forecast together. + // Lazy row estimates can move the overview above the content + // origin when a bottom-anchored viewport expands. + VStack(spacing: stylesheet.spacing.large) { + if presentation == .full, !pinsOverview { + YearRibbon( + days: yearReport?.days ?? [], + year: report.selectedYear, + calendar: report.calendar, + ) + } + + VStack(spacing: 0) { + journeyRows( + recordedItems: recordedItems, + plannedItems: plannedItems, + ) + } + .scrollTargetLayout() + + if presentation == .full, showsForecast { + LocationForecastPanel( + forecasts: timelineForecasts, + microprintRegions: report.ranking.primary.map(\.region), + homeRegion: report.forecasts.planning.homeRegion, + planningAction: { planningDestination.wrappedValue = .list }, + ) + } + } + .padding(.horizontal, stylesheet.spacing.xxLarge) + .padding( + .top, + pinsOverview ? 0 : stylesheet.spacing.large, + ) .padding(.bottom, stylesheet.spacing.large) } + .scrollPosition($position) + .task(id: TimelineLayoutID( + year: report.selectedYear, + recordedItems: recordedItems, + plannedItems: plannedItems, + )) { + guard !Task.isCancelled else { return } + _position.initialize(edge: .bottom) + if let target = position.viewID(type: RowID.self), + !rowIDs.contains(target) + { + _position.scrollTo(edge: .bottom) + } + } + .defaultScrollAnchor(.bottom, for: .initialOffset) + .id(report.selectedYear) } - .defaultScrollAnchor(.bottom, for: .initialOffset) - .id(report.selectedYear) } } - .sheet(item: $planningDestination) { destination in - PlannedStaysDestinationView(destination: destination, report: report) - } + .modifier(PlanningPresentation( + report: report, + destination: planningDestination, + isEnabled: suppliedPlanningDestination == nil, + )) .toolbar { - if presentation == .full, report.showsEstimatedTimeAndPlanning { + if presentation != .excerpt, report.showsEstimatedTimeAndPlanning { ToolbarItem(placement: .topBarTrailing) { Button( String(localized: .plannedStaysTitle), systemSymbol: .calendarBadgeClock, ) { - planningDestination = .list + planningDestination.wrappedValue = .list } } } } } + private struct TimelineLayoutID: Equatable { + let year: Int + let recordedItems: [PresenceJourneyItem] + let plannedItems: [PlanningTimelineItem] + } + /// Both presentations share recorded and planned row rendering. @ViewBuilder private func journeyRows( @@ -156,26 +220,27 @@ struct PresenceTimelineList: View { Group { switch item { case let .stay(stint): - PresenceJourneyRow( + let row = PresenceJourneyRow( stint: stint, calendar: report.calendar, daysInYear: report.daysInSelectedYear, isFirst: item.id == recordedItems.first?.id, isLast: plannedItems.isEmpty && item.id == recordedItems.last?.id, - cardPosition: joinsRecorded && item.id == recordedItems.last? - .id ? .top : .standalone, + cardPosition: joinsRecorded && item.id == recordedItems.last?.id + ? .top : .standalone, ) + row case let .flight(flight): FlightJourneyRow( flight: flight, calendar: report.calendar, isFirst: item.id == recordedItems.first?.id, - isLast: plannedItems.isEmpty && item.id == recordedItems - .last?.id, + isLast: plannedItems.isEmpty && item.id == recordedItems.last?.id, ) } } .fixedSize(horizontal: false, vertical: presentation == .excerpt) + .id(RowID(item)) } ForEach(plannedItems) { item in let row = PlannedPresenceJourneyRow( @@ -189,26 +254,38 @@ struct PresenceTimelineList: View { ) .fixedSize(horizontal: false, vertical: presentation == .excerpt) - if presentation == .full { + if presentation != .excerpt { Button { - if case let .stay(interval) = item, - let stay = report.forecasts.planning.stays - .first(where: { $0.id == interval.stayID }) - { - planningDestination = .edit(stay) - } else { - planningDestination = .list - } + edit(item) } label: { row } .buttonStyle(.plain) + .contextMenu { + Button( + String(localized: .plannedStaysTitle), + systemSymbol: .calendarBadgeClock, + ) { + edit(item) + } + } + .id(RowID.planned(item.id)) } else { row } } } + private func edit(_ item: PlanningTimelineItem) { + if case let .stay(interval) = item, + let stay = report.forecasts.planning.stays.first(where: { $0.id == interval.stayID }) + { + planningDestination.wrappedValue = .edit(stay) + } else { + planningDestination.wrappedValue = .list + } + } + private var showsForecast: Bool { report.showsEstimatedTimeAndPlanning && !timelineForecasts.isEmpty } diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift index b98e63ebc..b110e13cd 100644 --- a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -1,5 +1,6 @@ import RegionKit import SFSafeSymbols +import SnapshotKit import SwiftUI /// A passport-inspired acknowledgement of the user's current tracked region. diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index f0896d4bd..b6fcd9111 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -29,7 +29,7 @@ public struct RootView: View { /// The logged-in tab bar's measured height, reported up from `MainTabs` and /// handed to the sibling `DeveloperOverlay` so its button rests clear of the /// tab bar. Zero when logged out (no tab bar in the tree). - @State private var developerTabBarInset: CGFloat = 0 + @State private var developerTabBarInset = EdgeInsets() /// The footprint the non-modal floating developer HUD occupies, reported up /// from the sibling `DeveloperOverlay` and applied as extra safe area to the /// app content so screens behind the HUD can scroll clear of it. diff --git a/Where/WhereUI/Sources/Settings/SettingsContent.swift b/Where/WhereUI/Sources/Settings/SettingsContent.swift new file mode 100644 index 000000000..339a8e7c6 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/SettingsContent.swift @@ -0,0 +1,256 @@ +import InstallerKit +import LifecycleKitUI +import RegionKit +import SFSafeSymbols +import SwiftUI +import WhereCore + +/// An embedded demo has no app lifecycle to exit. Its host owns dismissal. +enum DemoExitPresentation { + case session + case embedded +} + +extension EnvironmentValues { + @Entry var demoExitPresentation = DemoExitPresentation.session +} + +/// Shared searchable Settings list. Its host supplies navigation and sheet presentation. +struct SettingsContent: View { + let report: YearReportModel + let recordingWarning: RecordingConfigurationWarningModel + @State private var navigation: SettingsView.Navigation + @Environment(UpdateModel.self) private var updates: UpdateModel? + @Environment(WhereSession.self) private var session + @Environment(WhereModel.self) private var model + @Environment(\.lifecycle) private var lifecycle + @Environment(\.isInDemoMode) private var isInDemoMode + @Environment(\.demoExitPresentation) private var demoExitPresentation + + init( + report: YearReportModel, + recordingWarning: RecordingConfigurationWarningModel? = nil, + navigation: SettingsView.Navigation? = nil, + ) { + self.report = report + self + .recordingWarning = recordingWarning ?? + RecordingConfigurationWarningModel(preferences: report.preferences) + _navigation = State(initialValue: navigation ?? SettingsView.Navigation()) + } + + private var searchQuery: String { + navigation.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isSearching: Bool { + !searchQuery.isEmpty + } + + private var searchResults: [SettingsSearchResult] { + SettingsCatalog.results(matching: searchQuery) + .filter { isAvailable($0.destination) } + } + + /// Whether a group is offered right now. Everything is, except the few + /// groups a demo has no business showing (see + /// `SettingsDestination.isAvailableInDemoMode`) — filtered here rather than + /// per-row so the list and the search index can't disagree about what + /// exists. + private func isAvailable(_ destination: SettingsDestination) -> Bool { + destination.isAvailable(isInDemoMode: isInDemoMode, updatesEnabled: updates != nil) + } + + var body: some View { + @Bindable var navigation = navigation + List(selection: $navigation.selectedRoute) { + if isSearching { + ForEach(searchResults) { result in + searchNavigationRow(result) + } + } else { + if recordingWarning.isPresented { + recordingWarningSection + } + if isInDemoMode, demoExitPresentation == .session { + demoSection + } + ForEach(SettingsListSection.allCases, id: \.self) { section in + let destinations = section.destinations.filter(isAvailable) + if !destinations.isEmpty { + Section { + ForEach(destinations, id: \.self) { destination in + groupNavigationRow(destination) + } + } header: { + if let headerTitle = section.headerTitle { + Text(headerTitle) + } + } + } + } + } + } + .navigationTitle(String(localized: .settingsTitle)) + .searchable( + text: $navigation.searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: String(localized: .settingsSearchPrompt), + ) + .overlay { + if isSearching, searchResults.isEmpty { + ContentUnavailableView.search(text: searchQuery) + } + } + } + + private var recordingWarningSection: some View { + Section { + NavigationLink(value: SettingsRoute(.devices)) { + Label { + VStack(alignment: .leading, spacing: 4) { + Text(.settingsRecordingWarningTitle) + .font(.headline) + Text(.settingsRecordingWarningMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemSymbol: .exclamationmarkTriangleFill) + .foregroundStyle(.yellow) + .accessibilityHidden(true) + } + } + Button(.settingsRecordingWarningDismiss, role: .cancel) { + recordingWarning.dismiss() + } + } + } + + /// The way out of demo mode, at the very top of the list where a temporary + /// state belongs — above the real settings rather than filed among them. + /// + /// No confirmation: there is nothing to lose. Demo data was never saved, + /// and leaving is exactly what quitting the app would do anyway, so a + /// "are you sure?" would overstate the stakes. + private var demoSection: some View { + Section { + Button { + exitDemoMode() + } label: { + Label( + String(localized: .settingsDemoExit), + systemSymbol: .rectanglePortraitAndArrowRight, + ) + } + } header: { + Text(String(localized: .settingsDemoHeader)) + } footer: { + Text(String(localized: .settingsDemoFooter)) + } + } + + /// Tear the demo world down and re-drive the launch, through the same + /// `LifecycleProxy` the reset flow uses. The relaunch parks on the + /// onboarding gate (or resolves straight through for someone who had + /// already onboarded before trying the demo). + private func exitDemoMode() { + Task { await lifecycle.teardown(WhereLaunch.exitDemoPlan(for: model), input: session) } + } + + /// A top-level row that either pushes its sub-screen or, for a sheet group + /// (Regions), presents it modally. + @ViewBuilder + private func groupNavigationRow(_ destination: SettingsDestination) -> some View { + if destination.isSheet { + Button { present(destination) } label: { groupRow(destination) } + .tint(.primary) + } else { + NavigationLink(value: SettingsRoute(destination)) { groupRow(destination) } + } + } + + /// A search-result row that mirrors the group row's push-vs-sheet routing. + @ViewBuilder + private func searchNavigationRow(_ result: SettingsSearchResult) -> some View { + if result.destination.isSheet { + Button { present(result.destination) } label: { searchRow(result) } + .tint(.primary) + } else { + NavigationLink(value: SettingsRoute(result)) { searchRow(result) } + } + } + + /// Present a sheet group. Non-sheet destinations are pushed via + /// `NavigationLink`, so reaching them here is a programmer error. + private func present(_ destination: SettingsDestination) { + switch destination { + case .regions: + navigation.showRegions = true + case .placesYear, .recording, .privacyBackups, .attachments, .loggedDays, .devices, + .alerts, .appearance, .year, .siri, + .widgets, .shareEvidence, .estimatedTime, .insightsAccuracy, .personalization, + .data, .privacyDiagnostics, .updates, .about: + assertionFailure("\(destination) is a push destination, not a sheet") + } + } + + /// A top-level drill-in row: icon + group title, with a value subtitle where + /// one is cheap and useful (location status, the report year). + private func groupRow(_ destination: SettingsDestination) -> some View { + LabeledContent { + if let subtitle = subtitle(for: destination) { + Text(subtitle) + .foregroundStyle(.secondary) + } + } label: { + Label { + Text(destination.rowTitle) + } icon: { + SettingsIcon(systemSymbol: destination.systemSymbol, color: destination.iconColor) + } + } + } + + /// A cheap, always-available value shown on the right of a group row; `nil` + /// for groups without a meaningful one-line summary. + private func subtitle(for destination: SettingsDestination) -> String? { + switch destination { + case .devices: + LocationStatusRow.statusTitle( + status: session.authorizationStatus, + isTracking: session.isTracking, + ) + case .updates: updates?.attentionTitle + case .year: + report.selectedYear.formatted(.number.grouping(.never)) + case .placesYear, .recording, .privacyBackups, .attachments, .loggedDays, .regions, + .alerts, .appearance, .siri, .widgets, + .shareEvidence, .estimatedTime, .insightsAccuracy, .personalization, .data, + .privacyDiagnostics, .about: + nil + } + } + + /// A search result row: the owning group's icon beside the setting's name + /// over its parent group, so a result reads as belonging to its section. + private func searchRow(_ result: SettingsSearchResult) -> some View { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + Text(result.destination.rowTitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + SettingsIcon( + systemSymbol: result.destination.systemSymbol, + color: result.destination.iconColor, + ) + } + } +} + +#if DEBUG + #Preview { SettingsView.snapshotPreviews } +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 6279a4984..0b56d3eb0 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,6 +1,8 @@ +import Observation #if DEBUG @_spi(Testing) import InstallerKitUI #endif +import BroadwayUI import InstallerKit import RegionKit import SFSafeSymbols @@ -15,29 +17,49 @@ import WhereCore /// deep-links to the screen — and the row — containing each. /// /// The top level owns nothing but navigation; behavior lives in the sub-screens -/// (`DevicesSettingsView`, `AlertsSettingsView`, …). The scene's report model and -/// the two view-scoped editing models (backup, reminders) are owned here and -/// handed down; the `WhereSession` coordinator (recording/location) and +/// (`DevicesSettingsView`, `AlertsSettingsView`, …). The scene injects its report. +/// This view owns the backup and reminders models above the native navigation container; +/// the `WhereSession` coordinator (recording/location) and /// `WhereModel` (reset) come from the environment via the sub-screens. struct SettingsView: View { + @MainActor @Observable + final class Navigation { + // Settings, evidence, and feature-gallery routes share the same stack. + // NavigationPath retains each value's concrete Hashable identity. + var path = NavigationPath() + private var route: SettingsRoute? + var compactColumn = NavigationSplitViewColumn.sidebar + var selectedRoute: SettingsRoute? { + get { route } + set { + guard route != newValue else { return } + route = newValue + path = NavigationPath() + if newValue != nil { compactColumn = .detail } + } + } + + var searchText = "" + var showRegions = false + } + let report: YearReportModel let recordingWarning: RecordingConfigurationWarningModel @State private var backup: BackupModel @State private var reminders: RemindersSettingsModel - @State private var searchText = "" - @State private var showRegions = false + @State private var navigation: SettingsView.Navigation - @Environment(WhereSession.self) private var session - @Environment(UpdateModel.self) private var updates: UpdateModel? - @Environment(WhereModel.self) private var model - @Environment(\.lifecycle) private var lifecycle - @Environment(\.isInDemoMode) private var isInDemoMode - @Environment(\.primaryAppIconName) private var primaryAppIconName + @Environment(\.stylesheet) private var stylesheet init( report: YearReportModel, recordingWarning: RecordingConfigurationWarningModel? = nil, + navigation: SettingsView.Navigation? = nil, + initialRoute: SettingsRoute? = nil, ) { + let navigation = navigation ?? SettingsView.Navigation() + if let initialRoute { navigation.selectedRoute = initialRoute } + _navigation = State(initialValue: navigation) self.report = report self.recordingWarning = recordingWarning ?? RecordingConfigurationWarningModel( preferences: report.preferences, @@ -50,223 +72,80 @@ struct SettingsView: View { )) } - private var searchQuery: String { - searchText.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var isSearching: Bool { - !searchQuery.isEmpty - } - - private var searchResults: [SettingsSearchResult] { - SettingsCatalog.results(matching: searchQuery) - .filter { isAvailable($0.destination) } - } - - /// Whether a group is offered right now. Everything is, except the few - /// groups a demo has no business showing (see - /// `SettingsDestination.isAvailableInDemoMode`) — filtered here rather than - /// per-row so the list and the search index can't disagree about what - /// exists. - private func isAvailable(_ destination: SettingsDestination) -> Bool { - destination.isAvailable(isInDemoMode: isInDemoMode, updatesEnabled: updates != nil) - } - var body: some View { - NavigationStack { - List { - if isSearching { - ForEach(searchResults) { result in - searchNavigationRow(result) - } - } else { - if recordingWarning.isPresented { - recordingWarningSection - } - if isInDemoMode { - demoSection - } - ForEach(SettingsListSection.allCases, id: \.self) { section in - let destinations = section.destinations.filter(isAvailable) - if !destinations.isEmpty { - Section { - ForEach(destinations, id: \.self) { destination in - groupNavigationRow(destination) - } - } header: { - if let headerTitle = section.headerTitle { - Text(headerTitle) - } - } - } + @Bindable var navigation = navigation + NavigationSplitView( + columnVisibility: .constant(.all), + preferredCompactColumn: $navigation.compactColumn, + ) { + SettingsContent( + report: report, + recordingWarning: recordingWarning, + navigation: navigation, + ) + .navigationSplitViewColumnWidth( + min: stylesheet.adaptivePane.minimumControlsWidth, + ideal: stylesheet.adaptivePane.minimumDetailWidth, + ) + } detail: { + NavigationStack(path: $navigation.path) { + Group { + if let route = navigation.selectedRoute { + destination(for: route) + } else { + ContentUnavailableView( + String(localized: .settingsTitle), + systemSymbol: .gearshape, + ) } } - } - .navigationTitle(String(localized: .settingsTitle)) - .searchable(text: $searchText, prompt: String(localized: .settingsSearchPrompt)) - .overlay { - if isSearching, searchResults.isEmpty { - ContentUnavailableView.search(text: searchQuery) + .navigationDestination(for: SettingsRoute.self) { route in + destination(for: route) } } - .navigationDestination(for: SettingsRoute.self) { route in - destination(for: route) - } - .sheet(isPresented: $showRegions) { - RegionsSettingsView(usedThisYear: regionsUsedThisYear) - } - } - } - - private var recordingWarningSection: some View { - Section { - NavigationLink(value: SettingsRoute(.devices)) { - Label { - VStack(alignment: .leading, spacing: 4) { - Text(.settingsRecordingWarningTitle) - .font(.headline) - Text(.settingsRecordingWarningMessage) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemSymbol: .exclamationmarkTriangleFill) - .foregroundStyle(.yellow) - .accessibilityHidden(true) - } - } - Button(.settingsRecordingWarningDismiss, role: .cancel) { - recordingWarning.dismiss() - } - } - } - - /// The way out of demo mode, at the very top of the list where a temporary - /// state belongs — above the real settings rather than filed among them. - /// - /// No confirmation: there is nothing to lose. Demo data was never saved, - /// and leaving is exactly what quitting the app would do anyway, so a - /// "are you sure?" would overstate the stakes. - private var demoSection: some View { - Section { - Button { - exitDemoMode() - } label: { - Label( - String(localized: .settingsDemoExit), - systemSymbol: .rectanglePortraitAndArrowRight, - ) - } - } header: { - Text(String(localized: .settingsDemoHeader)) - } footer: { - Text(String(localized: .settingsDemoFooter)) - } - } - - /// Tear the demo world down and re-drive the launch, through the same - /// `LifecycleProxy` the reset flow uses. The relaunch parks on the - /// onboarding gate (or resolves straight through for someone who had - /// already onboarded before trying the demo). - private func exitDemoMode() { - Task { await lifecycle.teardown(WhereLaunch.exitDemoPlan(for: model), input: session) } - } - - /// A top-level row that either pushes its sub-screen or, for a sheet group - /// (Regions), presents it modally. - @ViewBuilder - private func groupNavigationRow(_ destination: SettingsDestination) -> some View { - if destination.isSheet { - Button { present(destination) } label: { groupRow(destination) } - .tint(.primary) - } else { - NavigationLink(value: SettingsRoute(destination)) { groupRow(destination) } - } - } - - /// A search-result row that mirrors the group row's push-vs-sheet routing. - @ViewBuilder - private func searchNavigationRow(_ result: SettingsSearchResult) -> some View { - if result.destination.isSheet { - Button { present(result.destination) } label: { searchRow(result) } - .tint(.primary) - } else { - NavigationLink(value: SettingsRoute(result)) { searchRow(result) } + .navigationSplitViewColumnWidth( + min: stylesheet.adaptivePane.minimumDetailWidth, + ideal: stylesheet.adaptivePane.minimumDetailWidth, + ) } - } - - /// Present a sheet group. Non-sheet destinations are pushed via - /// `NavigationLink`, so reaching them here is a programmer error. - private func present(_ destination: SettingsDestination) { - switch destination { - case .regions: - showRegions = true - case .placesYear, .recording, .privacyBackups, .attachments, .loggedDays, .devices, - .alerts, .appearance, .year, .siri, - .widgets, .shareEvidence, .estimatedTime, .insightsAccuracy, .personalization, - .data, .privacyDiagnostics, .updates, .about: - assertionFailure("\(destination) is a push destination, not a sheet") + .navigationSplitViewStyle(.balanced) + .environment( + \.horizontalSizeClass, + stylesheet.adaptivePane.panes == .single ? .compact : .regular, + ) + .bHorizontalSizeClass(stylesheet.adaptivePane.panes == .single ? .compact : .regular) + .background(.background) + .sheet(isPresented: $navigation.showRegions) { + RegionsSettingsView(usedThisYear: regionsUsedThisYear) } } - /// A top-level drill-in row: icon + group title, with a value subtitle where - /// one is cheap and useful (location status, the report year). - private func groupRow(_ destination: SettingsDestination) -> some View { - LabeledContent { - if let subtitle = subtitle(for: destination) { - Text(subtitle) - .foregroundStyle(.secondary) - } - } label: { - Label { - Text(destination.rowTitle) - } icon: { - SettingsIcon(systemSymbol: destination.systemSymbol, color: destination.iconColor) - } - } + private func destination(for route: SettingsRoute) -> some View { + SettingsDestinationView(route: route, report: report, backup: backup, reminders: reminders) } - /// A cheap, always-available value shown on the right of a group row; `nil` - /// for groups without a meaningful one-line summary. - private func subtitle(for destination: SettingsDestination) -> String? { - switch destination { - case .devices: - LocationStatusRow.statusTitle( - status: session.authorizationStatus, - isTracking: session.isTracking, - ) - case .updates: - updates?.attentionTitle - case .year: - report.selectedYear.formatted(.number.grouping(.never)) - case .placesYear, .recording, .privacyBackups, .attachments, .loggedDays, .regions, - .alerts, .appearance, .siri, .widgets, - .shareEvidence, .estimatedTime, .insightsAccuracy, .personalization, .data, - .privacyDiagnostics, .about: - nil - } + /// Regions with days in the selected report year, so the region editor can + /// surface a "used this year" group (grouping order only — it doesn't affect + /// what's saved). `.other` isn't a pickable region, so it's dropped. + private var regionsUsedThisYear: Set { + guard let totals = report.report?.totals else { return [] } + return Set(totals.filter { $0.key != .other && $0.value > 0 }.map(\.key)) } +} - /// A search result row: the owning group's icon beside the setting's name - /// over its parent group, so a result reads as belonging to its section. - private func searchRow(_ result: SettingsSearchResult) -> some View { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(result.title) - Text(result.destination.rowTitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - SettingsIcon( - systemSymbol: result.destination.systemSymbol, - color: result.destination.iconColor, - ) - } - } +/// The shared typed destination builder for both adaptive navigation and standalone panes. +private struct SettingsDestinationView: View { + let route: SettingsRoute + let report: YearReportModel + let backup: BackupModel + let reminders: RemindersSettingsModel + @Environment(UpdateModel.self) private var updates: UpdateModel? + @Environment(\.isInDemoMode) private var isInDemoMode + @Environment(WhereSession.self) private var session + @Environment(WhereModel.self) private var model + @Environment(\.primaryAppIconName) private var primaryAppIconName - @ViewBuilder - private func destination(for route: SettingsRoute) -> some View { + var body: some View { switch route.destination { case .attachments: EvidenceListView(report: report) @@ -343,14 +222,6 @@ struct SettingsView: View { } } - /// Regions with days in the selected report year, so the region editor can - /// surface a "used this year" group (grouping order only — it doesn't affect - /// what's saved). `.other` isn't a pickable region, so it's dropped. - private var regionsUsedThisYear: Set { - guard let totals = report.report?.totals else { return [] } - return Set(totals.filter { $0.key != .other && $0.value > 0 }.map(\.key)) - } - private var featureDiscoveryPresentation: FeatureDiscoveryPresentation { FeatureDiscoveryPresentation( report: report.report, @@ -366,6 +237,15 @@ struct SettingsView: View { /// The extra right-to-left variant exercises the RTL configuration axis /// on a directional screen (leading labels, trailing values/toggles). static var snapshots: [SnapshotCase] { + whereSnapshot( + name: "BrowseColumn", + configurations: [SnapshotConfiguration(device: DuoLayoutSnapshotFixture + .browseColumn)], + ) { + snapshotList(report: PreviewSupport.loadedYearReportModel()) + .environment(PreviewSupport.loadedModel()) + .environment(PreviewSupport.loadedSession()) + } whereSnapshot( name: "Default", configurations: .fullContentScreenRepresentative + [ @@ -375,7 +255,7 @@ struct SettingsView: View { ), ], ) { - SettingsView(report: PreviewSupport.loadedYearReportModel()) + snapshotList(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) } @@ -383,7 +263,7 @@ struct SettingsView: View { name: "UpdateAvailable", configurations: .fullContentPhoneLightDark, ) { - SettingsView(report: PreviewSupport.loadedYearReportModel()) + snapshotList(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) .environment(InstallerPreview.model(.available)) @@ -392,7 +272,7 @@ struct SettingsView: View { name: "LocalBuild", configurations: .fullContentPhoneLightDark, ) { - SettingsView(report: PreviewSupport.loadedYearReportModel()) + snapshotList(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) .environment(InstallerPreview.model(.local)) @@ -403,7 +283,7 @@ struct SettingsView: View { name: "DemoMode", configurations: .fullContentPhoneLightDark, ) { - SettingsView(report: PreviewSupport.loadedYearReportModel()) + snapshotList(report: PreviewSupport.loadedYearReportModel()) .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) .environment(\.isInDemoMode, true) @@ -412,7 +292,7 @@ struct SettingsView: View { name: "RecordingConfigurationWarning", configurations: .fullContentPhoneLightDark, ) { - SettingsView( + snapshotList( report: PreviewSupport.loadedYearReportModel(), recordingWarning: PreviewSupport.recordingConfigurationWarningModel(), ) @@ -420,6 +300,29 @@ struct SettingsView: View { .environment(PreviewSupport.loadedSession()) } } + + private static func snapshotList( + report: YearReportModel, + recordingWarning: RecordingConfigurationWarningModel? = nil, + ) -> some View { + let backup = BackupModel(services: report.services) + let reminders = RemindersSettingsModel( + services: report.services, + preferences: report.preferences, + now: report.now, + ) + return NavigationStack { + SettingsContent(report: report, recordingWarning: recordingWarning) + .navigationDestination(for: SettingsRoute.self) { route in + SettingsDestinationView( + route: route, + report: report, + backup: backup, + reminders: reminders, + ) + } + } + } } #Preview { diff --git a/Where/WhereUI/Sources/Year/YearView.swift b/Where/WhereUI/Sources/Year/YearView.swift index edb64f0f2..2e488c27c 100644 --- a/Where/WhereUI/Sources/Year/YearView.swift +++ b/Where/WhereUI/Sources/Year/YearView.swift @@ -1,3 +1,4 @@ +import BroadwayUI import SFSafeSymbols import SnapshotKit import SwiftUI @@ -10,12 +11,21 @@ struct YearView: View { let report: YearReportModel @State private var mode: YearMode + @State private var planningDestination: PlannedStaysDestination? + @State private var calendarPresentation: CalendarPresentationState @Environment(\.stylesheet) private var stylesheet - init(report: YearReportModel, initialMode: YearMode = .calendar) { + init( + report: YearReportModel, + initialMode: YearMode = .calendar, + ) { self.report = report _mode = State(initialValue: initialMode) + _calendarPresentation = State(initialValue: CalendarPresentationState(mode: CalendarModel( + report: report, + persistenceKey: .year, + ).isOverview(fallback: false) ? .overview : .detail)) } var body: some View { @@ -23,9 +33,18 @@ struct YearView: View { Group { switch mode { case .calendar: - CalendarContentView(report: report, persistenceKey: .year) + CalendarContentView( + report: report, + presentationState: calendarPresentation, + persistenceKey: .year, + scrollRole: .year, + planningDestination: $planningDestination, + ) case .timeline: - PresenceTimelineList(report: report) + PresenceTimelineList( + report: report, + planningDestination: $planningDestination, + ) } } // Crossfade between the two views rather than hard-cutting. @@ -42,6 +61,7 @@ struct YearView: View { .padding(.bottom, stylesheet.spacing.xLarge) } } + .modifier(PlanningPresentation(report: report, destination: $planningDestination)) } } diff --git a/Where/WhereUI/Tests/CalendarContentViewTests.swift b/Where/WhereUI/Tests/CalendarContentViewTests.swift index 2cf876437..32d204289 100644 --- a/Where/WhereUI/Tests/CalendarContentViewTests.swift +++ b/Where/WhereUI/Tests/CalendarContentViewTests.swift @@ -30,7 +30,7 @@ struct CalendarContentViewTests { try await waitForCalendar("overview to load") { element("November", in: host.view) != nil && isEnabled("Show Months", in: host.view) } - for _ in 0 ..< 3 { + for iteration in 0 ..< 6 { try activate(#require(element("November", in: host.view))) if !reduceMotion { try await waitForCalendar("month transition to start") { @@ -38,7 +38,7 @@ struct CalendarContentViewTests { .contains(.notEnabled) == true } } - try await waitForCalendar("November to expand") { + try await waitForCalendar("November to expand on iteration \(iteration)") { isEnabled("Show Year", in: host.view) && isVisible("November", in: host.view, expanded: true) } @@ -64,7 +64,8 @@ struct CalendarContentViewTests { } private func element(_ prefix: String, in view: UIView) -> NSObject? { - accessibilityObjects(in: view).first { $0.accessibilityLabel?.hasPrefix(prefix) == true } + HostedAccessibility.objects(in: view) + .first { $0.accessibilityLabel?.hasPrefix(prefix) == true } } private func isEnabled(_ label: String, in view: UIView) -> Bool { @@ -99,24 +100,395 @@ struct CalendarContentViewTests { } } - private func accessibilityObjects(in root: NSObject) -> [NSObject] { - var visited: Set = [] - func visit(_ object: NSObject) -> [NSObject] { - guard visited.insert(ObjectIdentifier(object)).inserted else { return [] } - var result = [object] - if let view = object as? UIView { - result += view.subviews.flatMap(visit) + @Test func overviewAndTabletopDoNotReplaceTheForecastAnchor() async throws { + let report = PreviewSupport.loadedYearReportModel() + let fixture = CalendarPresentationFixture() + let reveals = CalendarRevealTestSupport() + let host = UIHostingController(rootView: CalendarPresentationHarness( + fixture: fixture, + report: report, + reveals: reveals, + )) + try await show(host) { controller in + controller.view.window?.layer.speed = 1 + try await waitForCalendar("initial forecast") { + guard let frame = element("Planned stays", in: host.view)?.accessibilityFrame + else { return false } + let bottom = host.view.convert(host.view.safeAreaLayoutGuide.layoutFrame, to: nil) + .maxY + - WhereStylesheet.default.locationForecast.padding + return abs(frame.maxY - bottom) < 1 } - let count = object.accessibilityElementCount() - if count > 0, count < 10000 { - for index in 0 ..< count { - if let child = object.accessibilityElement(at: index) as? NSObject { - result += visit(child) - } + let initialBottom = try #require(element("Planned stays", in: host.view)? + .accessibilityFrame.maxY) + try activate(#require(element("Show Year", in: host.view))) + try await waitForCalendar("overview transition starts") { + element("Show Months", in: host.view)?.accessibilityTraits + .contains(.notEnabled) == true + } + try await waitForCalendar("overview") { + isEnabled("Show Months", in: host.view) && isVisible( + "July", + in: host.view, + expanded: false, + ) + } + try activate(#require(element("Show Months", in: host.view))) + try await waitForCalendar("detail transition starts") { + element("Show Year", in: host.view)?.accessibilityTraits + .contains(.notEnabled) == true + } + do { + try await waitForCalendar("forecast after overview") { + isEnabled("Show Year", in: host.view) && element("Planned stays", in: host.view) + .map { abs($0.accessibilityFrame.maxY - initialBottom) < 1 } == true + } + } catch { + throw TestHostError( + "\(error); expectedBottom=\(initialBottom), frame=\(String(describing: element("Planned stays", in: host.view)?.accessibilityFrame)), enabled=\(isEnabled("Show Year", in: host.view))", + ) + } + fixture.showsTabletop = true + try await waitForCalendar("tabletop month") { isVisible( + "July", + in: host.view, + expanded: true, + ) && element("Planned stays", in: host.view) == nil } + fixture.showsTabletop = false + try await waitForCalendar("forecast after tabletop") { element( + "Planned stays", + in: host.view, + ).map { abs($0.accessibilityFrame.maxY - initialBottom) < 1 } == true } + } + } + + @Test func theUnselectedLocationsCalendarKeepsItsSessionOnlyZoomMode() async throws { + let report = PreviewSupport.loadedYearReportModel() + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + LocationsView(report: report) + }.bScrollRestorationScope()) + host.safeAreaRegions = [] + try await show(host) { controller in + try await AdaptivePaneTestSupport.resize( + controller, + state: state, + to: .innerLandscape, + ) + try await waitForCalendar("unselected detail calendar") { isEnabled( + "Show Year", + in: host.view, + ) } + try activate(#require(element("Show Year", in: host.view))) + try await waitForCalendar("session-only overview") { isEnabled( + "Show Months", + in: host.view, + ) } + try await AdaptivePaneTestSupport.resize(controller, state: state, to: .tabletop) + try await waitForCalendar("tabletop calendar") { isVisible( + "July", + in: host.view, + expanded: true, + ) } + try await AdaptivePaneTestSupport.resize( + controller, + state: state, + to: .innerLandscape, + ) + try await waitForCalendar("session-only overview after folding") { isEnabled( + "Show Months", + in: host.view, + ) } + #expect(report.preferences.calendarOverview(forKey: .year) == nil) + } + } + + @Test func changingRegionsResetsTheAnchorButKeepsEachZoomPreference() async throws { + let report = PreviewSupport.loadedYearReportModel() + report.preferences.setCalendarOverview(true, forKey: .region(.california)) + report.preferences.setCalendarOverview(false, forKey: .region(.newYork)) + let navigation = LocationsView.Navigation() + navigation.select(region: .california) + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + LocationsView(report: report, navigation: navigation) + }.bScrollRestorationScope()) + host.safeAreaRegions = [] + try await show(host) { controller in + try await AdaptivePaneTestSupport.resize( + controller, + state: state, + to: .innerLandscape, + ) + try await waitForCalendar("California overview") { isEnabled( + "Show Months", + in: host.view, + ) } + try activate(#require(element("March", in: host.view))) + try await waitForCalendar("California March") { + isEnabled("Show Year", in: host.view) && isVisible( + "March", + in: host.view, + expanded: true, + ) + } + try activate(#require(element("Show Year", in: host.view))) + try await waitForCalendar("saved California overview") { isEnabled( + "Show Months", + in: host.view, + ) } + navigation.select(region: .newYork) + try await waitForCalendar("New York detail") { + isEnabled("Show Year", in: host.view) && isVisible( + "July", + in: host.view, + expanded: true, + ) + } + navigation.select(region: .california) + try await waitForCalendar("restored California overview preference") { isEnabled( + "Show Months", + in: host.view, + ) } + try activate(#require(element("Show Months", in: host.view))) + try await waitForCalendar("fresh California context") { isEnabled( + "Show Year", + in: host.view, + ) && isVisible("July", in: host.view, expanded: true) && !isVisible( + "March", + in: host.view, + expanded: true, + ) } + } + } + + @Test func removingTheForecastFallsBackToTheInitialMonth() async throws { + let report = PreviewSupport.loadedYearReportModel() + let host = UIHostingController(rootView: CalendarContentView(report: report) + .whereBroadwayRoot().bScrollRestorationScope()) + try await show(host) { _ in + try await waitForCalendar("initial forecast") { + element("Planned stays", in: host.view) != nil + } + try await report.setEstimatedTimeAndPlanningEnabled(false) + try await waitForCalendar("fallback after forecast removal") { element( + "Planned stays", + in: host.view, + ) == nil && isVisible("July", in: host.view, expanded: true) } + } + } + + @Test func aRevealWaitsForDelayedCalendarData() async throws { + let store = try TestStore() + let report = PlanningTestSupport.report(store: store) + let reveals = CalendarRevealTestSupport() + let month = CalendarDay(year: report.selectedYear, month: 2, day: 1) + reveals.select(month: month) + await store.enableFirstSamplesGate() + let load = Task { await report.refresh() } + await store.awaitFirstSamplesCall() + let host = UIHostingController(rootView: CalendarContentView( + report: report, + monthReveal: reveals.binding, + ).whereBroadwayRoot().bScrollRestorationScope()) + do { + try await show(host) { _ in + #expect(reveals.request?.isPending == true) + await store.releaseFirstSamplesCall() + await load.value + try await waitForCalendar("delayed February reveal") { + reveals.request?.isPending == false && isVisible( + "February", + in: host.view, + expanded: true, + ) } } - return result + } catch { + await store.releaseFirstSamplesCall() + await load.value + throw error + } + } + + @Test func repeatedMonthRevealExpandsOverviewAndKeepsMonthTarget() async throws { + let report = PreviewSupport.loadedYearReportModel() + let reveals = CalendarRevealTestSupport() + let month = CalendarDay(year: report.selectedYear, month: 2, day: 1) + let host = UIHostingController(rootView: NavigationStack { + CalendarContentView( + report: report, + monthReveal: reveals.binding, + initialMode: .overview, + persistenceKey: .year, + ) + }.whereBroadwayRoot().bScrollRestorationScope()) + try await show(host) { _ in + try await waitForCalendar("overview to load") { isEnabled("Show Months", in: host.view) + } + for _ in 0 ..< 2 { + reveals.select(month: month) + try await waitForCalendar("explicit February reveal") { + reveals.request?.isPending == false + && isVisible("February", in: host.view, expanded: true) + && isEnabled("Show Year", in: host.view) + } + try activate(#require(element("Show Year", in: host.view))) + try await waitForCalendar("overview after reveal") { isEnabled( + "Show Months", + in: host.view, + ) } + } + } + } + + @Test func aConsumedRevealDoesNotReplayOverBrowsingAfterRemount() async throws { + let report = PreviewSupport.loadedYearReportModel() + let reveals = CalendarRevealTestSupport() + let capture = ScrollPositionCapture() + let store = BScrollRestorationStore() + let february = CalendarDay(year: report.selectedYear, month: 2, day: 1) + let june = CalendarPresentationState.Target.month(CalendarDay( + year: report.selectedYear, + month: 6, + day: 1, + )) + func calendar() -> some View { + CalendarContentView(report: report, monthReveal: reveals.binding) + .overlay { + ScrollPositionProbe( + CalendarContentView.self, + role: CalendarContentView.ScrollRole.standalone, + context: CalendarContentView.ScrollContext( + region: nil, + year: report.selectedYear, + ), + idType: CalendarPresentationState.Target.self, + capture: capture, + ) + } + .whereBroadwayRoot() + .bScrollRestorationScope(store) + } + let first = UIHostingController(rootView: calendar()) + try await show(first) { _ in + reveals.select(month: february) + try await waitForCalendar("February reveal consumed") { + reveals.request?.isPending == false && isVisible( + "February", + in: first.view, + expanded: true, + ) + } + capture.binding?.wrappedValue.scrollTo(id: june, anchor: .top) + try await waitForCalendar("independent browsing to June") { + isVisible("June", in: first.view, expanded: true) + } + } + let requestID = try #require(reveals.request?.id) + let second = UIHostingController(rootView: calendar()) + try await show(second) { _ in + try await waitForCalendar("June restored without replaying February") { + capture.binding?.wrappedValue + .viewID(type: CalendarPresentationState.Target.self) == june + && isVisible("June", in: second.view, expanded: true) + } + #expect(reveals.request?.id == requestID) + #expect(reveals.request?.isPending == false) + } + } + + @Test func mountedCalendarPositionsAfterTheNewYearsMonthsArrive() async throws { + let report = PreviewSupport.loadedYearReportModel() + let reveals = CalendarRevealTestSupport() + let host = UIHostingController(rootView: CalendarContentView( + report: report, + monthReveal: reveals.binding, + ) + .whereBroadwayRoot().bScrollRestorationScope()) + try await show(host) { _ in + try await waitForCalendar("initial forecast") { + element("Planned stays", in: host.view) != nil + } + reveals.select( + month: CalendarDay(year: report.selectedYear, month: 2, day: 1), + ) + try await waitForCalendar("February before year change") { isVisible( + "February", + in: host.view, + expanded: true, + ) } + await report.select(year: report.selectedYear - 1) + try await waitForCalendar("December after year change") { isVisible( + "December", + in: host.view, + expanded: true, + ) } + } + } + + @Test func aRemountedCalendarPreservesTheInitialEstimateAlignment() async throws { + let report = PreviewSupport.loadedYearReportModel() + let store = BScrollRestorationStore() + let first = UIHostingController(rootView: CalendarContentView(report: report) + .whereBroadwayRoot().bScrollRestorationScope(store)) + var initialBottom: CGFloat? + try await show(first) { _ in + try await waitForCalendar("initial estimate to reach the viewport bottom") { + guard let frame = element("Planned stays", in: first.view)?.accessibilityFrame + else { return false } + let bottom = first.view.convert(first.view.safeAreaLayoutGuide.layoutFrame, to: nil) + .maxY + - WhereStylesheet.default.locationForecast.padding + return abs(frame.maxY - bottom) < 1 + } + initialBottom = element("Planned stays", in: first.view)?.accessibilityFrame.maxY + } + let expectedBottom = try #require(initialBottom) + let second = UIHostingController(rootView: CalendarContentView(report: report) + .whereBroadwayRoot().bScrollRestorationScope(store)) + try await show(second) { _ in + try await waitForCalendar("remounted estimate to retain its bottom alignment") { + guard let bottom = element("Planned stays", in: second.view)?.accessibilityFrame + .maxY else { return false } + return abs(bottom - expectedBottom) <= 1 + } + } + } + + @Test func aRemountedCalendarUsesItsRenderedMonthAnchor() async throws { + let report = PreviewSupport.loadedYearReportModel() + let reveals = CalendarRevealTestSupport() + let store = BScrollRestorationStore() + let first = UIHostingController(rootView: CalendarContentView( + report: report, + monthReveal: reveals.binding, + ) + .whereBroadwayRoot().bScrollRestorationScope(store)) + try await show(first) { _ in + try await waitForCalendar("initial forecast") { element( + "Planned stays", + in: first.view, + ) != nil } + reveals.select( + month: CalendarDay(year: report.selectedYear, month: 2, day: 1), + ) + try await waitForCalendar("rendered February anchor") { isVisible( + "February", + in: first.view, + expanded: true, + ) } + } + // A new view has no reveal request: only the retained native position can restore + // February. + let second = UIHostingController(rootView: CalendarContentView(report: report) + .whereBroadwayRoot().bScrollRestorationScope(store)) + try await show(second) { _ in + try await waitForCalendar("restored February anchor") { isVisible( + "February", + in: second.view, + expanded: true, + ) } } - return visit(root) } } diff --git a/Where/WhereUI/Tests/CalendarModelTests.swift b/Where/WhereUI/Tests/CalendarModelTests.swift new file mode 100644 index 000000000..409d7aa9a --- /dev/null +++ b/Where/WhereUI/Tests/CalendarModelTests.swift @@ -0,0 +1,46 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct CalendarModelTests { + @Test func preferencesRemainIndependentAcrossRecreationAndYearChanges() async { + let report = PreviewSupport.loadedYearReportModel() + let california = CalendarModel(report: report, persistenceKey: .region(.california)) + let newYork = CalendarModel(report: report, persistenceKey: .region(.newYork)) + california.setOverview(true) + newYork.setOverview(false) + #expect(CalendarModel(report: report, persistenceKey: .region(.california)) + .isOverview(fallback: false)) + await report.select(year: report.selectedYear - 1) + #expect(california.isOverview(fallback: false)) + #expect(!newYork.isOverview(fallback: true)) + let sessionOnly = CalendarModel(report: report, persistenceKey: nil) + sessionOnly.setOverview(true) + #expect(!sessionOnly.isOverview(fallback: false)) + } + + @Test func overviewAvailabilityMatchesDetailedPlanningVisibility() throws { + let report = PreviewSupport.plannedStayYearReportModel() + let yearReport = try #require(report.report) + let months = try yearReport.calendarMonths( + calendar: report.calendar, + referenceDate: report.referenceDate, + flightDays: [], + ) + let visible = CalendarModel.visibleMonths( + months, + report: report, + focusedRegion: nil, + ) + #expect(months.count == 12) + #expect(visible.contains { $0.month > 7 }) + let hidden = PreviewSupport.plannedStayYearReportModel(showsEstimatedTimeAndPlanning: false) + let hiddenMonths = CalendarModel.visibleMonths( + months, + report: hidden, + focusedRegion: nil, + ) + #expect(hiddenMonths.map(\.month) == Array(1 ... 7)) + } +} diff --git a/Where/WhereUI/Tests/CalendarPresentationModelTests.swift b/Where/WhereUI/Tests/CalendarPresentationModelTests.swift deleted file mode 100644 index 6757992bd..000000000 --- a/Where/WhereUI/Tests/CalendarPresentationModelTests.swift +++ /dev/null @@ -1,196 +0,0 @@ -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct CalendarPresentationModelTests { - private func month(_ number: Int, year: Int = 2026) -> CalendarDay { - CalendarDay(year: year, month: number, day: 1) - } - - private func model( - preferences: WherePreferences = makePreferences(), - persistenceKey: CalendarZoomKey? = nil, - ) -> CalendarPresentationModel { - let model = CalendarPresentationModel( - mode: .detail, - preferences: preferences, - persistenceKey: persistenceKey, - ) - model.reconcile( - year: 2026, - availableMonths: (1 ... 10).map { month($0) }, - currentMonth: month(7), - estimateMonth: month(7), - ) - return model - } - - @Test(arguments: [false, true]) - func savedZoomOverridesInitialModeAcrossRecreation(overview: Bool) { - let store = RecordingKeyValueStore() - WherePreferences(store: store).setCalendarOverview(overview, forKey: .year) - - let restored = CalendarPresentationModel( - mode: overview ? .detail : .overview, - preferences: WherePreferences(store: store), - persistenceKey: .year, - ) - - #expect(restored.mode == (overview ? .overview : .detail)) - #expect(store.writeCount == 1) - } - - @Test func missingPreferenceUsesInitialModeWithoutWritingIt() { - let store = RecordingKeyValueStore() - let model = CalendarPresentationModel( - mode: .overview, - preferences: WherePreferences(store: store), - persistenceKey: .year, - ) - #expect(model.mode == .overview) - #expect(store.writeCount == 0) - } - - @Test func nilKeyNeverReadsOrWritesPreferences() { - let store = RecordingKeyValueStore() - let model = model(preferences: WherePreferences(store: store), persistenceKey: nil) - model.toggle() - #expect(model.mode == .overview) - model.expand(.estimate(month(7))) - model.finishPinch(magnification: 0.8, destination: nil) - model.finishPinch(magnification: 1.2, destination: .month(month(5))) - #expect(model.mode == .detail) - #expect(store.readCount == 0) - #expect(store.writeCount == 0) - } - - @Test func everyZoomIntentPersistsOnlyItsCalendar() { - let preferences = makePreferences() - preferences.setCalendarOverview(false, forKey: .year) - preferences.setCalendarOverview(true, forKey: .region(.california)) - let key = CalendarZoomKey.region(.newYork) - let model = model(preferences: preferences, persistenceKey: key) - - model.finishPinch(magnification: 0.8, destination: nil) - #expect(preferences.calendarOverview(forKey: key) == true) - model.finishPinch(magnification: 1.2, destination: .month(month(9))) - #expect(preferences.calendarOverview(forKey: key) == false) - model.toggle() - #expect(preferences.calendarOverview(forKey: key) == true) - model.expand(.estimate(month(7))) - #expect(preferences.calendarOverview(forKey: key) == false) - model.toggle() - model.expand(.month(month(5))) - #expect(preferences.calendarOverview(forKey: key) == false) - - #expect(preferences.calendarOverview(forKey: .year) == false) - #expect(preferences.calendarOverview(forKey: .region(.california)) == true) - } - - @Test func rejectedAndUnchangedIntentsDoNotWritePreferences() { - let store = RecordingKeyValueStore() - let model = model(preferences: WherePreferences(store: store), persistenceKey: .year) - model.expand(.month(month(5))) - model.finishPinch(magnification: 0.81, destination: nil) - #expect(store.writeCount == 0) - - model.toggle() - #expect(store.writeCount == 1) - model.expand(.month(month(11))) - model.finishPinch(magnification: 1.19, destination: .month(month(5))) - model.reconcile( - year: 2025, - availableMonths: [month(12, year: 2025)], - currentMonth: nil, - estimateMonth: nil, - ) - #expect(model.mode == .overview) - #expect(store.writeCount == 1) - } - - @Test func pinchThresholdsAndRestoration() { - let model = model() - model.remember(.month(month(5))) - model.finishPinch(magnification: 0.81, destination: nil) - #expect(model.mode == .detail) - model.finishPinch(magnification: 0.8, destination: nil) - #expect(model.mode == .overview) - model.finishPinch(magnification: 1.19, destination: .month(month(9))) - #expect(model.mode == .overview) - model.toggle() - #expect(model.target == .month(month(5))) - model.toggle() - model.finishPinch(magnification: 1.2, destination: .month(month(9))) - #expect(model.mode == .detail) - #expect(model.target == .month(month(9))) - } - - @Test func unavailableMonthRejectsButPlannedFutureMonthExpands() { - let model = model() - model.toggle() - #expect(!model.expand(.month(month(11)))) - #expect(model.mode == .overview) - #expect(model.rejectedTarget == .month(month(11))) - #expect(model.rejectionCount == 1) - model.finishPinch(magnification: 1.3, destination: .month(month(11))) - #expect(model.rejectionCount == 2) - #expect(model.expand(.month(month(10)))) - #expect(model.target == .month(month(10))) - } - - @Test func estimateRestoresAndReconcilesWhenHidden() { - let model = model() - model.toggle() - #expect(model.expand(.estimate(month(7)))) - #expect(model.target == .estimate(month(7))) - model.reconcile( - year: 2026, - availableMonths: (1 ... 7).map { month($0) }, - currentMonth: month(7), - estimateMonth: nil, - ) - #expect(model.target == .month(month(7))) - #expect(!model.isAvailable(.estimate(month(7)))) - } - - @Test func yearChangePreservesZoomAndUsesLastAvailableMonth() { - let model = model() - model.toggle() - model.reconcile( - year: 2025, - availableMonths: (1 ... 12).map { month($0, year: 2025) }, - currentMonth: nil, - estimateMonth: nil, - ) - #expect(model.mode == .overview) - #expect(model.target == .month(month(12, year: 2025))) - model.reconcile(year: 2027, availableMonths: [], currentMonth: nil, estimateMonth: nil) - #expect(model.target == nil) - #expect(!model.expand(nil)) - } - - @Test func overviewAvailabilityMatchesDetailedPlanningVisibility() throws { - let report = PreviewSupport.plannedStayYearReportModel() - let yearReport = try #require(report.report) - let months = try yearReport.calendarMonths( - calendar: report.calendar, - referenceDate: report.referenceDate, - flightDays: [], - ) - let visible = CalendarPresentationModel.visibleMonths( - months, - report: report, - focusedRegion: nil, - ) - #expect(months.count == 12) - #expect(visible.contains { $0.month > 7 }) - let hidden = PreviewSupport.plannedStayYearReportModel(showsEstimatedTimeAndPlanning: false) - let hiddenMonths = CalendarPresentationModel.visibleMonths( - months, - report: hidden, - focusedRegion: nil, - ) - #expect(hiddenMonths.map(\.month) == Array(1 ... 7)) - } -} diff --git a/Where/WhereUI/Tests/CalendarPresentationStateTests.swift b/Where/WhereUI/Tests/CalendarPresentationStateTests.swift new file mode 100644 index 000000000..9686554e4 --- /dev/null +++ b/Where/WhereUI/Tests/CalendarPresentationStateTests.swift @@ -0,0 +1,20 @@ +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct CalendarPresentationStateTests { + @Test func unavailableDestinationsRejectWithoutChangingMode() { + let state = CalendarPresentationState(mode: .overview) + let july = CalendarDay(year: 2026, month: 7, day: 1) + state.reconcile(year: 2026, availableMonths: [july], estimateMonth: nil) + #expect(!state.expand(.estimate(july))) + #expect(state.mode == .overview) + #expect(state.rejectionCount == 1) + #expect(state.expand(.month(july))) + state.mode = .overview + state.reconcile(year: 2025, availableMonths: [], estimateMonth: nil) + #expect(state.mode == .overview) + #expect(state.rejectedTarget == nil) + } +} diff --git a/Where/WhereUI/Tests/CalendarRevealTestSupport.swift b/Where/WhereUI/Tests/CalendarRevealTestSupport.swift new file mode 100644 index 000000000..c56343584 --- /dev/null +++ b/Where/WhereUI/Tests/CalendarRevealTestSupport.swift @@ -0,0 +1,18 @@ +import Observation +import SwiftUI +import WhereCore +@testable import WhereUI + +@MainActor @Observable +final class CalendarRevealTestSupport { + var request: CalendarMonthReveal? + + var binding: Binding { + @Bindable var owner = self + return $owner.request + } + + func select(month: CalendarDay) { + request = CalendarMonthReveal(month: month) + } +} diff --git a/Where/WhereUI/Tests/CalendarZoomTransitionModelTests.swift b/Where/WhereUI/Tests/CalendarZoomTransitionStateTests.swift similarity index 83% rename from Where/WhereUI/Tests/CalendarZoomTransitionModelTests.swift rename to Where/WhereUI/Tests/CalendarZoomTransitionStateTests.swift index bda89813f..eaa801097 100644 --- a/Where/WhereUI/Tests/CalendarZoomTransitionModelTests.swift +++ b/Where/WhereUI/Tests/CalendarZoomTransitionStateTests.swift @@ -4,24 +4,24 @@ import WhereCore @testable import WhereUI @MainActor -struct CalendarZoomTransitionModelTests { +struct CalendarZoomTransitionStateTests { private let viewport = CGRect(x: 0, y: 0, width: 402, height: 800) private let content = CGRect(x: 0, y: 0, width: 402, height: 2000) private let compact = CGRect(x: 140, y: 420, width: 120, height: 100) private let expanded = CGRect(x: 16, y: 0, width: 370, height: 360) - private let november = CalendarPresentationModel.Target.month(CalendarDay( + private let november = CalendarPresentationState.Target.month(CalendarDay( year: 2026, month: 11, day: 1, )) - private let december = CalendarPresentationModel.Target.month(CalendarDay( + private let december = CalendarPresentationState.Target.month(CalendarDay( year: 2026, month: 12, day: 1, )) @Test func waitsForTheSelectedMonthToFinishScrollingBeforeMatching() throws { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() model.begin( from: .init( mode: .overview, @@ -61,8 +61,31 @@ struct CalendarZoomTransitionModelTests { #expect(tile.destination == expanded) } + @Test func aRestoredCenteredAnchorCanFinishTheTransition() throws { + let state = CalendarZoomTransitionState() + state.begin( + from: .init( + mode: .overview, + frames: [november: compact], + viewport: viewport, + content: content, + ), + to: .detail, + focus: november, + alignment: .center, + ) + let centered = expanded.offsetBy(dx: 0, dy: (viewport.height - expanded.height) / 2) + state.resolve(destination: .init( + mode: .detail, + frames: [november: centered], + viewport: viewport, + content: content, + )) + #expect(try movingPlan(state).tiles.first?.destination == centered) + } + @Test func matchesBothVisibleMonthsWhenZoomingOut() throws { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() let decemberDetail = expanded.offsetBy(dx: 0, dy: 376) let decemberOverview = compact.offsetBy(dx: 128, dy: 0) model.begin( @@ -90,23 +113,23 @@ struct CalendarZoomTransitionModelTests { #expect(plan.tiles.allSatisfy { $0.visibility == .both }) } - @Test(arguments: [CalendarPresentationModel.Mode.detail, .overview]) - func projectedFramesCannotIntroduceAnOffscreenMonth(destinationMode: CalendarPresentationModel + @Test(arguments: [CalendarPresentationState.Mode.detail, .overview]) + func projectedFramesCannotIntroduceAnOffscreenMonth(destinationMode: CalendarPresentationState .Mode) throws { - let august = CalendarPresentationModel.Target.month(CalendarDay( + let august = CalendarPresentationState.Target.month(CalendarDay( year: 2026, month: 8, day: 1, )) - let overview = CalendarZoomTransitionModel.Measurement( + let overview = CalendarZoomTransitionState.Measurement( mode: .overview, frames: [november: compact, august: compact.offsetBy(dx: 0, dy: -108)], viewport: viewport, content: content, ) - let detail = CalendarZoomTransitionModel.Measurement( + let detail = CalendarZoomTransitionState.Measurement( mode: .detail, frames: [ november: expanded.offsetBy(dx: 0, dy: 400), @@ -115,7 +138,7 @@ struct CalendarZoomTransitionModelTests { viewport: viewport, content: content.offsetBy(dx: 0, dy: -1200), ) - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() model.begin( from: destinationMode == .detail ? overview : detail, to: destinationMode, @@ -132,7 +155,7 @@ struct CalendarZoomTransitionModelTests { func offscreenCounterpartsContinueTheSelectedMonthsScaleAndTranslation( hasOffscreenFrame: Bool, ) throws { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() let decemberOverview = compact.offsetBy(dx: 128, dy: 0) model.begin( from: .init( @@ -161,8 +184,8 @@ struct CalendarZoomTransitionModelTests { } @Test func estimateCanSettleAgainstTheBottomInsteadOfTheTop() throws { - let model = CalendarZoomTransitionModel() - let estimate = CalendarPresentationModel.Target.estimate(CalendarDay( + let model = CalendarZoomTransitionState() + let estimate = CalendarPresentationState.Target.estimate(CalendarDay( year: 2026, month: 7, day: 1, @@ -194,8 +217,8 @@ struct CalendarZoomTransitionModelTests { } @Test func staleCompletionCannotEndANewerTransition() throws { - let model = CalendarZoomTransitionModel() - let measurement = CalendarZoomTransitionModel.Measurement( + let model = CalendarZoomTransitionState() + let measurement = CalendarZoomTransitionState.Measurement( mode: .overview, frames: [november: compact], viewport: viewport, @@ -214,7 +237,7 @@ struct CalendarZoomTransitionModelTests { @Test(arguments: [false, true]) func resizingCancelsInsteadOfAnimatingStaleCoordinates(alreadyMoving: Bool) { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() model.begin( from: .init( mode: .overview, @@ -247,7 +270,7 @@ struct CalendarZoomTransitionModelTests { } @Test func disjointLazyLayoutsReleaseTheOverlay() { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() model.begin( from: .init( mode: .detail, @@ -270,8 +293,8 @@ struct CalendarZoomTransitionModelTests { } @Test func unrelatedLayoutCallbacksCannotStartTheFlight() { - let model = CalendarZoomTransitionModel() - let overview = CalendarZoomTransitionModel.Measurement( + let model = CalendarZoomTransitionState() + let overview = CalendarZoomTransitionState.Measurement( mode: .overview, frames: [november: compact], viewport: viewport, @@ -286,7 +309,7 @@ struct CalendarZoomTransitionModelTests { } @Test func subpixelViewportRoundingDoesNotCancelMatching() throws { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() model.begin( from: .init( mode: .overview, @@ -311,7 +334,7 @@ struct CalendarZoomTransitionModelTests { } @Test func waitsForIncomingMeasurementsAfterOutgoingAnchorsDisappear() { - let model = CalendarZoomTransitionModel() + let model = CalendarZoomTransitionState() model.begin( from: .init( mode: .detail, @@ -332,8 +355,8 @@ struct CalendarZoomTransitionModelTests { } @Test func offscreenEstimateEntersAtFullWidthBelowTheGrid() throws { - let model = CalendarZoomTransitionModel() - let estimate = CalendarPresentationModel.Target.estimate(CalendarDay( + let model = CalendarZoomTransitionState() + let estimate = CalendarPresentationState.Target.estimate(CalendarDay( year: 2026, month: 7, day: 1, @@ -364,10 +387,10 @@ struct CalendarZoomTransitionModelTests { #expect(tile.destination == destination) } - private func movingPlan(_ model: CalendarZoomTransitionModel) throws - -> CalendarZoomTransitionModel.Plan + private func movingPlan(_ model: CalendarZoomTransitionState) throws + -> CalendarZoomTransitionState.Plan { - try #require({ () -> CalendarZoomTransitionModel.Plan? in + try #require({ () -> CalendarZoomTransitionState.Plan? in guard case let .moving(plan) = model.phase else { return nil } return plan }()) diff --git a/Where/WhereUI/Tests/DeveloperDestinationTests.swift b/Where/WhereUI/Tests/DeveloperDestinationTests.swift index cdb1721ea..cbb351a10 100644 --- a/Where/WhereUI/Tests/DeveloperDestinationTests.swift +++ b/Where/WhereUI/Tests/DeveloperDestinationTests.swift @@ -10,6 +10,7 @@ .tool(.logs), .tool(.openSpans), .flyover, + .tool(.duoLayoutLab), .tool(.regionMap), .tool(.crashTesting), ], diff --git a/Where/WhereUI/Tests/DeveloperTabBarInsetTests.swift b/Where/WhereUI/Tests/DeveloperTabBarInsetTests.swift index fa4cd3654..7d4087a2a 100644 --- a/Where/WhereUI/Tests/DeveloperTabBarInsetTests.swift +++ b/Where/WhereUI/Tests/DeveloperTabBarInsetTests.swift @@ -1,18 +1,95 @@ import SwiftUI import TestHostSupport import Testing +import UIKit @testable import WhereUI @MainActor struct DeveloperTabBarInsetTests { - @Test func reduceKeepsTheLargestReportedInset() { + @Test func windowReaderDiscardsTransientInsetsBeforeDeferredDelivery() async throws { + let window = InsetsWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = DeveloperWindowInsetsReader.InsetView() + var reported: [UIEdgeInsets] = [] + view.changed = { reported.append($0) } + window.addSubview(view) + let initial = try #require(view.pending) + await initial.value + #expect(reported == [.zero]) + + window.simulatedInsets.bottom = 34 + view.scheduleUpdate() + let pending = try #require(view.pending) + // Restore the last published geometry before the task can execute. + // The second request must invalidate the transient 34pt observation. + window.simulatedInsets = .zero + view.scheduleUpdate() + await pending.value + #expect(reported == [.zero]) + #expect(view.pending == nil) + + window.simulatedInsets.bottom = 20 + view.scheduleUpdate() + let next = try #require(view.pending) + await next.value + #expect(reported == [.zero, UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)]) + #expect(view.pending == nil) + } + + @Test(arguments: [false, true]) + func windowReaderDropsPendingDeliveryWhenRemoved(dismantles: Bool) async throws { + let window = InsetsWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = DeveloperWindowInsetsReader.InsetView() + var reported: [UIEdgeInsets] = [] + view.changed = { reported.append($0) } + window.addSubview(view) + let initial = try #require(view.pending) + await initial.value + #expect(reported == [.zero]) + + window.simulatedInsets.bottom = 34 + view.scheduleUpdate() + let pending = try #require(view.pending) + if dismantles { + DeveloperWindowInsetsReader.dismantleUIView(view, coordinator: ()) + } else { + view.removeFromSuperview() + } + await pending.value + #expect(reported == [.zero]) + #expect(view.pending == nil) + } + + @Test func reduceKeepsEachEdgesLargestReportedInset() { var value = DeveloperTabBarInsetKey.defaultValue - DeveloperTabBarInsetKey.reduce(value: &value) { 40 } - #expect(value == 40) - DeveloperTabBarInsetKey.reduce(value: &value) { 10 } - #expect(value == 40) - DeveloperTabBarInsetKey.reduce(value: &value) { 55 } - #expect(value == 55) + DeveloperTabBarInsetKey.reduce(value: &value) { EdgeInsets( + top: 2, + leading: 4, + bottom: 40, + trailing: 8, + ) } + DeveloperTabBarInsetKey.reduce(value: &value) { EdgeInsets( + top: 1, + leading: 12, + bottom: 10, + trailing: 6, + ) } + #expect(value == EdgeInsets(top: 2, leading: 12, bottom: 40, trailing: 8)) + } + + @Test func sideBarsUseTheAttachedWindowsPhysicalInsets() { + let content = EdgeInsets(top: 8, leading: 64, bottom: 34, trailing: 84) + let window = UIEdgeInsets(top: 0, left: 0, bottom: 34, right: 84) + #expect(DeveloperBarInsets.additional( + content: content, + window: window, + direction: .leftToRight, + ) + == EdgeInsets(top: 8, leading: 64, bottom: 0, trailing: 0)) + #expect(DeveloperBarInsets.additional( + content: EdgeInsets(top: 8, leading: 84, bottom: 34, trailing: 64), + window: window, + direction: .rightToLeft, + ) == EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 64)) } /// The reporter must measure the *floating tab bar*, not just the home @@ -33,14 +110,24 @@ struct DeveloperTabBarInsetTests { .onPreferenceChange(DeveloperTabBarInsetKey.self) { box.value = $0 } try show(UIHostingController(rootView: root)) { _ in - try waitFor { box.value > 0 } - #expect(box.value > 0) + try waitFor { box.value.bottom > 0 } + #expect(box.value.bottom > 0) } } } +/// Supplies deterministic native-window changes without requiring a simulator scene. +@MainActor +private final class InsetsWindow: UIWindow { + var simulatedInsets = UIEdgeInsets.zero + + override var safeAreaInsets: UIEdgeInsets { + simulatedInsets + } +} + /// Bridges the `@Sendable` `onPreferenceChange` callback back to the test. Reads /// and writes are confined to the main actor by the hosted run loop. private final class InsetBox: @unchecked Sendable { - var value: CGFloat = 0 + var value = EdgeInsets() } diff --git a/Where/WhereUI/Tests/LocationForecastModelTests.swift b/Where/WhereUI/Tests/LocationForecastModelTests.swift index 75a051fbe..705fd4089 100644 --- a/Where/WhereUI/Tests/LocationForecastModelTests.swift +++ b/Where/WhereUI/Tests/LocationForecastModelTests.swift @@ -106,8 +106,8 @@ struct LocationForecastModelTests { let firstStay = try PlanningModelTestSupport.stay(region: .newYork) let secondStay = try PlanningModelTestSupport.stay(region: .california) try await model.create(stay: firstStay) - let firstGate = TestStore.PlanningReadGate() - let secondGate = TestStore.PlanningReadGate() + let firstGate = TestStore.PlanningOperationGate() + let secondGate = TestStore.PlanningOperationGate() await store.gateNextPlanningRead(with: firstGate) let firstRefresh = Task { await model.refresh() } @@ -150,8 +150,8 @@ struct LocationForecastModelTests { await model.refresh() let lastGoodSnapshot = model.planning try await model.create(stay: secondStay) - let firstGate = TestStore.PlanningReadGate() - let secondGate = TestStore.PlanningReadGate() + let firstGate = TestStore.PlanningOperationGate() + let secondGate = TestStore.PlanningOperationGate() await store.gateNextPlanningRead(with: firstGate) let firstRefresh = Task { await model.refresh() } @@ -184,7 +184,7 @@ struct LocationForecastModelTests { let stay = try PlanningModelTestSupport.stay(region: .newYork) try await model.create(stay: stay) await store.failPlanningReads() - let gate = TestStore.PlanningReadGate() + let gate = TestStore.PlanningOperationGate() await store.gateNextPlanningRead(with: gate) let failingRefresh = Task { await model.refresh() } await gate.waitUntilReached() @@ -208,7 +208,7 @@ struct LocationForecastModelTests { let model = PlanningModelTestSupport.model(store: store) let stay = try PlanningModelTestSupport.stay(region: .newYork) try await model.create(stay: stay) - let gate = TestStore.PlanningReadGate() + let gate = TestStore.PlanningOperationGate() await store.gateNextPlanningRead(with: gate) let activeRefresh = Task { await model.refresh() } await gate.waitUntilReached() diff --git a/Where/WhereUI/Tests/LocationsViewTests.swift b/Where/WhereUI/Tests/LocationsViewTests.swift index 496d856a7..58036d284 100644 --- a/Where/WhereUI/Tests/LocationsViewTests.swift +++ b/Where/WhereUI/Tests/LocationsViewTests.swift @@ -1,5 +1,11 @@ +import AccessibilitySnapshotParser +import BroadwayUI +import RegionKit +import SnapshotKit import SwiftUI +import TestHostSupport import Testing +import WhereCore @testable import WhereUI struct LocationsViewTests { @@ -7,3 +13,250 @@ struct LocationsViewTests { #expect(MemoryLayout.size < 16384) } } + +/// These tests resize native navigation in the shared test host window. +@MainActor +@Suite(.serialized) +struct LocationsViewTestsDuo { + @Test func selectedRegionIsIndicatedOnlyBesideItsCalendar() async throws { + let world = try await WhereFlyoverWorld.build() + let navigation = LocationsView.Navigation() + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + LocationsView(report: world.report, navigation: navigation) + }.bScrollRestorationScope()) + host.safeAreaRegions = [] + try await show(host) { controller in + controller.view.window?.layer.speed = 1 + navigation.select(region: .california) + // Returning to browsing retains the selected region for the next unfold. + navigation.compactColumn = .sidebar + for pose in [ + SnapshotConfiguration.DuoPose.outerPortrait, + .innerLandscape, + .tabletop, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + try await waitForStage("California card after \(pose)") { + HostedAccessibility.objects(in: controller.view).contains { + $0.accessibilityLabel?.hasPrefix("California:") == true + && $0.accessibilityTraits.contains(.button) + && $0.accessibilityFrame.intersects(controller.view.convert( + controller.view.bounds, + to: nil, + )) + && $0.accessibilityTraits + .contains(.selected) == (pose != .outerPortrait) + } + } + #expect(navigation.selectedRegion == .california) + } + } + } + + @Test func initialBrowsePositionFollowsInsetsUntilARegionIsRestored() async throws { + let world = try await WhereFlyoverWorld.build() + let navigation = LocationsView.Navigation() + let store = BScrollRestorationStore() + let capture = ScrollPositionCapture() + let host = UIHostingController(rootView: LocationsBrowsePane( + report: world.report, + navigation: navigation, + ) + .overlay { ScrollPositionProbe( + LocationsCardsView.self, + role: LocationsCardsView.ScrollRole.browse, + context: world.report.selectedYear, + idType: Region.self, + capture: capture, + ) } + .bScrollRestorationScope(store) + .dynamicTypeSize(.accessibility5) + .whereBroadwayRoot()) + + try await show(host) { controller in + controller.view.frame = CGRect(x: 0, y: 0, width: 466, height: 678) + controller.traitOverrides.horizontalSizeClass = .compact + controller.view.layoutIfNeeded() + try await waitForStage("initial scroll content") { + AdaptivePaneTestSupport.descendants( + of: controller.view, + matching: UIScrollView.self, + ).contains { $0.contentSize.height > $0.bounds.height } + } + var scroll = try #require(AdaptivePaneTestSupport.descendants( + of: controller.view, + matching: UIScrollView.self, + ).first { $0.contentSize.height > $0.bounds.height }) + @MainActor func currentScroll() -> UIScrollView? { + AdaptivePaneTestSupport.descendants( + of: controller.view, + matching: UIScrollView.self, + ) + .first { $0.window != nil && $0.contentSize.height > $0.bounds.height } + } + for topInset in [CGFloat(82), 0, 56, 0] { + let previousInset = scroll.adjustedContentInset.top + let previousHostInset = controller.view.safeAreaInsets.top + controller.additionalSafeAreaInsets.top = topInset + controller.view.setNeedsLayout() + // Lay out the host whose safe area changed, not only its window. + controller.view.layoutIfNeeded() + do { + try await waitForStage("host safe area after requested inset \(topInset)") { + abs(controller.view.safeAreaInsets.top - previousHostInset) > 1 + } + try await waitForStage( + "top anchoring after inset \(previousInset) -> requested \(topInset)", + ) { + guard let current = currentScroll() else { return false } + return abs(current.adjustedContentInset.top - previousInset) > 1 + && abs(current.contentOffset.y + current.adjustedContentInset.top) < 1 + } + } catch { + throw TestHostError( + "\(error); requested=\(controller.additionalSafeAreaInsets), hostSafeArea=\(controller.view.safeAreaInsets), scrollSafeArea=\(scroll.safeAreaInsets), adjusted=\(scroll.adjustedContentInset), offset=\(scroll.contentOffset), bounds=\(scroll.bounds), content=\(scroll.contentSize), attached=\(scroll.window != nil), current=\(String(describing: currentScroll()))", + ) + } + let current = try #require(currentScroll()) + scroll = current + } + let position = try #require(capture.binding) + position.wrappedValue.scrollTo(id: Region.california, anchor: .top) + try await waitForStage("browse to California") { + scroll.contentOffset.y + scroll.adjustedContentInset.top > 100 + } + #expect(navigation.selectedRegion == nil) + } + let restored = UIHostingController(rootView: LocationsBrowsePane(report: world.report) + .dynamicTypeSize(.accessibility5).whereBroadwayRoot().bScrollRestorationScope(store)) + try await show(restored) { controller in + controller.view.frame = CGRect(x: 0, y: 0, width: 466, height: 678) + controller.view.layoutIfNeeded() + try await waitForStage("restored California after pane replacement") { + AdaptivePaneTestSupport.descendants( + of: controller.view, + matching: UIScrollView.self, + ) + .contains { + $0.contentSize.height > $0.bounds.height && $0.contentOffset.y + $0 + .adjustedContentInset.top > 100 + } + } + } + } + + @Test func aPushedBrowseStackRemainsMountedThroughTabletop() async throws { + let world = try await WhereFlyoverWorld.build() + let navigation = LocationsView.Navigation() + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + LocationsView(report: world.report, navigation: navigation) + .environment(world.session).environment(world.model) + }.bScrollRestorationScope()) + host.safeAreaRegions = [] + @MainActor func controllers(_ root: UIViewController) -> [UIViewController] { + [root] + root.children.flatMap(controllers) + } + try await show(host) { controller in + controller.view.window?.layer.speed = 1 + try await AdaptivePaneTestSupport.resize( + controller, + state: state, + to: .outerPortrait, + ) + try await waitForStage("browse stack to mount") { + controllers(controller).contains { $0 is UINavigationController } + } + navigation.open(.flight(PreviewSupport.flightReview(state: .ready))) + do { + try await waitForStage("flight detail push") { + controllers(controller).compactMap { $0 as? UINavigationController } + .contains { $0.viewControllers.count > 1 } + } + } catch { + throw TestHostError( + "\(error); path=\(navigation.browsePath.count); controllers=\(controllers(controller).map { String(describing: type(of: $0)) + ":" + (($0 as? UINavigationController)?.navigationBar.topItem?.title ?? $0.title ?? "nil") + ":" + String(($0 as? UINavigationController)?.viewControllers.count ?? 0) })", + ) + } + let stack = try #require(controllers(controller) + .compactMap { $0 as? UINavigationController } + .first { $0.viewControllers.count > 1 }) + let pushed = try #require(stack.topViewController) + for pose in [SnapshotConfiguration.DuoPose.innerPortrait, .tabletop, .outerPortrait] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + #expect(navigation.browsePath.count == 1) + #expect(controllers(controller).contains { $0 === pushed }) + } + } + } + + private func waitForStage(_ stage: String, _ predicate: () -> Bool) async throws { + do { try await waitForAsync(predicate: predicate) } + catch { throw TestHostError("Locations stage: \(stage): \(error)") } + } + + @Test func foldingDuringLoadingAndAYearChangeDoesNotRestartOrPublishTheOlderRead() async throws { + let store = try TestStore() + let report = PlanningTestSupport.report(store: store) + let calendar = report.calendar + try await report.services.journal.addManualDay( + date: CalendarDay(year: 2024, month: 3, day: 1).startOfDay(in: calendar), + regions: [.newYork], + audit: nil, + ) + try await report.services.journal.addManualDay( + date: CalendarDay(year: 2026, month: 3, day: 1).startOfDay(in: calendar), + regions: [.california], + audit: nil, + ) + await store.enableFirstSamplesGate() + let olderRead = Task { await report.select(year: 2024) } + await store.awaitFirstSamplesCall() + let navigation = LocationsView.Navigation() + navigation.select(region: .california) + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + LocationsView(report: report, navigation: navigation) + }) + host.safeAreaRegions = [] + do { + try await show(host) { controller in + let readsBeforeFolding = await store.sampleReadCount + for pose in [ + SnapshotConfiguration.DuoPose.outerPortrait, + .innerPortrait, + .tabletop, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + #expect(await store.sampleReadCount == readsBeforeFolding) + #expect(report.loadState == .loading) + } + await report.select(year: 2026) + let readsAfterYearChange = await store.sampleReadCount + for pose in [ + SnapshotConfiguration.DuoPose.innerLandscape, + .tabletop, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + #expect(await store.sampleReadCount == readsAfterYearChange) + } + await store.releaseFirstSamplesCall() + await olderRead.value + #expect(report.selectedYear == 2026) + #expect(report.report?.year == 2026) + #expect(report.report?.totals[.california] == 1) + #expect(report.report?.totals[.newYork] == nil) + #expect(report.loadState == .loaded) + #expect(navigation.selectedRegion == .california) + } + } catch { + await store.releaseFirstSamplesCall() + await olderRead.value + throw error + } + } +} diff --git a/Where/WhereUI/Tests/MainTabsTests.swift b/Where/WhereUI/Tests/MainTabsTests.swift index eb839098e..5fd723bcc 100644 --- a/Where/WhereUI/Tests/MainTabsTests.swift +++ b/Where/WhereUI/Tests/MainTabsTests.swift @@ -1,4 +1,6 @@ +import BroadwayUI import InstallerKit +import SnapshotKit @_spi(Testing) import InstallerKitUI import SwiftUI import TestHostSupport @@ -72,3 +74,115 @@ struct MainTabsTests { } } } + +@MainActor +struct MainTabsTestsDuo { + @Test func settingsSearchStaysVisibleAcrossFoldLayouts() async throws { + let world = try await WhereFlyoverWorld.build() + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + MainTabs(world: world, tab: .settings) + .environment(world.model) + .environment(world.session) + .environment(\.isInDemoMode, true) + }) + host.safeAreaRegions = [] + try await show(host) { controller in + var enteredQuery = false + for pose in [ + SnapshotConfiguration.DuoPose.outerPortrait, + .innerPortrait, + .tabletop, + .book, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + let window = try #require(controller.view.window) + try await waitForAsync { + AdaptivePaneTestSupport.descendants( + of: window, + matching: UISearchTextField.self, + ) + .contains { field in + var ancestor: UIView? = field + while let view = ancestor { + if view.isHidden || view.alpha == 0 { return false } + ancestor = view.superview + } + let frame = field.convert(field.bounds, to: controller.view) + return frame.width > 0 && frame.height > 0 && controller.view.bounds + .contains(frame) + } + } + let field = try #require(AdaptivePaneTestSupport.descendants( + of: window, + matching: UISearchTextField.self, + ).first { !$0.isHidden && $0.window != nil }) + if !enteredQuery { + field.text = "appearance" + field.sendActions(for: .editingChanged) + enteredQuery = true + } + #expect(field.text == "appearance") + } + } + } + + @Test func foldingKeepsOnePresentedEditorAndItsUnsavedDate() async throws { + let world = try await WhereFlyoverWorld.build() + let navigation = LocationsView.Navigation() + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + LocationsView(report: world.report, navigation: navigation) + .bScrollRestorationScope() + .environment(world.model) + .environment(world.session) + .environment(\.isInDemoMode, true) + }) + host.safeAreaRegions = [] + try await show(host) { controller in + try await AdaptivePaneTestSupport.resize( + controller, + state: state, + to: .outerPortrait, + ) + navigation.select(region: .california) + navigation.planningDestination = .new(.california) + let root = try #require(controller.view.window?.rootViewController) + try await waitForAsync { + AdaptivePaneTestSupport.presentedController(from: root) != nil + } + let editor = try #require(AdaptivePaneTestSupport.presentedController(from: root)) + defer { editor.dismiss(animated: false) } + try await waitForAsync { + !AdaptivePaneTestSupport + .descendants(of: editor.view, matching: UIDatePicker.self).isEmpty + } + let picker = try #require(AdaptivePaneTestSupport.descendants( + of: editor.view, + matching: UIDatePicker.self, + ).first) + let draft = CalendarDay(year: world.report.selectedYear, month: 9, day: 8) + .startOfDay(in: world.report.calendar) + picker.date = draft + picker.sendActions(for: .valueChanged) + for pose in [ + SnapshotConfiguration.DuoPose.innerPortrait, + .tabletop, + .outerPortrait, + .innerLandscape, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + #expect(AdaptivePaneTestSupport.presentedController(from: root) === editor) + #expect(navigation.selectedRegion == .california) + #expect(AdaptivePaneTestSupport.descendants( + of: editor.view, + matching: UIDatePicker.self, + ).contains { + world.report.calendar.isDate($0.date, inSameDayAs: draft) + }) + } + } + } +} diff --git a/Where/WhereUI/Tests/PlannedStayEditorTests.swift b/Where/WhereUI/Tests/PlannedStayEditorTests.swift new file mode 100644 index 000000000..4bdb15b2b --- /dev/null +++ b/Where/WhereUI/Tests/PlannedStayEditorTests.swift @@ -0,0 +1,60 @@ +import SnapshotKit +import SwiftUI +import TestHostSupport +import Testing +import WhereCore +@_spi(Testing) @testable import WhereUI + +@MainActor +struct PlannedStayEditorTests { + @Test(arguments: [false, true]) + func resizingDuringSavePreservesOneWriteAndItsResult(fails: Bool) async throws { + let store = try TestStore() + let report = PlanningTestSupport.report(store: store) + await report.refresh() + let model = PlannedStayEditorModel(report: report, mode: .new(initialRegion: .california)) + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + PlannedStayEditor(model: model) + }) + host.safeAreaRegions = [] + let gate = TestStore.PlanningOperationGate() + await store.gateNextPlannedStayWrite(with: gate) + if fails { await store.failPlannedStays() } + try await show(host) { controller in + let saving = Task { await model.save() } + await gate.waitUntilReached() + do { + for pose in [ + SnapshotConfiguration.DuoPose.outerPortrait, + .innerPortrait, + .tabletop, + .outerPortrait, + ] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + #expect(model.isSaving) + #expect(model.region == .california) + #expect(await store.plannedStayWriteCount == 1) + } + #expect(await model.save() == false) + #expect(await store.plannedStayWriteCount == 1) + await gate.release() + #expect(await saving.value == !fails) + #expect(!model.isSaving) + if fails { + guard case let .failed(error) = model.saveState else { + Issue.record("The failed save must remain visible after resizing") + return + } + #expect(error is PlannedStaySaveFailure) + } + let snapshot = try await report.services.plannedStays.snapshot() + #expect(snapshot.stays.count == (fails ? 0 : 1)) + } catch { + await gate.release() + _ = await saving.value + throw error + } + } + } +} diff --git a/Where/WhereUI/Tests/PresenceTimelineListTests.swift b/Where/WhereUI/Tests/PresenceTimelineListTests.swift new file mode 100644 index 000000000..aeda2f638 --- /dev/null +++ b/Where/WhereUI/Tests/PresenceTimelineListTests.swift @@ -0,0 +1,110 @@ +import AccessibilitySnapshotParser +import BroadwayUI +import SwiftUI +import TestHostSupport +import Testing +import WhereCore +@testable import WhereUI + +@MainActor +struct PresenceTimelineListTests { + @Test func aRemountedTimelineRestoresItsBrowsedRowWithoutSelectionState() async throws { + let world = try await WhereFlyoverWorld.build() + let report = try #require(world.report.report) + let items = PresenceJourneyItem.items( + stints: PresenceTimeline.stints(from: report, calendar: world.report.calendar), + flights: world.report.completedFlights, + year: report.year, + calendar: world.report.calendar, + ) + let stint = try #require(items.compactMap { item -> RegionStint? in + if case let .stay(stint) = item { return stint } + return nil + }.first) + let target = PresenceTimelineList.RowID.recorded(stint.id) + let label = WhereFormat.timelineRowAccessibility( + region: stint.region.localizedName, + range: DateRangeFormatting.abbreviated( + start: stint.start, + end: stint.end, + calendar: world.report.calendar, + ), + days: stint.dayCount, + ) + let store = BScrollRestorationStore() + let capture = ScrollPositionCapture() + func timeline() -> some View { + PresenceTimelineList(report: world.report) + .overlay { + ScrollPositionProbe( + PresenceTimelineList.self, + role: PresenceTimelineList.ScrollRole.timeline, + context: report.year, + idType: PresenceTimelineList.RowID.self, + capture: capture, + ) + } + .whereBroadwayRoot() + .bScrollRestorationScope(store) + } + func waitForTimeline( + _ stage: String, + in view: UIView, + until condition: () -> Bool, + ) async throws { + do { + try await waitForAsync { condition() } + } catch { + let scrolls = AdaptivePaneTestSupport.descendants( + of: view, + matching: UIScrollView.self, + ) + let geometry = scrolls.map { + "offset=\($0.contentOffset), size=\($0.contentSize), bounds=\($0.bounds), insets=\($0.adjustedContentInset)" + } + throw TestHostError( + "Timeline \(stage): \(error); target=\(String(describing: capture.binding?.wrappedValue.viewID(type: PresenceTimelineList.RowID.self))); \(geometry)", + ) + } + } + let first = UIHostingController(rootView: timeline()) + try await show(first) { _ in + // Native scrolling can clear ScrollPosition.edge after reaching the bottom. + // Wait for the rendered offset, not for the transient command to remain stored. + try await waitForTimeline("initial bottom", in: first.view) { + guard capture.binding != nil, + let scroll = AdaptivePaneTestSupport.descendants( + of: first.view, + matching: UIScrollView.self, + ) + .first(where: { $0.contentSize.height > $0.bounds.height }) + else { return false } + let bottom = scroll.contentSize.height - scroll.bounds.height + scroll + .adjustedContentInset.bottom + return abs(scroll.contentOffset.y - bottom) < 1 + } + capture.binding?.wrappedValue.scrollTo(id: target, anchor: .top) + try await waitForTimeline("browsed row", in: first.view) { + HostedAccessibility.isVisible( + label, + in: first.view, + ) + } + #expect(capture.binding?.wrappedValue + .viewID(type: PresenceTimelineList.RowID.self) == target) + let row = try #require(HostedAccessibility.objects(in: first.view).first { + $0.accessibilityLabel == label + }) + #expect(row.accessibilityTraits.contains(.button) == false) + #expect(row.accessibilityTraits.contains(.selected) == false) + } + let second = UIHostingController(rootView: timeline()) + try await show(second) { _ in + try await waitForTimeline("restored row", in: second.view) { + capture.binding?.wrappedValue + .viewID(type: PresenceTimelineList.RowID.self) == target + && HostedAccessibility.isVisible(label, in: second.view) + } + } + } +} diff --git a/Where/WhereUI/Tests/ScrollPositionTestSupport.swift b/Where/WhereUI/Tests/ScrollPositionTestSupport.swift new file mode 100644 index 000000000..f8d9eec8e --- /dev/null +++ b/Where/WhereUI/Tests/ScrollPositionTestSupport.swift @@ -0,0 +1,29 @@ +import BroadwayUI +import SwiftUI + +/// Reads the production restoration entry without creating a second scroll view. +@MainActor +final class ScrollPositionCapture { + var binding: Binding? +} + +struct ScrollPositionProbe: View { + let capture: ScrollPositionCapture + @BScrollPosition private var position: ScrollPosition + + init( + _ view: (some View).Type, + role: some Hashable, + context: Context, + idType: ID.Type, + capture: ScrollPositionCapture, + ) { + self.capture = capture + _position = BScrollPosition(view, role: role, context: context, idType: idType) + } + + var body: some View { + Color.clear.frame(width: 0, height: 0) + .onAppear { capture.binding = $position } + } +} diff --git a/Where/WhereUI/Tests/Support/AdaptivePaneLoadFixture.swift b/Where/WhereUI/Tests/Support/AdaptivePaneLoadFixture.swift new file mode 100644 index 000000000..458c06570 --- /dev/null +++ b/Where/WhereUI/Tests/Support/AdaptivePaneLoadFixture.swift @@ -0,0 +1,50 @@ +import Foundation +import SnapshotKit +@_spi(Testing) import WhereCore +@testable import WhereUI + +/// Drives the real report through controlled store reads. No presentation state is injected. +@MainActor +final class AdaptivePaneLoadFixture { + enum State: String, CaseIterable { + case loading + case empty + case failed + } + + let report: YearReportModel + private let store: TestStore + private let refresh: Task + + init(state: State) async throws { + let store = try TestStore() + self.store = store + switch state { + case .loading: await store.enableFirstSamplesGate() + case .empty: break + case .failed: await store.failSamples() + } + let now = PreviewSupport.referenceNow + let report = YearReportModel( + services: WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + now: { now }, + ), + selectedYear: PreviewSupport.year, + preferences: PreviewSupport.previewPreferences(), + now: { now }, + ) + self.report = report + refresh = Task { await report.refresh() } + switch state { + case .loading: await store.awaitFirstSamplesCall() + case .empty, .failed: await refresh.value + } + } + + func finish() async { + await store.releaseFirstSamplesCall() + await refresh.value + } +} diff --git a/Where/WhereUI/Tests/Support/CalendarPresentationFixture.swift b/Where/WhereUI/Tests/Support/CalendarPresentationFixture.swift new file mode 100644 index 000000000..71dac4162 --- /dev/null +++ b/Where/WhereUI/Tests/Support/CalendarPresentationFixture.swift @@ -0,0 +1,45 @@ +import BroadwayUI +import Observation +import RegionKit +import SwiftUI +import WhereCore +@testable import WhereUI + +@MainActor @Observable +final class CalendarPresentationFixture { + var showsTabletop = false + var region: Region? +} + +/// Separate layout branches recreate the production calendar under one retained scope. +struct CalendarPresentationHarness: View { + let fixture: CalendarPresentationFixture + let report: YearReportModel + let reveals: CalendarRevealTestSupport + + var body: some View { + NavigationStack { + Group { + if fixture.showsTabletop { + CalendarContentView( + focusedRegion: fixture.region, + report: report, + monthReveal: reveals.binding, + presentation: .month, + persistenceKey: fixture.region.map(CalendarZoomKey.region), + ) + } else { + CalendarContentView( + focusedRegion: fixture.region, + report: report, + monthReveal: reveals.binding, + persistenceKey: fixture.region.map(CalendarZoomKey.region), + ) + } + } + .id(fixture.region) + } + .whereBroadwayRoot() + .bScrollRestorationScope() + } +} diff --git a/Where/WhereUI/Tests/Support/HostedAccessibility.swift b/Where/WhereUI/Tests/Support/HostedAccessibility.swift new file mode 100644 index 000000000..0cc3acab4 --- /dev/null +++ b/Where/WhereUI/Tests/Support/HostedAccessibility.swift @@ -0,0 +1,31 @@ +import AccessibilitySnapshotParser +import UIKit + +@MainActor +enum HostedAccessibility { + static func objects(in root: NSObject) -> [NSObject] { + var visited: Set = [] + @MainActor func visit(_ object: NSObject) -> [NSObject] { + guard visited.insert(ObjectIdentifier(object)).inserted else { return [] } + var result = [object] + if let view = object as? UIView { result += view.subviews.flatMap(visit) } + let count = object.accessibilityElementCount() + if count > 0, count < 10000 { + for index in 0 ..< count { + if let child = object + .accessibilityElement(at: index) as? NSObject { result += visit(child) } + } + } + return result + } + return visit(root) + } + + static func isVisible(_ label: String, in view: UIView) -> Bool { + let viewport = view.convert(view.bounds, to: nil) + return objects(in: view) + .contains { + $0.accessibilityLabel == label && $0.accessibilityFrame.intersects(viewport) + } + } +} diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index 9bd499c05..b8b2a4f0e 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -9,7 +9,11 @@ struct PlannedStaySaveFailure: Error, Equatable {} /// Thrown by `TestStore.samples(in:)` when failure injection is enabled, so a /// year-report load can be forced to fail. -struct SampleReadFailure: Error, Equatable {} +struct SampleReadFailure: LocalizedError, Equatable { + var errorDescription: String? { + "The history store is unavailable." + } +} /// Thrown by the Devices settings save-failure hooks below. struct RecordingDeviceSaveFailure: Error, Equatable {} @@ -29,8 +33,8 @@ struct RecordingDeviceSaveFailure: Error, Equatable {} /// /// Everything else forwards to the backing store so reads stay deterministic. actor TestStore: WhereStore { - /// Holds one captured planning result until the test chooses its completion order. - actor PlanningReadGate { + /// Holds one planning read or write at its suspension boundary until the test releases it. + actor PlanningOperationGate { private enum State { case waiting case suspended(CheckedContinuation) @@ -47,7 +51,7 @@ actor TestStore: WhereStore { } func suspend() async { - precondition(!hasArrived, "A planning read gate must only suspend one read") + precondition(!hasArrived, "A planning operation gate must only suspend one operation") hasArrived = true arrival?.resume() arrival = nil @@ -65,6 +69,7 @@ actor TestStore: WhereStore { private var gateFirstSamplesCall = false private var firstSamplesSeen = false + private(set) var sampleReadCount = 0 private var gate: CheckedContinuation? private var arrival: CheckedContinuation? @@ -73,7 +78,9 @@ actor TestStore: WhereStore { private var recordingDevicesGate: CheckedContinuation? private var recordingDevicesArrival: CheckedContinuation? - private var nextPlanningReadGate: PlanningReadGate? + private var nextPlanningOperationGate: PlanningOperationGate? + private var nextPlannedStayWriteGate: PlanningOperationGate? + private(set) var plannedStayWriteCount = 0 private var shouldFailManualDay = false private var shouldFailPlannedStay = false @@ -135,15 +142,20 @@ actor TestStore: WhereStore { shouldFailPlanningRead = false } - func gateNextPlanningRead(with gate: PlanningReadGate) { - precondition(nextPlanningReadGate == nil, "The next planning read already has a gate") - nextPlanningReadGate = gate + func gateNextPlanningRead(with gate: PlanningOperationGate) { + precondition(nextPlanningOperationGate == nil, "The next planning read already has a gate") + nextPlanningOperationGate = gate } func failPlannedStays() { shouldFailPlannedStay = true } + func gateNextPlannedStayWrite(with gate: PlanningOperationGate) { + precondition(nextPlannedStayWriteGate == nil, "The next planning write already has a gate") + nextPlannedStayWriteGate = gate + } + /// Makes `samples(in:)` throw, so a `refresh()`'s year-report load fails and /// the model's `.failed` load state is exercisable. func failSamples() { @@ -204,6 +216,7 @@ actor TestStore: WhereStore { } func samples(in interval: DateInterval) async throws -> [LocationSample] { + sampleReadCount += 1 if shouldFailSamples { throw SampleReadFailure() } if gateFirstSamplesCall, !firstSamplesSeen { firstSamplesSeen = true @@ -345,8 +358,8 @@ actor TestStore: WhereStore { } func plannedStayRecords() async throws -> [PlannedStayRecord] { - let gate = nextPlanningReadGate - nextPlanningReadGate = nil + let gate = nextPlanningOperationGate + nextPlanningOperationGate = nil let result: Result<[PlannedStayRecord], Error> do { if shouldFailPlanningRead { throw PlannedStaySaveFailure() } @@ -372,6 +385,10 @@ actor TestStore: WhereStore { } func replacePlannedStayRecord(with record: PlannedStayRecord) async throws { + plannedStayWriteCount += 1 + let gate = nextPlannedStayWriteGate + nextPlannedStayWriteGate = nil + if let gate { await gate.suspend() } if shouldFailPlannedStay { throw PlannedStaySaveFailure() } try await backing.replacePlannedStayRecord(with: record) } diff --git a/Where/WhereUI/Tests/YearViewTests.swift b/Where/WhereUI/Tests/YearViewTests.swift new file mode 100644 index 000000000..b6f0130b6 --- /dev/null +++ b/Where/WhereUI/Tests/YearViewTests.swift @@ -0,0 +1,64 @@ +import AccessibilitySnapshotParser +import BroadwayUI +import SnapshotKit +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +@MainActor +struct YearViewTests { + @Test func bottomSelectorAndSelectedModeSurviveEveryFoldLayout() async throws { + let world = try await WhereFlyoverWorld.build() + let state = AdaptivePaneHostState() + let host = UIHostingController(rootView: AdaptivePaneHostedSurface(state: state) { + YearView(report: world.report) + }.bScrollRestorationScope()) + host.safeAreaRegions = [] + try await show(host) { controller in + controller.view.window?.layer.speed = 1 + var retainedMode = YearMode.calendar + for pose in SnapshotConfiguration.DuoPose.allCases + [.outerPortrait] { + try await AdaptivePaneTestSupport.resize(controller, state: state, to: pose) + try await waitForAsync { + button(retainedMode, in: controller.view)?.accessibilityTraits + .contains(.selected) == true + } + for mode in [YearMode.calendar, .timeline] { + try await waitForAsync { self.button(mode, in: controller.view) != nil } + let button = try #require(self.button(mode, in: controller.view)) + if let control = button as? UIControl { + control.sendActions(for: .touchUpInside) + } else { + try #require(button.accessibilityActivate()) + } + try await waitForAsync { + self.button(mode, in: controller.view)?.accessibilityTraits + .contains(.selected) == true + && navigationControllers(in: controller) + .contains { $0.navigationBar.topItem?.title == mode.title } + } + #expect(navigationControllers(in: controller).count == 1) + } + retainedMode = .timeline + // Timeline is selected before the next resize; the same view owns it. + #expect(button(.timeline, in: controller.view)?.accessibilityTraits + .contains(.selected) == true) + } + } + } + + private func button(_ mode: YearMode, in view: UIView) -> NSObject? { + HostedAccessibility.objects(in: view).first { + $0.accessibilityLabel == mode.title && $0.accessibilityTraits.contains(.button) + && $0.accessibilityFrame.intersects(view.convert(view.bounds, to: nil)) + } + } + + private func navigationControllers(in controller: UIViewController) + -> [UINavigationController] + { + (controller as? UINavigationController).map { [$0] } ?? controller.children + .flatMap { navigationControllers(in: $0) } + } +}