diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9071cf..93e3b80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - "release/**" tags: - "v*" + - "[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: permissions: @@ -22,6 +23,7 @@ env: DEMO_SCHEME: AdaptiveShellDemo DEMO_DESTINATION: "generic/platform=iOS Simulator" XCODEGEN_VERSION: "2.45.4" + SWIFTLINT_VERSION: "0.65.0" jobs: fast: @@ -33,8 +35,18 @@ jobs: DEVELOPER_DIR: /Applications/Xcode_26.5.app/Contents/Developer steps: - uses: actions/checkout@v4 - - name: Ensure SwiftLint - run: command -v swiftlint >/dev/null || brew install swiftlint + - name: Install pinned tools + run: | + curl --fail --location --silent --show-error \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" \ + --output "$RUNNER_TEMP/swiftlint.zip" + unzip -q "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/swiftlint" + echo "$RUNNER_TEMP/swiftlint" >> "$GITHUB_PATH" + - name: Tool versions + run: | + swift --version + xcodebuild -version + test "$(swiftlint version)" = "$SWIFTLINT_VERSION" - name: Test package run: swift test - name: Lint @@ -47,6 +59,12 @@ jobs: -destination "$DEMO_DESTINATION" \ CODE_SIGNING_ALLOWED=NO \ build + - name: Build separate public consumer + run: | + consumer="$RUNNER_TEMP/public-consumer" + python3 Scripts/prepare_consumer.py "$consumer" + swift package --package-path "$consumer" resolve + swift build --package-path "$consumer" full: name: Full · ${{ matrix.name }} @@ -62,13 +80,29 @@ jobs: runner: macos-26 developer_dir: /Applications/Xcode_26.5.app/Contents/Developer runs-on: ${{ matrix.runner }} - timeout-minutes: 30 + timeout-minutes: 45 env: DEVELOPER_DIR: ${{ matrix.developer_dir }} steps: - uses: actions/checkout@v4 - - name: Ensure SwiftLint - run: command -v swiftlint >/dev/null || brew install swiftlint + - name: Install pinned tools + run: | + curl --fail --location --silent --show-error \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" \ + --output "$RUNNER_TEMP/swiftlint.zip" + unzip -q "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/swiftlint" + echo "$RUNNER_TEMP/swiftlint" >> "$GITHUB_PATH" + curl --fail --location --silent --show-error \ + "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" \ + --output "$RUNNER_TEMP/xcodegen.zip" + unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP/xcodegen" + echo "$RUNNER_TEMP/xcodegen/xcodegen/bin" >> "$GITHUB_PATH" + - name: Tool versions + run: | + swift --version + xcodebuild -version + test "$(swiftlint version)" = "$SWIFTLINT_VERSION" + test "$(xcodegen --version | awk '{print $2}')" = "$XCODEGEN_VERSION" - name: Test package run: swift test - name: Lint @@ -81,14 +115,23 @@ jobs: -destination "$DEMO_DESTINATION" \ CODE_SIGNING_ALLOWED=NO \ build - - name: Install pinned XcodeGen - if: matrix.name == 'current' + - name: Hosted demo and public consumer · iPhone + run: Scripts/run_ui_tests.sh iphone + - name: Hosted demo and public consumer · iPad + run: Scripts/run_ui_tests.sh ipad + - name: Upload native test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-results-${{ matrix.name }} + path: DerivedData-qa-*/**/results/*.xcresult + if-no-files-found: warn + - name: Build released public consumer run: | - curl --fail --location --silent --show-error \ - "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" \ - --output "$RUNNER_TEMP/xcodegen.zip" - unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP/xcodegen" - echo "$RUNNER_TEMP/xcodegen/xcodegen/bin" >> "$GITHUB_PATH" + consumer="$RUNNER_TEMP/released-consumer" + python3 Scripts/prepare_consumer.py "$consumer" --version 1.0.3 + swift package --package-path "$consumer" resolve + swift build --package-path "$consumer" - name: Reproduce checked-in project if: matrix.name == 'current' run: | diff --git a/.gitignore b/.gitignore index fe32716..39b7c41 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ xcuserdata/ Assets/GIFs/*.mov Assets/GIFs/*.mp4 Assets/Screenshots/qa-*.png +__pycache__/ diff --git a/.swiftlint.yml b/.swiftlint.yml index 14e65ee..04cd302 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,9 @@ excluded: - .build - DerivedData + - DerivedData-showcase + - DerivedData-qa-iphone + - DerivedData-qa-ipad line_length: warning: 120 diff --git a/Assets/GIFs/README.md b/Assets/GIFs/README.md index 24b75ac..1ec12b5 100644 --- a/Assets/GIFs/README.md +++ b/Assets/GIFs/README.md @@ -1,5 +1,7 @@ # GIF Assets -Run `Scripts/record_showcase.sh` to rebuild `adaptive-app-shell.gif` from the iPhone simulator. +Run `Scripts/record_showcase.sh` to rebuild `adaptive-app-shell.gif` from an installed iPhone simulator runtime. It checks Xcode/simctl, Python 3 and ffmpeg, creates its own device and removes that device and temporary recording directory on exit. Its trap stops only its own recorder PID. Set `ADAPTIVE_SHELL_RUNTIME=26.5` to choose that installed runtime explicitly. The source MOV is ignored by Git. + +The current GIF was captured on 14 October 2026: iPhone 17 Pro, iOS 26.5, built with Xcode 27.0; 400×870, 20 fps, 243 frames, 12.15 seconds. It records the real demo's `--showcase` sequence across its preset themes and tabs. Temporary recording avoids requiring Simulator to write directly into a protected Documents directory. diff --git a/Assets/GIFs/adaptive-app-shell.gif b/Assets/GIFs/adaptive-app-shell.gif index 27a2ff8..fa26800 100644 Binary files a/Assets/GIFs/adaptive-app-shell.gif and b/Assets/GIFs/adaptive-app-shell.gif differ diff --git a/Assets/Screenshots/README.md b/Assets/Screenshots/README.md index 31c0d03..a5fa165 100644 --- a/Assets/Screenshots/README.md +++ b/Assets/Screenshots/README.md @@ -1,9 +1,11 @@ # Screenshot Assets -These images are direct iOS 26.5 Simulator captures. +These are actual iOS 26.5 Simulator captures of the 1.0.4 candidate, refreshed on 14 October 2026 using Xcode 27.0. The devices were owned, temporary iPhone 17 Pro and iPad Pro 13-inch (M5) simulators. Status-bar time/battery were set with Simulator's own override. App content and pixels were captured directly; no design mockups or generated imagery are included here. - `iphone-classic.png` - `iphone-graphite-search.png` +- `ipad-classic.png` - `ipad-stone.png` - `ipad-inspector.png` -- `ipad-landscape-inspector.png` + +Landscape interaction is covered by hosted tests. The local XCTest landscape attachment has a rotated/cropped framebuffer and is excluded from the current gallery. A new landscape image should be added only after its actual capture has been visually verified. diff --git a/Assets/Screenshots/ipad-classic.png b/Assets/Screenshots/ipad-classic.png index d2792d3..81da592 100644 Binary files a/Assets/Screenshots/ipad-classic.png and b/Assets/Screenshots/ipad-classic.png differ diff --git a/Assets/Screenshots/ipad-inspector.png b/Assets/Screenshots/ipad-inspector.png index 1d15fba..f17c81b 100644 Binary files a/Assets/Screenshots/ipad-inspector.png and b/Assets/Screenshots/ipad-inspector.png differ diff --git a/Assets/Screenshots/ipad-landscape-inspector.png b/Assets/Screenshots/ipad-landscape-inspector.png deleted file mode 100644 index a3f607c..0000000 Binary files a/Assets/Screenshots/ipad-landscape-inspector.png and /dev/null differ diff --git a/Assets/Screenshots/ipad-stone.png b/Assets/Screenshots/ipad-stone.png index 8f01060..973e9cc 100644 Binary files a/Assets/Screenshots/ipad-stone.png and b/Assets/Screenshots/ipad-stone.png differ diff --git a/Assets/Screenshots/iphone-classic.png b/Assets/Screenshots/iphone-classic.png index 3a9422f..a6db5d0 100644 Binary files a/Assets/Screenshots/iphone-classic.png and b/Assets/Screenshots/iphone-classic.png differ diff --git a/Assets/Screenshots/iphone-graphite-search.png b/Assets/Screenshots/iphone-graphite-search.png index fcb4460..a625ace 100644 Binary files a/Assets/Screenshots/iphone-graphite-search.png and b/Assets/Screenshots/iphone-graphite-search.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index bde469b..1b15429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased · 1.0.4 candidate + +- Reconcile dynamic sections and compact placement, with deterministic first-occurrence duplicate handling and retained app-owned histories. +- Give each regular detail stack its tab identity to preserve the correct visible destination when switching tabs. +- Open Studio projects consistently from Overview, Projects, Search, creation and validated links, regardless of inspector visibility. +- Add session-only creation, shared task/progress data, effective settings and scoped search with retained query/scope. +- Provide an accessible close action inside Studio's inspector for narrow native modal presentations. +- Reject unknown and malformed links explicitly, without fixture fallback or navigation mutations. +- Capture theme transitions from their hosting window and cancel pending transition work on disappearance. +- Add hosted demo tests and a separate public consumer; align version-tag triggers, pinned tools and owned-simulator capture. + +No public initializer or typed navigation model changes. See [candidate notes](Docs/ReleaseNotes-1.0.4.md) and [migration rules](Docs/Migration-1.0.4.md). + ## 1.0.3 - Added coverage for invalid selection, compact/regular transitions, deep-link path replacement, inspector state, and per-tab navigation. diff --git a/Docs/Accessibility.md b/Docs/Accessibility.md index 9f32388..f9b6ef5 100644 --- a/Docs/Accessibility.md +++ b/Docs/Accessibility.md @@ -13,14 +13,17 @@ The package and demo are designed around system behavior instead of fixed visual - Reduce Transparency replaces custom glass actions with opaque controls - Native navigation keeps standard VoiceOver and keyboard behavior -## Verified states +## Simulator and automated checks - Standard Dynamic Type on iPhone and iPad - Accessibility Extra Large on iPhone -- Increase Contrast on iPhone - Classic, Graphite, and Stone contrast - Compact tab presentation - Sidebar with and without inspector +- Accessibility XXXL project/task/create control reachability +- Project labels, task values and empty/recovery messages in the accessibility hierarchy + +These are simulator/UI assertions and visual checks. Physical VoiceOver activation, hardware keyboard focus, Increase Contrast, Reduce Transparency/Motion and Stage Manager are recorded separately in the [validation matrix](Validation.md); system controls do not by themselves prove those device gates. ## Consumer checklist diff --git a/Docs/Architecture.md b/Docs/Architecture.md index 1be2180..36b4a12 100644 --- a/Docs/Architecture.md +++ b/Docs/Architecture.md @@ -19,10 +19,24 @@ Compact width renders a system `TabView`. Every compact item wraps its feature r Regular width renders a `NavigationSplitView`. Sidebar buttons use the same destination IDs as compact tabs. The currently selected path is attached to the detail column, and an optional inspector reads contextual feature selection. +The inspector belongs to the split-view container and its toggle belongs to the detail stack, so pushing a route preserves both panel and control. Inspector eligibility still comes from the consumer's predicate. + ## Adaptation Compact and regular presentations never own separate selection or route values. Changing size class swaps presentation while keeping the same state object. If a regular-only collection becomes unavailable in compact width, the shell selects the first valid compact tab and preserves the hidden collection path for later use. +Configuration changes are observed independently of selection and size class. The first occurrence of each section ID and item ID wins. A valid selection survives reordering; an unavailable selection resolves to the first eligible item. With no eligible items, the shell shows an empty state and retains the current selection and all app-owned paths. The regular detail stack has the selected tab's identity, preventing native stack presentation from leaking between tabs. + +Typed paths preserve navigation context. They do not preserve arbitrary child `@State`. The demo therefore owns its `StudioStore` and search query/scope above the adaptive presentation. This follows Apple's [navigation state](https://developer.apple.com/documentation/swiftui/understanding-the-navigation-stack) and [Observation ownership](https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro) guidance. + +## Demo boundary + +`StudioCoordinator` validates project identity and owns one opening rule for Overview, Projects, Search, creation and URLs. Both presentations push the source tab's typed path. Known project links replace only the Projects path. Parsing and identity validation precede mutations; failures show a message and preserve history. + +`StudioStore` owns session-only projects, task completion, progress settings and search context. Progress derives from completed tasks. The list, details, task screen and inspector read the same project. Search builds immutable scoped results once per render, and the empty condition uses those visible results. + +Theme transitions capture the view's hosting window through a weak reference. The transition task is cancelled and its snapshot cleared when the root disappears; no connected-scene search is used. + ## Why the inspector starts closed An open inspector can force a portrait iPad to hide its sidebar. Defaulting to closed prioritizes primary navigation. Wide layouts can show it with the toolbar action, and consumers can opt into another initial value. diff --git a/Docs/Migration-1.0.4.md b/Docs/Migration-1.0.4.md new file mode 100644 index 0000000..dee7236 --- /dev/null +++ b/Docs/Migration-1.0.4.md @@ -0,0 +1,27 @@ +# Configuration and migration + +The 1.0.4 candidate is a patch to the reusable shell. Public generic types, initializer signatures, deployment floors and navigation methods remain source compatible. There are no runtime dependencies. + +## Selection and identity + +Supply stable, globally unique item IDs and unique section IDs. If IDs repeat, the first section with that ID and the first item occurrence in accepted sections win. Later duplicates do not create duplicate native tabs or rows; compact placement also comes from the first item. + +Every section change is reconciled, including additions, removals, reordering and `.tab`/`.hidden` changes. A still eligible selection remains selected. Otherwise the first eligible item in configuration order becomes selected. Compact candidates use `.tab`; regular candidates include all accepted items. + +An empty configuration displays **No destinations** and retains the previous selection. When items return, a valid retained selection is reused; an invalid one falls back to the first eligible item. + +## History ownership + +Removing or temporarily hiding an item does not delete its path. Reinserting the same ID restores that history. Permanently retired IDs can be cleaned by the application with `state.paths.removeValue(forKey:)`; do this only when its own domain policy permits it. + +The regular stack is keyed by tab ID, so a tab switch presents that tab's actual route array. Compact/regular replacement preserves typed routes, not arbitrary child `@State`. Hoist feature state such as queries, drafts or filters above the shell when it must survive replacement. + +## Domain validation + +The package cannot decide whether an app-owned route ID still exists. Validate restored or URL-derived routes before applying them, and provide an honest unavailable destination when a record disappears. The Studio example demonstrates this without adding storage or URL semantics to the package. + +Theme 1.x IDs continue to identify the documented preset family. Application persistence remains optional and app-owned. + +## Inspector presentation + +The native inspector can adapt to a modal presentation in a narrow window. Provide a close action inside custom inspector content so it remains reachable when the main toolbar is covered; setting `state.isInspectorPresented = false` dismisses it. The Studio example keeps this action separate from its workspace preference to reopen the inspector for later project navigation. diff --git a/Docs/QualityChecklist.md b/Docs/QualityChecklist.md index f3a2dac..664088e 100644 --- a/Docs/QualityChecklist.md +++ b/Docs/QualityChecklist.md @@ -5,6 +5,7 @@ - Stable generic destination IDs - Independent per-destination route paths - Empty configuration handled without a crash +- Dynamic section reconciliation and deterministic duplicate handling - Sidebar-only items normalize safely in compact width - Inspector is opt-in per destination - No feature or service dependencies in the package @@ -35,4 +36,11 @@ - Classic, Graphite, and Stone screenshots - Accessibility Extra Large screenshot - SwiftLint +- Hosted demo model, window lifecycle and UI tests +- Separate public-API consumer, released baseline and local candidate +- Plain SemVer and v-prefixed CI triggers +- Two-SDK CI and second-generation XcodeGen equality +- Owned simulator / recorder cleanup in capture and test scripts - No unintended code comments or debug output + +See [Validation](Validation.md) for concrete contracts and remaining physical-device/adoption gates. diff --git a/Docs/ReleaseNotes-1.0.4.md b/Docs/ReleaseNotes-1.0.4.md new file mode 100644 index 0000000..bf957b5 --- /dev/null +++ b/Docs/ReleaseNotes-1.0.4.md @@ -0,0 +1,15 @@ +# 1.0.4 candidate release notes + +Status: review candidate; no 1.0.4 tag or release is implied by this document. + +When a selected section disappears, the shell now presents the remaining valid destination without requiring a size-class change. Reordering preserves a valid selection, hidden or removed histories remain app-owned, and regular tab switches present the correct independent detail stack. Duplicate IDs use the first occurrence. + +The Studio demo now opens the selected project from Overview, Projects and Search in both presentations. Creating a named project inserts a real session-only record into list/search and opens it. Task changes update shared progress and inspector data. Both workspace settings have visible effects. Scoped search trims the query, ignores case, respects empty-query scopes and displays **No results** when its visible result set is empty. + +The inspector includes an accessible close control inside the panel, so it can be dismissed when a narrow native modal presentation covers the main toolbar. Closing does not disable the workspace preference to reopen it for a later project. + +Known project links replace one Projects path. Unknown IDs, extra path segments, credentials, queries and fragments produce explicit errors before navigation changes. A missing project is never replaced by the first fixture. + +Hosted demo and external-consumer checks supplement package tests. CI retains compatibility/current SDK gates and accepts both plain SemVer and `v`-prefixed tags. Capture creates and cleans its own simulator and recorder. Theme snapshots use the hosting window and cancelled transitions release their image. + +The public initializer and typed model remain source compatible: expected package release category **patch**. See [migration rules](Migration-1.0.4.md), [validation evidence](Validation.md) and the [demo walkthrough](../Examples/AdaptiveShellDemo/README.md). Physical-device checks and an independent adoption pilot remain separate release evidence. diff --git a/Docs/Validation.md b/Docs/Validation.md new file mode 100644 index 0000000..418ed57 --- /dev/null +++ b/Docs/Validation.md @@ -0,0 +1,93 @@ +# Validation matrix + +This document describes the 1.0.4 candidate gates. Simulator execution, compile support and physical-device validation are separate evidence. Package tests alone do not establish complete native adaptation or a 10/10 score. + +## Reproduction + +The source deployment floors remain Swift tools 6.0, iOS 17 and macOS 14. CI pins XcodeGen 2.45.4 and SwiftLint 0.65.0, and keeps Xcode 16.4/macOS 15 and Xcode 26.5/macOS 26 jobs. Local candidate verification uses Xcode 27.0 (27A266a), Swift 6.4 and installed iOS 26.5 simulators: iPhone 17 Pro and iPad Pro 13-inch (M5). + +```bash +swift test +swiftlint lint --strict +xcodebuild -project Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj \ + -scheme AdaptiveShellDemo -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO build +xcodebuild -project Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj \ + -scheme AdaptiveShellDemo -showdestinations + +Scripts/run_ui_tests.sh iphone +Scripts/run_ui_tests.sh ipad +``` + +Each UI script creates an owned simulator from an installed runtime, builds for testing, then executes the actual test products without rebuilding. It retains result bundles under the ignored `DerivedData-qa-*` directory and deletes only its own simulator. These are hosted UI runs, not generic builds. + +To reproduce a separate baseline or candidate package consumer: + +```bash +python3 Scripts/prepare_consumer.py /tmp/adaptive-baseline --version 1.0.3 +swift package --package-path /tmp/adaptive-baseline resolve +swift build --package-path /tmp/adaptive-baseline + +python3 Scripts/prepare_consumer.py /tmp/adaptive-candidate +swift package --package-path /tmp/adaptive-candidate resolve +swift build --package-path /tmp/adaptive-candidate +``` + +Use fresh output directories. Both packages declare tools 6.0, iOS 17/macOS 14 and the public product. The baseline uses the remote URL with exact 1.0.3; the candidate uses an absolute local package path. The consumer defines its own String IDs/routes and never uses `@testable` or imports demo models. Its hosted app is generated from its own `project.yml` and shared scheme. + +## Contracts exercised + +| Contract | Evidence | +| --- | --- | +| Dynamic configuration | Public consumer controls remove selected/nonselected items, clear/load, reorder, hide and reinsert. Tests assert visible roots/details and preserve independent histories. Duplicate section/item tests verify first-occurrence placement. | +| Adaptation | Hosted public consumer changes compact→regular→compact with both typed paths populated. A separate test-only Studio host forces both subtree replacements in a wide iPad window, asserts actual TabView/split presentation, and checks visible query/scope/results and an existing project destination. Demo rotation/tab checks supplement this; physical resizing remains pending. | +| Project routing | Demo UI opens Overview, Projects and Search projects with inspector preference off/on. Each action reaches the chosen detail; tab return uses that tab's path. | +| Links | Hosted UI checks cold known/unknown/malformed URLs. Model tests check warm/repeated links, one-level replacement and unchanged unrelated paths. Manual warm `simctl openurl` also verifies these outcomes in one running process. | +| Search | Scoped model tests cover empty/whitespace, trimmed case-insensitive project/task/person queries and no match. UI checks `spring`+People, editable task results, named people and retained query/scope. | +| Creation | Model/UI checks reject blank names, cancel without creation, create a stable local ID, and open/find the resulting project. | +| Shared model | Task completion updates progress (40%→60%), inspector tasks, project-specific owners and next unfinished deadline. Both settings have visible UI effects. | +| Snapshot lifecycle | Hosted two-window regression verifies a 160×120 source is captured instead of another key window. Nil window returns nil; cancellation clears the snapshot and stops delayed work. No performance or leak measurement is claimed. | +| Accessibility | UI tests inspect project labels and reach required root/create/task controls at accessibility XXXL. Native VoiceOver and keyboard flows still need physical testing. | +| Reproducibility | Project generation uses XcodeGen 2.45.4; second generation must produce no project diff. Capture owns simulator/record PID and keeps actual screenshots separate from `Design/Mockups`. | + +### Local candidate results · 14 October 2026 + +The baseline is `ed5cc9573a7258cc2549125d538102c9029e753a` (released 1.0.3). A separate exact-version consumer resolved and built successfully; its hosted selected-section removal reproduced the missing remaining destination. The candidate passes that same public boundary scenario. + +| Gate | Actual result | +| --- | --- | +| Package tests | 10 passed; exit 0 | +| SwiftLint 0.65.0 | Strict lint: 0 violations; exit 0 | +| Demo, iPhone 17 Pro / iOS 26.5 | 24 passed, 2 iPad-only skips, 0 failed; exit 0 | +| Demo, iPad Pro 13-inch (M5) / iOS 26.5 | 26 passed, 0 skipped, 0 failed; exit 0 | +| Inspector assertions, focused rechecks | Visible/hittable landscape inspector and project routing with explicitly closed inspector both passed after strengthening those tests; exit 0 for each run. | +| Separate public consumer, both devices | 3 passed per device, 0 failed; exit 0 | +| Native warm links | Same running process: known/repeated link reaches Website QA; one Back reaches Projects root. Unknown/malformed links preserve Website QA, and Overview retains its independent Spring catalogue detail. | +| Search regression sensitivity | Reintroducing child-local query/scope reproduces a visible reset from `spring` to `website`; restored app-owned state passes both presentation replacements. | +| Window regression sensitivity | Original capture selects another 1032×1376 key window; the fix captures the requested 160×120 window and passes cancellation checks. | + +These native runs used Xcode 27.0. Compatibility/current CI results are recorded with the exact candidate commit in the pull request; local execution does not substitute for those toolchains. Physical iOS 17/macOS 14 checks remain pending. + +Inspector column interactions explicitly use a wide landscape iPad window and require its content to be hittable. On a narrower window the native inspector may adapt to a modal presentation, which must be dismissed before interacting with the background. Closing assertions check that inspector content is no longer hittable: iOS 18 may retain hidden inspector elements in its accessibility tree after dismissal. + +The inspector-enabled navigation test also exercises portrait presentation: use the panel's Close inspector button, require the project row to be hittable, open Website QA, verify that the preference reopens the correct inspector with Sam Campbell, then close it and require the underlying Website QA detail. The close action changes presentation only and preserves the preference to reopen the inspector for another project. + +Five portrait screenshots and the actual showcase GIF were refreshed and visually inspected. The local XCTest landscape attachment has a rotated/cropped framebuffer despite the passing wide-window interaction assertions; it is excluded from current screenshot assets. Its image-capture verification remains pending, separately from the hosted interaction results. + +## Physical-device result form + +These checks remain pending until actually run. Record device/model, OS, app revision, input/accessibility settings, observed result, pass/fail and a screenshot or recording. + +| Check | Steps / expected result | Result | +| --- | --- | --- | +| iPhone / iPad, including minimum OS | Open a different detail in every tab, rotate/resize and return. Route and displayed identity stay aligned. | Pending | +| Stage Manager / multiple windows | Give two windows different projects/search/theme. Resize independently; switching theme captures only its own window. | Pending | +| VoiceOver | Read project name/status/action, activate it, toggle a task, reach sidebar/detail/inspector and recovery alert. | Pending | +| Hardware keyboard | Tab/Shift-Tab and navigation shortcuts reach sidebar, detail, task controls and inspector without losing focus. | Pending | +| Reduce Motion / Transparency / Increase Contrast | Switch themes and inspector; essential controls remain readable and reachable. | Pending device validation | +| Safe areas and accessibility sizes | Check the largest text size in portrait/landscape, split-window widths and inspector on/off. | Pending device validation | +| Independent adoption pilot | Another app team integrates its own model/routes and records API/usability feedback. Prepared consumers do not count as a pilot. | Pending | + +## Catalog proposal + +After a released revision is available, the external catalog can describe reactive configuration, visible project routing, session-only creation/shared tasks, scoped search and hosted public-consumer validation. It should keep physical gates and adoption pilot pending until supported by evidence. No catalog change is included with this package candidate. diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/project.pbxproj b/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/project.pbxproj index bc7a2f1..dfe606b 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/project.pbxproj +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/project.pbxproj @@ -7,36 +7,115 @@ objects = { /* Begin PBXBuildFile section */ + 0008A330E77684B3FADA0FAA /* StudioRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 685D6329C337DD87CED7E6FD /* StudioRegressionTests.swift */; }; + 018FD70DC530D140E053DAFA /* DemoRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8F652D30895DADFF8FC413 /* DemoRootView.swift */; }; + 09483F173B4BA76970D91053 /* StudioTabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8CE16EDFC593876FCCBDE6 /* StudioTabContent.swift */; }; + 0B86ED90493F35683517D951 /* StudioModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFAD52F96F3DA3E7970A3FAE /* StudioModels.swift */; }; 0CC75A9D8749E66AFC14C70C /* ProjectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AC15089586F9F7F034498FD /* ProjectsView.swift */; }; 0D6AAF0C615A0C0E2C4BE375 /* DemoRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8F652D30895DADFF8FC413 /* DemoRootView.swift */; }; 1078B9E4B8EDAC5678966904 /* StudioTabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8CE16EDFC593876FCCBDE6 /* StudioTabContent.swift */; }; + 115260720C295142177D30EE /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3217B7F8D4E6C8636271B71 /* OverviewView.swift */; }; + 12230A89F0B90571D9B54D14 /* StudioAdaptationApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A23CA93EE3F4D0A2E7CF2B82 /* StudioAdaptationApp.swift */; }; + 1BC69D4BFDA4B7A5589608BA /* StudioSearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2471A48D0E6BE9EC684739 /* StudioSearchResults.swift */; }; + 1E1D36519F486786B7DEC2A7 /* StudioDataUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F57801E5378223ABAC27C54 /* StudioDataUITests.swift */; }; 24228A653F8368E4469A6D5A /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3217B7F8D4E6C8636271B71 /* OverviewView.swift */; }; + 2AEA2BC08A15513003ADECFB /* StudioTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B1C8584435645A817B41BE /* StudioTestSupport.swift */; }; + 2B32AD0C6445DBF0FDA1EB81 /* StudioCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CFD6FB576098583C8379BB /* StudioCoordinator.swift */; }; 2DCF75B3059CFCD0CBB4130A /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6952814A9A10B1D49CD64181 /* ActivityView.swift */; }; 34A3D4DAF200F688F79F950F /* AdaptiveAppShell in Frameworks */ = {isa = PBXBuildFile; productRef = 6B1D029AC2B178ECD55454C1 /* AdaptiveAppShell */; }; + 3AEDE1DE54697072F3900C9E /* StudioStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C906A2A3F32E87FCFF927C /* StudioStore.swift */; }; + 3BD48362CE98E226432F2D53 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B11B8D51E0CF628D780800 /* SettingsView.swift */; }; + 3C544DFA733FD8EE0598130A /* StudioSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7363D5CCA279CB7EAF9A60 /* StudioSheets.swift */; }; + 4735341518F6BC21A9F41C2E /* ProjectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AC15089586F9F7F034498FD /* ProjectsView.swift */; }; 54E434E9B62684CD3C7BA846 /* AdaptiveShellDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69713C6EDA57BD7436AF2051 /* AdaptiveShellDemoApp.swift */; }; + 609A4E3C655D9A2E174D43D3 /* ProjectDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7037CF1691169C6011975F20 /* ProjectDetailView.swift */; }; + 69C3B5FDAD83CE24321FA4C5 /* StudioAdaptationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FEB276B86366F9DDE536F7D /* StudioAdaptationUITests.swift */; }; + 85A22EF11B84D3E4668AED0E /* StudioNavigationUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D35FDDDA173FF5BA0E49DA0 /* StudioNavigationUITests.swift */; }; + 8CC35CE936A22BCB8270BC94 /* StudioCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CFD6FB576098583C8379BB /* StudioCoordinator.swift */; }; + 98B8DDA47061276EDECE48E3 /* StudioUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B4AC23782DA0EBC19C1D1B2 /* StudioUITests.swift */; }; + 9FCD9ECC2C5CB834CC1D0434 /* StudioWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AF7BAA181382A8E849AEF01 /* StudioWorkflowTests.swift */; }; A3F3469EC93878DFEFFD110F /* StudioSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7363D5CCA279CB7EAF9A60 /* StudioSheets.swift */; }; + A88E3F74E1CB15B34CD119D5 /* StudioThemeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9914EBD1F344066140816E /* StudioThemeTransition.swift */; }; AA60038129374354CEA8AC97 /* StudioComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8868C17FDD84A72E4E6193 /* StudioComponents.swift */; }; + AD66639ADD495C1506FD1026 /* StudioSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C6F1240B3BCE6A1BF52A81 /* StudioSearchView.swift */; }; + AE634D99D3EAED91DF2735B1 /* AdaptiveAppShell in Frameworks */ = {isa = PBXBuildFile; productRef = 19817E145DCC7A6921F3A742 /* AdaptiveAppShell */; }; B1B68EDC9C97F8E297F72ED2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B11B8D51E0CF628D780800 /* SettingsView.swift */; }; B3AA18DD5070C1A41A7EAE38 /* StudioSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C6F1240B3BCE6A1BF52A81 /* StudioSearchView.swift */; }; BCACD1EF471BFFF6544D027F /* StudioModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFAD52F96F3DA3E7970A3FAE /* StudioModels.swift */; }; + C5CADFE75CD907AB927FD98B /* StudioStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C906A2A3F32E87FCFF927C /* StudioStore.swift */; }; + D9A4DC57290DBE93AC63E205 /* StudioThemeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9914EBD1F344066140816E /* StudioThemeTransition.swift */; }; + DBF28A958A1DDA1224AB709B /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6952814A9A10B1D49CD64181 /* ActivityView.swift */; }; + DE5A859A4922B09F5A2B6DDA /* StudioComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8868C17FDD84A72E4E6193 /* StudioComponents.swift */; }; EAA733C24EFFF1DD2D1E016B /* ProjectDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7037CF1691169C6011975F20 /* ProjectDetailView.swift */; }; + EC3DD91F978D02E56A2064D2 /* StudioWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6B6D7143C184D6803F6510 /* StudioWindowTests.swift */; }; + F09EEFC0BDBE8B596B09D06D /* StudioSearchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F2DC84AACEE036B4A371D6 /* StudioSearchUITests.swift */; }; + FF0B05587FF69C05EB7215D7 /* StudioSearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2471A48D0E6BE9EC684739 /* StudioSearchResults.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 431F098BA4A94F96E3AF8D6C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E90E4BC916F69D6EA9250B8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFFD6C1BDF49A63AC66F4023; + remoteInfo = StudioAdaptationHost; + }; + C3784C8646625D09559A1184 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E90E4BC916F69D6EA9250B8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFFD6C1BDF49A63AC66F4023; + remoteInfo = StudioAdaptationHost; + }; + E06493A835FAC3AF35A39989 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E90E4BC916F69D6EA9250B8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E309D1EA6A434D6608901F81; + remoteInfo = AdaptiveShellDemo; + }; + E7F0435E1721203EBAEE57E9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E90E4BC916F69D6EA9250B8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E309D1EA6A434D6608901F81; + remoteInfo = AdaptiveShellDemo; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 07C906A2A3F32E87FCFF927C /* StudioStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioStore.swift; sourceTree = ""; }; + 0AF7BAA181382A8E849AEF01 /* StudioWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioWorkflowTests.swift; sourceTree = ""; }; + 0FEB276B86366F9DDE536F7D /* StudioAdaptationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioAdaptationUITests.swift; sourceTree = ""; }; 1AC15089586F9F7F034498FD /* ProjectsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectsView.swift; sourceTree = ""; }; + 1CF622346E6EA8EC623BC41A /* StudioAdaptationHost.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = StudioAdaptationHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2A8F652D30895DADFF8FC413 /* DemoRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoRootView.swift; sourceTree = ""; }; + 2B4AC23782DA0EBC19C1D1B2 /* StudioUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioUITests.swift; sourceTree = ""; }; + 32CFD6FB576098583C8379BB /* StudioCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioCoordinator.swift; sourceTree = ""; }; + 3425B6EE48C8B4D0FB19A6DF /* AdaptiveShellDemoTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = AdaptiveShellDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3F57801E5378223ABAC27C54 /* StudioDataUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioDataUITests.swift; sourceTree = ""; }; + 4D35FDDDA173FF5BA0E49DA0 /* StudioNavigationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioNavigationUITests.swift; sourceTree = ""; }; + 4F2471A48D0E6BE9EC684739 /* StudioSearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioSearchResults.swift; sourceTree = ""; }; 54B11B8D51E0CF628D780800 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 685D6329C337DD87CED7E6FD /* StudioRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioRegressionTests.swift; sourceTree = ""; }; + 6874CDDA484ACA643C567BE3 /* StudioAdaptationUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = StudioAdaptationUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6952814A9A10B1D49CD64181 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; 69713C6EDA57BD7436AF2051 /* AdaptiveShellDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveShellDemoApp.swift; sourceTree = ""; }; 6DA6C6FE9A4F275244768F34 /* AdaptiveAppShell */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AdaptiveAppShell; path = ../..; sourceTree = SOURCE_ROOT; }; 6E8CE16EDFC593876FCCBDE6 /* StudioTabContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioTabContent.swift; sourceTree = ""; }; 7037CF1691169C6011975F20 /* ProjectDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDetailView.swift; sourceTree = ""; }; + 7B9914EBD1F344066140816E /* StudioThemeTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioThemeTransition.swift; sourceTree = ""; }; + 8E4876F3FB5ECE5E843CF98D /* AdaptiveShellDemoUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = AdaptiveShellDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A23CA93EE3F4D0A2E7CF2B82 /* StudioAdaptationApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioAdaptationApp.swift; sourceTree = ""; }; A3217B7F8D4E6C8636271B71 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; ADA306EEA689AE39BC495B86 /* AdaptiveShellDemo.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = AdaptiveShellDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; BD7363D5CCA279CB7EAF9A60 /* StudioSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioSheets.swift; sourceTree = ""; }; BD8868C17FDD84A72E4E6193 /* StudioComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioComponents.swift; sourceTree = ""; }; C9C6F1240B3BCE6A1BF52A81 /* StudioSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioSearchView.swift; sourceTree = ""; }; + DD6B6D7143C184D6803F6510 /* StudioWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioWindowTests.swift; sourceTree = ""; }; DFAD52F96F3DA3E7970A3FAE /* StudioModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioModels.swift; sourceTree = ""; }; + E6B1C8584435645A817B41BE /* StudioTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioTestSupport.swift; sourceTree = ""; }; + F1F2DC84AACEE036B4A371D6 /* StudioSearchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioSearchUITests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -48,9 +127,37 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EC8D5863C903327188B4BBB4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AE634D99D3EAED91DF2735B1 /* AdaptiveAppShell in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5DEBE1778973A3DDD5ABD68C /* AdaptiveShellDemoUITests */ = { + isa = PBXGroup; + children = ( + 3F57801E5378223ABAC27C54 /* StudioDataUITests.swift */, + 4D35FDDDA173FF5BA0E49DA0 /* StudioNavigationUITests.swift */, + F1F2DC84AACEE036B4A371D6 /* StudioSearchUITests.swift */, + E6B1C8584435645A817B41BE /* StudioTestSupport.swift */, + 2B4AC23782DA0EBC19C1D1B2 /* StudioUITests.swift */, + ); + path = AdaptiveShellDemoUITests; + sourceTree = ""; + }; + 8288F369C4AC299C9AACF45B /* StudioAdaptationHost */ = { + isa = PBXGroup; + children = ( + A23CA93EE3F4D0A2E7CF2B82 /* StudioAdaptationApp.swift */, + ); + path = StudioAdaptationHost; + sourceTree = ""; + }; 8D811EA671669D7BBEBEB700 /* Packages */ = { isa = PBXGroup; children = ( @@ -59,10 +166,32 @@ name = Packages; sourceTree = ""; }; + A84FB3D0ED9EA65FE83DC803 /* AdaptiveShellDemoTests */ = { + isa = PBXGroup; + children = ( + 685D6329C337DD87CED7E6FD /* StudioRegressionTests.swift */, + DD6B6D7143C184D6803F6510 /* StudioWindowTests.swift */, + 0AF7BAA181382A8E849AEF01 /* StudioWorkflowTests.swift */, + ); + path = AdaptiveShellDemoTests; + sourceTree = ""; + }; + BB39C01781A5690D8CD2DE27 /* StudioAdaptationUITests */ = { + isa = PBXGroup; + children = ( + 0FEB276B86366F9DDE536F7D /* StudioAdaptationUITests.swift */, + ); + path = StudioAdaptationUITests; + sourceTree = ""; + }; CBD9A5CE55ACCE5B4A5311D8 /* Products */ = { isa = PBXGroup; children = ( ADA306EEA689AE39BC495B86 /* AdaptiveShellDemo.app */, + 3425B6EE48C8B4D0FB19A6DF /* AdaptiveShellDemoTests.xctest */, + 8E4876F3FB5ECE5E843CF98D /* AdaptiveShellDemoUITests.xctest */, + 1CF622346E6EA8EC623BC41A /* StudioAdaptationHost.app */, + 6874CDDA484ACA643C567BE3 /* StudioAdaptationUITests.xctest */, ); name = Products; sourceTree = ""; @@ -78,10 +207,14 @@ 1AC15089586F9F7F034498FD /* ProjectsView.swift */, 54B11B8D51E0CF628D780800 /* SettingsView.swift */, BD8868C17FDD84A72E4E6193 /* StudioComponents.swift */, + 32CFD6FB576098583C8379BB /* StudioCoordinator.swift */, DFAD52F96F3DA3E7970A3FAE /* StudioModels.swift */, + 4F2471A48D0E6BE9EC684739 /* StudioSearchResults.swift */, C9C6F1240B3BCE6A1BF52A81 /* StudioSearchView.swift */, BD7363D5CCA279CB7EAF9A60 /* StudioSheets.swift */, + 07C906A2A3F32E87FCFF927C /* StudioStore.swift */, 6E8CE16EDFC593876FCCBDE6 /* StudioTabContent.swift */, + 7B9914EBD1F344066140816E /* StudioThemeTransition.swift */, ); path = AdaptiveShellDemo; sourceTree = ""; @@ -90,7 +223,11 @@ isa = PBXGroup; children = ( CF6D9764EE5F4CAA892A404A /* AdaptiveShellDemo */, + A84FB3D0ED9EA65FE83DC803 /* AdaptiveShellDemoTests */, + 5DEBE1778973A3DDD5ABD68C /* AdaptiveShellDemoUITests */, 8D811EA671669D7BBEBEB700 /* Packages */, + 8288F369C4AC299C9AACF45B /* StudioAdaptationHost */, + BB39C01781A5690D8CD2DE27 /* StudioAdaptationUITests */, CBD9A5CE55ACCE5B4A5311D8 /* Products */, ); sourceTree = ""; @@ -98,6 +235,61 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 253991B511D71377A1FFB076 /* AdaptiveShellDemoUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C2C873458BC5EF375BB2CBE2 /* Build configuration list for PBXNativeTarget "AdaptiveShellDemoUITests" */; + buildPhases = ( + F34C599088CB3ED4927DCBE0 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 6CBE81FF1D63AF4B46CF3FF5 /* PBXTargetDependency */, + 7ACD16305FCBF02730F94B9C /* PBXTargetDependency */, + ); + name = AdaptiveShellDemoUITests; + packageProductDependencies = ( + ); + productName = AdaptiveShellDemoUITests; + productReference = 8E4876F3FB5ECE5E843CF98D /* AdaptiveShellDemoUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 47E517443737D746BF43CAD7 /* AdaptiveShellDemoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BC52A70999AB3B214D9D1EBD /* Build configuration list for PBXNativeTarget "AdaptiveShellDemoTests" */; + buildPhases = ( + 96C19305EC11AE4B21EBA935 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + D3387B0D04D0F78E12D282A2 /* PBXTargetDependency */, + ); + name = AdaptiveShellDemoTests; + packageProductDependencies = ( + ); + productName = AdaptiveShellDemoTests; + productReference = 3425B6EE48C8B4D0FB19A6DF /* AdaptiveShellDemoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A7497FC705C1A7C61E3922A0 /* StudioAdaptationUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 084296B912BC84BA62B87CBE /* Build configuration list for PBXNativeTarget "StudioAdaptationUITests" */; + buildPhases = ( + 67DD745CFB41D2103AD8F1D3 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 5E2452E99EF345790E4088E8 /* PBXTargetDependency */, + ); + name = StudioAdaptationUITests; + packageProductDependencies = ( + ); + productName = StudioAdaptationUITests; + productReference = 6874CDDA484ACA643C567BE3 /* StudioAdaptationUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E309D1EA6A434D6608901F81 /* AdaptiveShellDemo */ = { isa = PBXNativeTarget; buildConfigurationList = 27D89512F65D9E020EAEBE66 /* Build configuration list for PBXNativeTarget "AdaptiveShellDemo" */; @@ -117,6 +309,25 @@ productReference = ADA306EEA689AE39BC495B86 /* AdaptiveShellDemo.app */; productType = "com.apple.product-type.application"; }; + FFFD6C1BDF49A63AC66F4023 /* StudioAdaptationHost */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33963464D1DC5A2AAF0748F0 /* Build configuration list for PBXNativeTarget "StudioAdaptationHost" */; + buildPhases = ( + 751EFC962BF13B3DF5868CE8 /* Sources */, + EC8D5863C903327188B4BBB4 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StudioAdaptationHost; + packageProductDependencies = ( + 19817E145DCC7A6921F3A742 /* AdaptiveAppShell */, + ); + productName = StudioAdaptationHost; + productReference = 1CF622346E6EA8EC623BC41A /* StudioAdaptationHost.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -126,10 +337,24 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2600; TargetAttributes = { + 253991B511D71377A1FFB076 = { + DevelopmentTeam = ""; + TestTargetID = E309D1EA6A434D6608901F81; + }; + 47E517443737D746BF43CAD7 = { + DevelopmentTeam = ""; + }; + A7497FC705C1A7C61E3922A0 = { + DevelopmentTeam = ""; + TestTargetID = FFFD6C1BDF49A63AC66F4023; + }; E309D1EA6A434D6608901F81 = { DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; + FFFD6C1BDF49A63AC66F4023 = { + DevelopmentTeam = ""; + }; }; }; buildConfigurationList = 9327170E6033EB4508C33236 /* Build configuration list for PBXProject "AdaptiveShellDemo" */; @@ -150,6 +375,10 @@ projectRoot = ""; targets = ( E309D1EA6A434D6608901F81 /* AdaptiveShellDemo */, + 47E517443737D746BF43CAD7 /* AdaptiveShellDemoTests */, + 253991B511D71377A1FFB076 /* AdaptiveShellDemoUITests */, + FFFD6C1BDF49A63AC66F4023 /* StudioAdaptationHost */, + A7497FC705C1A7C61E3922A0 /* StudioAdaptationUITests */, ); }; /* End PBXProject section */ @@ -167,16 +396,114 @@ 0CC75A9D8749E66AFC14C70C /* ProjectsView.swift in Sources */, B1B68EDC9C97F8E297F72ED2 /* SettingsView.swift in Sources */, AA60038129374354CEA8AC97 /* StudioComponents.swift in Sources */, + 2B32AD0C6445DBF0FDA1EB81 /* StudioCoordinator.swift in Sources */, BCACD1EF471BFFF6544D027F /* StudioModels.swift in Sources */, + 1BC69D4BFDA4B7A5589608BA /* StudioSearchResults.swift in Sources */, B3AA18DD5070C1A41A7EAE38 /* StudioSearchView.swift in Sources */, A3F3469EC93878DFEFFD110F /* StudioSheets.swift in Sources */, + 3AEDE1DE54697072F3900C9E /* StudioStore.swift in Sources */, 1078B9E4B8EDAC5678966904 /* StudioTabContent.swift in Sources */, + A88E3F74E1CB15B34CD119D5 /* StudioThemeTransition.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 67DD745CFB41D2103AD8F1D3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 69C3B5FDAD83CE24321FA4C5 /* StudioAdaptationUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 751EFC962BF13B3DF5868CE8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DBF28A958A1DDA1224AB709B /* ActivityView.swift in Sources */, + 018FD70DC530D140E053DAFA /* DemoRootView.swift in Sources */, + 115260720C295142177D30EE /* OverviewView.swift in Sources */, + 609A4E3C655D9A2E174D43D3 /* ProjectDetailView.swift in Sources */, + 4735341518F6BC21A9F41C2E /* ProjectsView.swift in Sources */, + 3BD48362CE98E226432F2D53 /* SettingsView.swift in Sources */, + 12230A89F0B90571D9B54D14 /* StudioAdaptationApp.swift in Sources */, + DE5A859A4922B09F5A2B6DDA /* StudioComponents.swift in Sources */, + 8CC35CE936A22BCB8270BC94 /* StudioCoordinator.swift in Sources */, + 0B86ED90493F35683517D951 /* StudioModels.swift in Sources */, + FF0B05587FF69C05EB7215D7 /* StudioSearchResults.swift in Sources */, + AD66639ADD495C1506FD1026 /* StudioSearchView.swift in Sources */, + 3C544DFA733FD8EE0598130A /* StudioSheets.swift in Sources */, + C5CADFE75CD907AB927FD98B /* StudioStore.swift in Sources */, + 09483F173B4BA76970D91053 /* StudioTabContent.swift in Sources */, + D9A4DC57290DBE93AC63E205 /* StudioThemeTransition.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 96C19305EC11AE4B21EBA935 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0008A330E77684B3FADA0FAA /* StudioRegressionTests.swift in Sources */, + EC3DD91F978D02E56A2064D2 /* StudioWindowTests.swift in Sources */, + 9FCD9ECC2C5CB834CC1D0434 /* StudioWorkflowTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F34C599088CB3ED4927DCBE0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1E1D36519F486786B7DEC2A7 /* StudioDataUITests.swift in Sources */, + 85A22EF11B84D3E4668AED0E /* StudioNavigationUITests.swift in Sources */, + F09EEFC0BDBE8B596B09D06D /* StudioSearchUITests.swift in Sources */, + 2AEA2BC08A15513003ADECFB /* StudioTestSupport.swift in Sources */, + 98B8DDA47061276EDECE48E3 /* StudioUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 5E2452E99EF345790E4088E8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FFFD6C1BDF49A63AC66F4023 /* StudioAdaptationHost */; + targetProxy = C3784C8646625D09559A1184 /* PBXContainerItemProxy */; + }; + 6CBE81FF1D63AF4B46CF3FF5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E309D1EA6A434D6608901F81 /* AdaptiveShellDemo */; + targetProxy = E06493A835FAC3AF35A39989 /* PBXContainerItemProxy */; + }; + 7ACD16305FCBF02730F94B9C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FFFD6C1BDF49A63AC66F4023 /* StudioAdaptationHost */; + targetProxy = 431F098BA4A94F96E3AF8D6C /* PBXContainerItemProxy */; + }; + D3387B0D04D0F78E12D282A2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E309D1EA6A434D6608901F81 /* AdaptiveShellDemo */; + targetProxy = E7F0435E1721203EBAEE57E9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 00FBC93CDEB46D5BB002BEE6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.adaptation; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 3638E42AB39DF059F8A38921 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -190,7 +517,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.demo; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -257,6 +584,126 @@ }; name = Release; }; + 4E9D035C1DB37AD37C5D1D20 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.adaptation.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = StudioAdaptationHost; + }; + name = Release; + }; + 53B90AA281D23DDFFEE0B0E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.demo.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AdaptiveShellDemo.app/AdaptiveShellDemo"; + }; + name = Debug; + }; + 57E4BB20FDF7181A7B053184 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.demo.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AdaptiveShellDemo.app/AdaptiveShellDemo"; + }; + name = Release; + }; + 635FDD29923A7CAD0875A44C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.adaptation; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 67D740B3A28C8F4592F1AD1D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.demo.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = AdaptiveShellDemo; + }; + name = Release; + }; + 8C74454481275F9DE41A55ED /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.adaptation.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = StudioAdaptationHost; + }; + name = Debug; + }; + 9E27DEB32BE37AE386FD59AF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.demo.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = AdaptiveShellDemo; + }; + name = Debug; + }; C1BB77DAC31814029063B6D8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -334,7 +781,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = dev.liquidglasstemplates.adaptiveshell.demo; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -347,6 +794,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 084296B912BC84BA62B87CBE /* Build configuration list for PBXNativeTarget "StudioAdaptationUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8C74454481275F9DE41A55ED /* Debug */, + 4E9D035C1DB37AD37C5D1D20 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 27D89512F65D9E020EAEBE66 /* Build configuration list for PBXNativeTarget "AdaptiveShellDemo" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -356,6 +812,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 33963464D1DC5A2AAF0748F0 /* Build configuration list for PBXNativeTarget "StudioAdaptationHost" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 635FDD29923A7CAD0875A44C /* Debug */, + 00FBC93CDEB46D5BB002BEE6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 9327170E6033EB4508C33236 /* Build configuration list for PBXProject "AdaptiveShellDemo" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -365,6 +830,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + BC52A70999AB3B214D9D1EBD /* Build configuration list for PBXNativeTarget "AdaptiveShellDemoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 53B90AA281D23DDFFEE0B0E2 /* Debug */, + 57E4BB20FDF7181A7B053184 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C2C873458BC5EF375BB2CBE2 /* Build configuration list for PBXNativeTarget "AdaptiveShellDemoUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9E27DEB32BE37AE386FD59AF /* Debug */, + 67D740B3A28C8F4592F1AD1D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -375,6 +858,10 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 19817E145DCC7A6921F3A742 /* AdaptiveAppShell */ = { + isa = XCSwiftPackageProductDependency; + productName = AdaptiveAppShell; + }; 6B1D029AC2B178ECD55454C1 /* AdaptiveAppShell */ = { isa = XCSwiftPackageProductDependency; productName = AdaptiveAppShell; diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/xcshareddata/xcschemes/AdaptiveShellDemo.xcscheme b/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/xcshareddata/xcschemes/AdaptiveShellDemo.xcscheme index b1ecc07..1d0cd26 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/xcshareddata/xcschemes/AdaptiveShellDemo.xcscheme +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj/xcshareddata/xcschemes/AdaptiveShellDemo.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:AdaptiveShellDemo.xcodeproj"> + + + + + + + + + + + + + + + + @@ -63,8 +111,6 @@ ReferencedContainer = "container:AdaptiveShellDemo.xcodeproj"> - - - - diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/DemoRootView.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/DemoRootView.swift index c56a2d7..86148f3 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/DemoRootView.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/DemoRootView.swift @@ -4,12 +4,12 @@ import UIKit struct DemoRootView: View { @State private var shellState: AdaptiveShellState + @State private var store: StudioStore + @State private var coordinator: StudioCoordinator @State private var themeID: AdaptiveShellThemeID - @State private var selectedProjectID = StudioFixtures.projects[0].id @State private var presentedSheet: StudioSheet? - @State private var themeSnapshot: UIImage? - @State private var themeSnapshotOpacity = 0.0 - @State private var themeTransitionID = UUID() + @State private var windowReference = StudioWindowReference() + @State private var themeTransition = StudioThemeTransition() private let showcaseMode: Bool private let profileMode: Bool @@ -27,13 +27,16 @@ struct DemoRootView: View { initialTheme = .classic } - _shellState = State( - initialValue: AdaptiveShellState( - selection: initialTab, - columnVisibility: .all, - isInspectorPresented: arguments.contains("--inspector") - ) + let state = AdaptiveShellState( + selection: initialTab, + columnVisibility: .all, + isInspectorPresented: arguments.contains("--inspector") ) + let store = StudioStore() + store.keepInspectorOpen = arguments.contains("--inspector") + _shellState = State(initialValue: state) + _store = State(initialValue: store) + _coordinator = State(initialValue: StudioCoordinator(store: store, state: state)) _themeID = State(initialValue: initialTheme) showcaseMode = arguments.contains("--showcase") profileMode = arguments.contains("--profile") @@ -43,10 +46,6 @@ struct DemoRootView: View { AdaptiveShellTheme.preset(themeID) } - private var selectedProject: StudioProject { - StudioFixtures.project(id: selectedProjectID) - } - private var themeSelection: Binding { Binding( get: { themeID }, @@ -60,35 +59,53 @@ struct DemoRootView: View { sections: StudioTab.sections, state: shellState, showsInspector: { item in + if shellState.paths[item.id]?.last != nil { return true } switch item.id { case .overview, .projects, .clientWork, .internalWork, .archive: - true + return true case .activity, .search, .settings: - false + return false } }, content: { item, navigation in StudioTabContent( tab: item.id, navigation: navigation, - selectedProjectID: $selectedProjectID, + store: store, + coordinator: coordinator, themeID: themeSelection, presentedSheet: $presentedSheet ) }, destination: { route in - StudioRouteView(route: route) + StudioRouteView(route: route, store: store) }, inspector: { _ in - ProjectInspectorView(project: selectedProject) + if let project = coordinator.inspectorProject { + ProjectInspectorView(project: project, showProgress: store.showProgress) { + shellState.isInspectorPresented = false + } + } else { + StudioEmptyState( + title: "No project selected", + message: "Open a project to see its details.", systemImage: "folder" + ) + } } ) .adaptiveShellTheme(theme) .preferredColorScheme(theme.recommendedColorScheme) + .background(StudioWindowReader(reference: windowReference)) .sheet(item: $presentedSheet) { sheet in switch sheet { case .newProject: - NewProjectSheet() + NewProjectSheet { name, category, dueDate in + guard let project = store.createProject(name: name, category: category, dueDate: dueDate) else { + return false + } + coordinator.openProject(project.id, from: .projects, replacingPath: true) + return true + } .adaptiveShellTheme(theme) .preferredColorScheme(theme.recommendedColorScheme) case .profile: @@ -97,25 +114,43 @@ struct DemoRootView: View { .preferredColorScheme(theme.recommendedColorScheme) } } - .onOpenURL(perform: handleDeepLink) + .onOpenURL(perform: coordinator.handleDeepLink) + .onChange(of: store.keepInspectorOpen) { _, keepOpen in + shellState.isInspectorPresented = keepOpen + } + .alert( + coordinator.navigationMessage?.title ?? "Navigation", + isPresented: Binding( + get: { coordinator.navigationMessage != nil }, + set: { if !$0 { coordinator.navigationMessage = nil } } + ) + ) { + Button("OK", role: .cancel) { coordinator.navigationMessage = nil } + } message: { + Text(coordinator.navigationMessage?.message ?? "") + } .overlay { - if let themeSnapshot { - Image(uiImage: themeSnapshot) + if let snapshot = themeTransition.snapshot { + Image(uiImage: snapshot) .resizable() .scaledToFill() .ignoresSafeArea() - .opacity(themeSnapshotOpacity) + .opacity(themeTransition.opacity) .allowsHitTesting(false) .accessibilityHidden(true) } } + .onDisappear { themeTransition.cancel() } .task { - if profileMode { - await pause(milliseconds: 300) - presentedSheet = .profile + do { + if profileMode { + try await pause(milliseconds: 300) + presentedSheet = .profile + } + try await runShowcaseIfNeeded() + } catch { + return } - - await runShowcaseIfNeeded() } } @@ -124,115 +159,67 @@ struct DemoRootView: View { guard newThemeID != themeID else { return } guard !UIAccessibility.isReduceMotionEnabled else { + themeTransition.cancel() themeID = newThemeID return } - let transitionID = UUID() - themeTransitionID = transitionID - themeSnapshot = WindowSnapshot.capture() - themeSnapshotOpacity = 1 + themeTransition.start(in: windowReference.window) themeID = newThemeID - - Task { @MainActor in - await pause(milliseconds: 70) - - guard themeTransitionID == transitionID else { return } - - withAnimation(.easeInOut(duration: 0.52)) { - themeSnapshotOpacity = 0 - } - - await pause(milliseconds: 560) - - guard themeTransitionID == transitionID else { return } - themeSnapshot = nil - } - } - - private func handleDeepLink(_ url: URL) { - guard url.scheme == "adaptiveshell" else { return } - let components = url.pathComponents.filter { $0 != "/" } - - if url.host == "project", let id = components.first { - selectedProjectID = id - shellState.select(.projects) - shellState.replacePath([.project(id)], in: .projects) - } else if url.host == "search" { - shellState.select(.search) - } } @MainActor - private func runShowcaseIfNeeded() async { + private func runShowcaseIfNeeded() async throws { guard showcaseMode else { return } - await pause(milliseconds: 2_600) + try await pause(milliseconds: 2_600) while !Task.isCancelled { - await pause(milliseconds: 1_700) + try await pause(milliseconds: 1_700) withAnimation(.easeInOut(duration: 0.28)) { shellState.select(.projects) } - await pause(milliseconds: 1_200) + try await pause(milliseconds: 1_200) withAnimation(.easeInOut(duration: 0.28)) { shellState.select(.overview) } - await pause(milliseconds: 350) + try await pause(milliseconds: 350) changeTheme(to: .graphite) - await pause(milliseconds: 1_900) + try await pause(milliseconds: 1_900) withAnimation(.easeInOut(duration: 0.28)) { - selectedProjectID = "website-qa" shellState.select(.search) } - await pause(milliseconds: 1_200) + try await pause(milliseconds: 1_200) withAnimation(.easeInOut(duration: 0.28)) { shellState.select(.overview) } - await pause(milliseconds: 350) + try await pause(milliseconds: 350) changeTheme(to: .stone) - await pause(milliseconds: 1_900) + try await pause(milliseconds: 1_900) withAnimation(.easeInOut(duration: 0.28)) { shellState.select(.activity) } - await pause(milliseconds: 1_200) + try await pause(milliseconds: 1_200) withAnimation(.easeInOut(duration: 0.28)) { - selectedProjectID = "spring-catalogue" shellState.select(.overview) } - await pause(milliseconds: 350) + try await pause(milliseconds: 350) changeTheme(to: .classic) - await pause(milliseconds: 500) + try await pause(milliseconds: 500) } } - private func pause(milliseconds: UInt64) async { - try? await Task.sleep(nanoseconds: milliseconds * 1_000_000) - } -} - -@MainActor -private enum WindowSnapshot { - static func capture() -> UIImage? { - let window = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first(where: \.isKeyWindow) - - guard let window else { return nil } - - return UIGraphicsImageRenderer(bounds: window.bounds).image { _ in - window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) - } + private func pause(milliseconds: UInt64) async throws { + try await Task.sleep(nanoseconds: milliseconds * 1_000_000) } } diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/OverviewView.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/OverviewView.swift index 6bd39a3..56d9262 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/OverviewView.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/OverviewView.swift @@ -5,11 +5,11 @@ struct OverviewView: View { @Environment(\.adaptiveShellTheme) private var theme @Environment(\.adaptiveShellPresentation) private var presentation - let navigation: AdaptiveShellNavigation - @Binding var selectedProjectID: String + let store: StudioStore + let coordinator: StudioCoordinator - private let activeProjects = StudioFixtures.projects.filter { - $0.collection != .archive + private var activeProjects: [StudioProject] { + store.projects.filter { $0.collection != .archive } } var body: some View { @@ -17,6 +17,10 @@ struct OverviewView: View { LazyVStack(alignment: .leading, spacing: 24) { morningSummary + Text("Sample workspace · Session-only data") + .font(.footnote) + .foregroundStyle(theme.secondaryText) + StudioSectionHeader(title: "Active projects") AdaptiveShellCard(padding: 0) { @@ -37,23 +41,24 @@ struct OverviewView: View { AdaptiveShellCard(padding: 0) { VStack(spacing: 0) { - upcomingRow( - day: "10", - month: "JUL", - title: "Spring catalogue", - subtitle: "Copy review" - ) - - Divider() - .overlay(theme.separator) - .padding(.leading, 70) - - upcomingRow( - day: "18", - month: "JUL", - title: "Website QA", - subtitle: "Usability testing" - ) + ForEach(store.upcomingTasks) { result in + Button { + coordinator.openProject( + result.project.id, from: .overview, presentation: presentation + ) + } label: { + upcomingRow(result) + } + .buttonStyle(.plain) + .accessibilityIdentifier("upcoming-project-\(result.project.id)") + .accessibilityHint("Opens project details") + if result.id != store.upcomingTasks.last?.id { + Divider().overlay(theme.separator).padding(.leading, 70) + } + } + if store.upcomingTasks.isEmpty { + Text("No upcoming tasks.").foregroundStyle(theme.secondaryText).padding(16) + } } } } @@ -81,7 +86,7 @@ struct OverviewView: View { .font(.headline) .foregroundStyle(theme.primaryText) - Text("3 projects need attention") + Text("\(activeProjects.filter { $0.progress < 1 }.count) active projects") .font(.subheadline) .foregroundStyle(theme.secondaryText) } @@ -98,28 +103,22 @@ struct OverviewView: View { private func projectButton(_ project: StudioProject) -> some View { Button { - selectedProjectID = project.id - if presentation == .compact { - navigation.push(.project(project.id)) - } + coordinator.openProject(project.id, from: .overview, presentation: presentation) } label: { - ProjectRow(project: project) + ProjectRow(project: project, showProgress: store.showProgress) .padding(16) } .buttonStyle(.plain) + .accessibilityIdentifier("project-\(project.id)") } - private func upcomingRow( - day: String, - month: String, - title: String, - subtitle: String - ) -> some View { - HStack(spacing: 14) { + private func upcomingRow(_ result: StudioTaskResult) -> some View { + let dateParts = result.task.dueText.split(separator: " ") + return HStack(spacing: 14) { VStack(spacing: 0) { - Text(day) + Text(dateParts.first.map(String.init) ?? "—") .font(.subheadline.weight(.bold).monospacedDigit()) - Text(month) + Text(dateParts.dropFirst().first.map { $0.uppercased() } ?? "") .font(.caption2.weight(.bold)) } .foregroundStyle(theme.accent) @@ -127,11 +126,11 @@ struct OverviewView: View { .background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { - Text(title) + Text(result.project.name) .font(.subheadline.weight(.semibold)) .foregroundStyle(theme.primaryText) - Text(subtitle) + Text(result.task.title) .font(.subheadline) .foregroundStyle(theme.secondaryText) } @@ -143,6 +142,7 @@ struct OverviewView: View { .foregroundStyle(theme.secondaryText.opacity(0.7)) } .padding(16) + .contentShape(Rectangle()) .accessibilityElement(children: .combine) } } diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectDetailView.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectDetailView.swift index 0787ccd..caf3188 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectDetailView.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectDetailView.swift @@ -5,14 +5,7 @@ struct ProjectDetailView: View { @Environment(\.adaptiveShellTheme) private var theme let project: StudioProject - @State private var completedTaskIDs: Set - - init(project: StudioProject) { - self.project = project - _completedTaskIDs = State( - initialValue: Set(project.tasks.filter(\.isComplete).map(\.id)) - ) - } + let store: StudioStore var body: some View { ScrollView { @@ -21,20 +14,23 @@ struct ProjectDetailView: View { AdaptiveShellCard { VStack(alignment: .leading, spacing: 14) { - HStack { - Text("Progress") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(theme.primaryText) - - Spacer() + if store.showProgress { + HStack { + Text("Progress") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(theme.primaryText) + + Spacer() + + Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(theme.secondaryText) + .accessibilityIdentifier("detail-progress") + } - Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) - .font(.subheadline.monospacedDigit()) - .foregroundStyle(theme.secondaryText) + ProjectProgressBar(progress: project.progress) } - ProjectProgressBar(progress: project.progress) - HStack { ProjectStatusBadge(status: project.status) Spacer() @@ -47,6 +43,10 @@ struct ProjectDetailView: View { StudioSectionHeader(title: "Tasks") + if project.tasks.isEmpty { + Text("No tasks in this project yet.").font(.subheadline).foregroundStyle(theme.secondaryText) + } + AdaptiveShellCard(padding: 0) { VStack(spacing: 0) { ForEach(project.tasks) { task in @@ -69,6 +69,7 @@ struct ProjectDetailView: View { .background(theme.canvas) .navigationTitle(project.name) .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("project-detail-\(project.id)") } private var header: some View { @@ -93,16 +94,12 @@ struct ProjectDetailView: View { private func taskRow(_ task: StudioTask) -> some View { Button { - if completedTaskIDs.contains(task.id) { - completedTaskIDs.remove(task.id) - } else { - completedTaskIDs.insert(task.id) - } + store.toggleTask(task.id, in: project.id) } label: { HStack(spacing: 14) { - Image(systemName: completedTaskIDs.contains(task.id) ? "checkmark.square.fill" : "square") + Image(systemName: task.isComplete ? "checkmark.square.fill" : "square") .font(.title3) - .foregroundStyle(completedTaskIDs.contains(task.id) ? theme.success : theme.secondaryText) + .foregroundStyle(task.isComplete ? theme.success : theme.secondaryText) Text(task.title) .foregroundStyle(theme.primaryText) @@ -117,7 +114,9 @@ struct ProjectDetailView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityValue(completedTaskIDs.contains(task.id) ? "Complete" : "Incomplete") + .accessibilityValue(task.isComplete ? "Complete" : "Incomplete") + .accessibilityHint("Toggles task completion") + .accessibilityIdentifier("task-\(project.id)-\(task.id)") } } @@ -125,6 +124,8 @@ struct ProjectInspectorView: View { @Environment(\.adaptiveShellTheme) private var theme let project: StudioProject + var showProgress = true + let onClose: () -> Void var body: some View { ScrollView { @@ -141,6 +142,17 @@ struct ProjectInspectorView: View { .font(.subheadline) .foregroundStyle(theme.secondaryText) } + Spacer(minLength: 0) + Button(role: .cancel, action: onClose) { + Image(systemName: "xmark") + .font(.body.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(theme.secondaryText) + .accessibilityLabel("Close inspector") + .accessibilityIdentifier("inspector-close") } Divider() @@ -151,14 +163,17 @@ struct ProjectInspectorView: View { .font(.headline) .foregroundStyle(theme.primaryText) - ProjectProgressBar(progress: project.progress) + if showProgress { ProjectProgressBar(progress: project.progress) } HStack { ProjectStatusBadge(status: project.status) Spacer() - Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) - .font(.subheadline.monospacedDigit()) - .foregroundStyle(theme.secondaryText) + if showProgress { + Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(theme.secondaryText) + .accessibilityIdentifier("inspector-progress") + } } } @@ -167,8 +182,11 @@ struct ProjectInspectorView: View { .font(.headline) .foregroundStyle(theme.primaryText) - ownerRow(initials: "AM", name: "Alex Morgan", role: "Design Director") - ownerRow(initials: "NW", name: "Noah Williams", role: "Art Director") + ForEach(project.ownerInitials, id: \.self) { initials in + if let person = StudioFixtures.people.first(where: { $0.id == initials }) { + ownerRow(initials: initials, name: person.name, role: person.role) + } + } } VStack(alignment: .leading, spacing: 10) { @@ -176,7 +194,9 @@ struct ProjectInspectorView: View { .font(.headline) .foregroundStyle(theme.primaryText) - Label("Print proof · 14 Jul", systemImage: "calendar") + Label(project.tasks.first(where: { !$0.isComplete }).map { + "\($0.title) · \($0.dueText)" + } ?? project.dueText, systemImage: "calendar") .font(.subheadline) .foregroundStyle(theme.secondaryText) } @@ -199,6 +219,7 @@ struct ProjectInspectorView: View { .padding(20) } .background(theme.surface) + .accessibilityIdentifier("project-inspector-\(project.id)") } private func ownerRow( @@ -230,14 +251,31 @@ struct ProjectInspectorView: View { struct TaskDetailView: View { @Environment(\.adaptiveShellTheme) private var theme + let projectID: String let taskID: String + let store: StudioStore var body: some View { - StudioEmptyState( - title: "Task details", - message: "Selected task: \(taskID)", - systemImage: "checklist" - ) + Group { + if let project = store.project(id: projectID), let task = project.tasks.first(where: { $0.id == taskID }) { + VStack(alignment: .leading, spacing: 18) { + Text(task.title).font(.title2.bold()) + .accessibilityIdentifier("task-detail-\(projectID)-\(taskID)") + Text("\(project.name) · \(task.dueText)").foregroundStyle(theme.secondaryText) + Button(task.isComplete ? "Mark incomplete" : "Mark complete") { + store.toggleTask(taskID, in: projectID) + } + .accessibilityValue(task.isComplete ? "Complete" : "Incomplete") + .accessibilityIdentifier("task-detail-toggle") + } + .padding(24) + } else { + StudioEmptyState( + title: "Task not found", + message: "This task is not available in this session.", systemImage: "checklist" + ) + } + } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(theme.canvas) .navigationTitle("Task") diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectsView.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectsView.swift index 224229a..6eca885 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectsView.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/ProjectsView.swift @@ -8,7 +8,8 @@ struct ProjectsView: View { let title: String let projects: [StudioProject] let navigation: AdaptiveShellNavigation - @Binding var selectedProjectID: String + let store: StudioStore + let coordinator: StudioCoordinator var body: some View { Group { @@ -27,15 +28,15 @@ struct ProjectsView: View { LazyVStack(spacing: 0) { ForEach(projects) { project in Button { - selectedProjectID = project.id - if presentation == .compact { - navigation.push(.project(project.id)) - } + coordinator.openProject( + project.id, from: navigation.currentTab, presentation: presentation + ) } label: { - ProjectRow(project: project) + ProjectRow(project: project, showProgress: store.showProgress) .padding(16) } .buttonStyle(.plain) + .accessibilityIdentifier("project-\(project.id)") if project.id != projects.last?.id { Divider() diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/SettingsView.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/SettingsView.swift index 94bf1f6..69beb8a 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/SettingsView.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/SettingsView.swift @@ -5,8 +5,7 @@ struct SettingsView: View { @Environment(\.adaptiveShellTheme) private var theme @Binding var themeID: AdaptiveShellThemeID - @State private var showProgress = true - @State private var keepInspectorOpen = true + @Bindable var store: StudioStore var body: some View { ScrollView { @@ -23,19 +22,26 @@ struct SettingsView: View { AdaptiveShellCard(padding: 0) { VStack(spacing: 0) { - Toggle("Show project progress", isOn: $showProgress) + Toggle("Show project progress", isOn: $store.showProgress) + .accessibilityIdentifier("show-project-progress") .padding(16) Divider() .overlay(theme.separator) .padding(.leading, 16) - Toggle("Keep inspector open", isOn: $keepInspectorOpen) + Toggle("Keep inspector open", isOn: $store.keepInspectorOpen) + .accessibilityIdentifier("keep-inspector-open") .padding(16) } .foregroundStyle(theme.primaryText) } + Text("Session-only data and settings. " + + "Inspector preference applies when opening projects in regular width.") + .font(.footnote) + .foregroundStyle(theme.secondaryText) + StudioSectionHeader(title: "About this template") AdaptiveShellCard { diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioComponents.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioComponents.swift index 009f9be..b4662f6 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioComponents.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioComponents.swift @@ -136,6 +136,7 @@ struct ProjectRow: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize let project: StudioProject + var showProgress = true var body: some View { if dynamicTypeSize.isAccessibilitySize { @@ -157,9 +158,11 @@ struct ProjectRow: View { Spacer(minLength: 8) - Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) - .font(.subheadline.monospacedDigit()) - .foregroundStyle(theme.secondaryText) + if showProgress { + Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(theme.secondaryText) + } } Text("\(project.category) · \(project.dueText)") @@ -167,8 +170,9 @@ struct ProjectRow: View { .foregroundStyle(theme.secondaryText) HStack(spacing: 12) { - ProjectProgressBar(progress: project.progress) - .frame(maxWidth: 170) + if showProgress { + ProjectProgressBar(progress: project.progress).frame(maxWidth: 170) + } ProjectStatusBadge(status: project.status) @@ -212,12 +216,14 @@ struct ProjectRow: View { .foregroundStyle(theme.secondaryText) .fixedSize(horizontal: false, vertical: true) - ProjectProgressBar(progress: project.progress) + if showProgress { ProjectProgressBar(progress: project.progress) } HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) - .font(.subheadline.monospacedDigit()) - .foregroundStyle(theme.secondaryText) + if showProgress { + Text(project.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(theme.secondaryText) + } ProjectStatusBadge(status: project.status) } diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioCoordinator.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioCoordinator.swift new file mode 100644 index 0000000..372d38b --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioCoordinator.swift @@ -0,0 +1,100 @@ +import AdaptiveAppShell +import Foundation +import Observation + +enum StudioDeepLink: Equatable { + case project(String) + case search + case invalid + + static func parse(_ url: URL) -> Self { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "adaptiveshell", + components.user == nil, components.password == nil, components.port == nil, + components.query == nil, components.fragment == nil else { return .invalid } + switch components.host?.lowercased() { + case "project": + let path = components.percentEncodedPath + guard path.hasPrefix("/"), + let id = String(path.dropFirst()).removingPercentEncoding, + !id.isEmpty, !id.contains("/"), + id == id.trimmingCharacters(in: .whitespacesAndNewlines) else { return .invalid } + return .project(id) + case "search" where components.path.isEmpty || components.path == "/": + return .search + default: + return .invalid + } + } +} + +struct StudioNavigationMessage: Identifiable { + let id = UUID() + let title: String + let message: String +} + +@MainActor +@Observable +final class StudioCoordinator { + let store: StudioStore + let state: AdaptiveShellState + private(set) var selectedProjectID: String? + var navigationMessage: StudioNavigationMessage? + + init(store: StudioStore, state: AdaptiveShellState) { + self.store = store + self.state = state + selectedProjectID = store.projects.first?.id + } + + var inspectorProject: StudioProject? { + if let route = state.paths[state.selection]?.last { + switch route { + case .project(let id): + return store.project(id: id) + case .task(let projectID, _): + return store.project(id: projectID) + } + } + return selectedProjectID.flatMap { store.project(id: $0) } + } + + @discardableResult + func openProject( + _ id: String, + from source: StudioTab, + presentation: AdaptiveShellPresentation = .regular, + replacingPath: Bool = false + ) -> Bool { + guard store.project(id: id) != nil else { + navigationMessage = StudioNavigationMessage( + title: "Project not found", message: "This project is not available in this demo session." + ) + return false + } + let tab = presentation == .compact && source.isCollection ? StudioTab.projects : source + selectedProjectID = id + state.select(tab) + if replacingPath { + state.replacePath([.project(id)], in: tab) + } else if state.paths[tab]?.last != .project(id) { + state.push(.project(id), in: tab) + } + if store.keepInspectorOpen { state.isInspectorPresented = true } + return true + } + + func handleDeepLink(_ url: URL) { + switch StudioDeepLink.parse(url) { + case .project(let id): + openProject(id, from: .projects, replacingPath: true) + case .search: + state.select(.search) + case .invalid: + navigationMessage = StudioNavigationMessage( + title: "Invalid link", message: "Use adaptiveshell://project/ or adaptiveshell://search." + ) + } + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioModels.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioModels.swift index a45753f..f5496ec 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioModels.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioModels.swift @@ -12,6 +12,8 @@ enum StudioTab: String, Hashable, CaseIterable, Sendable { case internalWork case archive + var isCollection: Bool { self == .clientWork || self == .internalWork || self == .archive } + static let sections: [AdaptiveShellSection] = [ AdaptiveShellSection( id: "primary", @@ -78,7 +80,7 @@ enum StudioTab: String, Hashable, CaseIterable, Sendable { enum StudioRoute: Hashable, Sendable { case project(String) - case task(String) + case task(projectID: String, taskID: String) } enum StudioSheet: Identifiable { @@ -112,19 +114,29 @@ struct StudioProject: Identifiable, Hashable, Sendable { let name: String let category: String let dueText: String - let progress: Double - let status: StudioProjectStatus + var status: StudioProjectStatus let collection: StudioCollection let systemImage: String let ownerInitials: [String] - let tasks: [StudioTask] + var tasks: [StudioTask] + + var progress: Double { + guard !tasks.isEmpty else { return status == .complete ? 1 : 0 } + return Double(tasks.filter(\.isComplete).count) / Double(tasks.count) + } } struct StudioTask: Identifiable, Hashable, Sendable { let id: String let title: String let dueText: String - let isComplete: Bool + var isComplete: Bool +} + +struct StudioPerson: Identifiable, Hashable, Sendable { + let id: String + let name: String + let role: String } struct StudioActivity: Identifiable, Hashable, Sendable { @@ -136,13 +148,17 @@ struct StudioActivity: Identifiable, Hashable, Sendable { } enum StudioFixtures { + static let people = [ + StudioPerson(id: "AM", name: "Alex Morgan", role: "Design Director"), + StudioPerson(id: "NW", name: "Noah Williams", role: "Art Director"), + StudioPerson(id: "SC", name: "Sam Campbell", role: "Designer") + ] static let projects: [StudioProject] = [ StudioProject( id: "spring-catalogue", name: "Spring catalogue", category: "Brand + Print", dueText: "Due 16 Jul", - progress: 0.75, status: .onTrack, collection: .client, systemImage: "book.closed", @@ -160,7 +176,6 @@ enum StudioFixtures { name: "Website QA", category: "Web", dueText: "Due 22 Jul", - progress: 0.40, status: .onTrack, collection: .client, systemImage: "globe", @@ -176,7 +191,6 @@ enum StudioFixtures { name: "Packaging review", category: "Packaging", dueText: "Due 30 Jul", - progress: 0.20, status: .atRisk, collection: .client, systemImage: "shippingbox", @@ -191,7 +205,6 @@ enum StudioFixtures { name: "Studio handbook", category: "Operations", dueText: "Due 4 Aug", - progress: 0.62, status: .onTrack, collection: .internalWork, systemImage: "text.book.closed", @@ -205,7 +218,6 @@ enum StudioFixtures { name: "Winter campaign", category: "Campaign", dueText: "Completed 2025", - progress: 1.0, status: .complete, collection: .archive, systemImage: "checkmark.seal", @@ -245,7 +257,7 @@ enum StudioFixtures { ) ] - static func project(id: String) -> StudioProject { - projects.first { $0.id == id } ?? projects[0] + static func project(id: String) -> StudioProject? { + projects.first { $0.id == id } } } diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchResults.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchResults.swift new file mode 100644 index 0000000..80d2b69 --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchResults.swift @@ -0,0 +1,45 @@ +import Foundation + +enum StudioSearchScope: String, CaseIterable, Identifiable { + case all = "All" + case projects = "Projects" + case tasks = "Tasks" + case people = "People" + + var id: String { rawValue } +} + +struct StudioTaskResult: Identifiable { + let project: StudioProject + let task: StudioTask + + var id: String { "\(project.id)/\(task.id)" } +} + +struct StudioSearchResults { + let query: String + let projects: [StudioProject] + let tasks: [StudioTaskResult] + let people: [StudioPerson] + + var isEmpty: Bool { projects.isEmpty && tasks.isEmpty && people.isEmpty } + + init(query: String, scope: StudioSearchScope, projects: [StudioProject], people: [StudioPerson]) { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + self.query = query + func matches(_ value: String) -> Bool { + query.isEmpty || value.localizedCaseInsensitiveContains(query) + } + self.projects = scope == .all || scope == .projects + ? projects.filter { matches($0.name) || matches($0.category) } : [] + self.tasks = scope == .all || scope == .tasks + ? projects.flatMap { project in + project.tasks.compactMap { task in + guard matches(task.title) || matches(project.name) else { return nil } + return StudioTaskResult(project: project, task: task) + } + } : [] + self.people = scope == .all || scope == .people + ? people.filter { matches($0.name) || matches($0.role) } : [] + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchView.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchView.swift index a4b0373..bee3854 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchView.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioSearchView.swift @@ -1,78 +1,26 @@ import AdaptiveAppShell import SwiftUI -private enum StudioSearchScope: String, CaseIterable, Identifiable { - case all = "All" - case projects = "Projects" - case tasks = "Tasks" - case people = "People" - - var id: String { rawValue } -} - struct StudioSearchView: View { @Environment(\.adaptiveShellTheme) private var theme @Environment(\.adaptiveShellPresentation) private var presentation let navigation: AdaptiveShellNavigation - @Binding var selectedProjectID: String - - @State private var query = "website" - @State private var scope = StudioSearchScope.all - - private var matchingProjects: [StudioProject] { - StudioFixtures.projects.filter { - query.isEmpty - || $0.name.localizedCaseInsensitiveContains(query) - || $0.category.localizedCaseInsensitiveContains(query) - } - } - - private var matchingTasks: [(StudioProject, StudioTask)] { - StudioFixtures.projects.flatMap { project in - project.tasks.compactMap { task in - let matches = query.isEmpty - || task.title.localizedCaseInsensitiveContains(query) - || project.name.localizedCaseInsensitiveContains(query) - return matches ? (project, task) : nil - } - } - } - - private var showsProjects: Bool { - scope == .all || scope == .projects - } - - private var showsTasks: Bool { - scope == .all || scope == .tasks - } - - private var showsPeople: Bool { - scope == .all || scope == .people - } - - private var personMatches: Bool { - query.isEmpty - || "Sam Campbell".localizedCaseInsensitiveContains(query) - || "Designer".localizedCaseInsensitiveContains(query) - || "Website QA".localizedCaseInsensitiveContains(query) - } + @Bindable var store: StudioStore + let coordinator: StudioCoordinator var body: some View { + let results = store.searchResults ScrollView { LazyVStack(alignment: .leading, spacing: 22) { - if query.isEmpty { - recentProjects - } else if matchingProjects.isEmpty - && matchingTasks.isEmpty - && !(showsPeople && personMatches) { + if results.isEmpty { StudioEmptyState( - title: "No results", - message: "Try another name or a broader search scope.", + title: "No results", message: "Try another name or a broader search scope.", systemImage: "magnifyingglass" ) + .accessibilityIdentifier("search-empty") } else { - resultSections + resultSections(results) } } .frame(maxWidth: 780, alignment: .leading) @@ -84,149 +32,111 @@ struct StudioSearchView: View { .navigationTitle("Search") .navigationBarTitleDisplayMode(.large) .searchable( - text: $query, + text: $store.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: "Projects, tasks, or people" ) - .searchScopes($scope) { + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .searchScopes($store.searchScope, activation: .onSearchPresentation) { ForEach(StudioSearchScope.allCases) { scope in - Text(scope.rawValue) - .tag(scope) + Text(scope.rawValue).tag(scope) } } } @ViewBuilder - private var resultSections: some View { - if showsProjects, !matchingProjects.isEmpty { + private func resultSections(_ results: StudioSearchResults) -> some View { + if !results.projects.isEmpty { StudioSectionHeader(title: "Projects") - AdaptiveShellCard(padding: 0) { VStack(spacing: 0) { - ForEach(matchingProjects) { project in - projectResult(project) - - if project.id != matchingProjects.last?.id { - Divider() - .overlay(theme.separator) - .padding(.leading, 78) + ForEach(results.projects) { project in + Button { + coordinator.openProject(project.id, from: .search, presentation: presentation) + } label: { + ProjectRow(project: project, showProgress: store.showProgress).padding(16) + } + .buttonStyle(.plain) + .accessibilityIdentifier("project-\(project.id)") + if project.id != results.projects.last?.id { + Divider().overlay(theme.separator).padding(.leading, 78) } } } } } - if showsTasks, !matchingTasks.isEmpty { + if !results.tasks.isEmpty { StudioSectionHeader(title: "Tasks") - AdaptiveShellCard(padding: 0) { VStack(spacing: 0) { - ForEach(matchingTasks, id: \.1.id) { project, task in - Button { - navigation.push(.task(task.id)) - } label: { - HStack(spacing: 14) { - Image(systemName: task.isComplete ? "checkmark.square.fill" : "square") - .font(.title3) - .foregroundStyle(task.isComplete ? theme.success : theme.secondaryText) - - VStack(alignment: .leading, spacing: 3) { - highlightedText(task.title) - .font(.body.weight(.medium)) - - Text("\(project.name) · \(task.dueText)") - .font(.subheadline) - .foregroundStyle(theme.secondaryText) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption.weight(.bold)) - .foregroundStyle(theme.secondaryText) - } - .padding(16) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if task.id != matchingTasks.last?.1.id { - Divider() - .overlay(theme.separator) - .padding(.leading, 54) + ForEach(results.tasks) { result in + taskResult(result, query: results.query) + if result.id != results.tasks.last?.id { + Divider().overlay(theme.separator).padding(.leading, 54) } } } } } - if showsPeople, personMatches { + if !results.people.isEmpty { StudioSectionHeader(title: "People") - - AdaptiveShellCard { - HStack(spacing: 14) { - Text("SC") - .font(.headline) - .foregroundStyle(theme.accent) - .frame(width: 46, height: 46) - .background(theme.accent.opacity(0.12), in: Circle()) - - VStack(alignment: .leading, spacing: 3) { - Text("Sam Campbell") - .font(.headline) - .foregroundStyle(theme.primaryText) - - Text("Designer · Website QA") - .font(.subheadline) - .foregroundStyle(theme.secondaryText) - } - - Spacer() - } - .accessibilityElement(children: .combine) + ForEach(results.people) { person in + personResult(person) } } } - private var recentProjects: some View { - Group { - StudioSectionHeader(title: "Recent projects") - - AdaptiveShellCard(padding: 0) { - VStack(spacing: 0) { - ForEach(Array(StudioFixtures.projects.prefix(3))) { project in - projectResult(project) - } + private func personResult(_ person: StudioPerson) -> some View { + AdaptiveShellCard { + HStack(spacing: 14) { + Text(person.id).font(.headline).foregroundStyle(theme.accent) + .frame(width: 46, height: 46) + .background(theme.accent.opacity(0.12), in: Circle()) + VStack(alignment: .leading, spacing: 3) { + Text(person.name).font(.headline).foregroundStyle(theme.primaryText) + Text(person.role).font(.subheadline).foregroundStyle(theme.secondaryText) } + Spacer() } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("search-person-\(person.id)") } } - private func projectResult(_ project: StudioProject) -> some View { + private func taskResult(_ result: StudioTaskResult, query: String) -> some View { Button { - selectedProjectID = project.id - if presentation == .compact { - navigation.push(.project(project.id)) - } + navigation.push(.task(projectID: result.project.id, taskID: result.task.id)) } label: { - ProjectRow(project: project) - .padding(16) + HStack(spacing: 14) { + Image(systemName: result.task.isComplete ? "checkmark.square.fill" : "square") + .font(.title3) + .foregroundStyle(result.task.isComplete ? theme.success : theme.secondaryText) + VStack(alignment: .leading, spacing: 3) { + highlightedText(result.task.title, query: query).font(.body.weight(.medium)) + Text("\(result.project.name) · \(result.task.dueText)") + .font(.subheadline).foregroundStyle(theme.secondaryText) + } + Spacer() + Image(systemName: "chevron.right").font(.caption.weight(.bold)) + .foregroundStyle(theme.secondaryText) + } + .padding(16) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("search-task-\(result.id)") + .accessibilityHint("Opens task details") } - private func highlightedText(_ text: String) -> Text { - guard !query.isEmpty, - let range = text.range(of: query, options: .caseInsensitive) else { + private func highlightedText(_ text: String, query: String) -> Text { + guard !query.isEmpty, let range = text.range(of: query, options: .caseInsensitive) else { return Text(text).foregroundColor(theme.primaryText) } - - let prefix = String(text[.. Bool var body: some View { NavigationStack { Form { Section("Project") { TextField("Name", text: $name) + .accessibilityIdentifier("new-project-name") TextField("Category", text: $category) + .accessibilityIdentifier("new-project-category") DatePicker("Due date", selection: $dueDate, displayedComponents: .date) } Section { - Text("This demo keeps creation local. Replace the action with your own repository or service.") + Text("Session-only demo data. Projects and changes reset when the app closes.") .font(.footnote) .foregroundStyle(theme.secondaryText) } @@ -35,7 +38,7 @@ struct NewProjectSheet: View { ToolbarItem(placement: .confirmationAction) { Button("Create") { - dismiss() + if create(name, category, dueDate) { dismiss() } } .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioStore.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioStore.swift new file mode 100644 index 0000000..7a41980 --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioStore.swift @@ -0,0 +1,60 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class StudioStore { + private(set) var projects = StudioFixtures.projects + var showProgress = true + var keepInspectorOpen = false + var searchQuery = "website" + var searchScope = StudioSearchScope.all + + var searchResults: StudioSearchResults { + StudioSearchResults(query: searchQuery, scope: searchScope, projects: projects, people: StudioFixtures.people) + } + + var upcomingTasks: [StudioTaskResult] { + Array(projects.filter { $0.collection != .archive }.compactMap { project in + project.tasks.first(where: { !$0.isComplete }).map { StudioTaskResult(project: project, task: $0) } + }.prefix(2)) + } + + func project(id: String) -> StudioProject? { + projects.first { $0.id == id } + } + + @discardableResult + func createProject(name: String, category: String, dueDate: Date) -> StudioProject? { + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + let category = category.trimmingCharacters(in: .whitespacesAndNewlines) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "d MMM" + let project = StudioProject( + id: "local-\(UUID().uuidString.lowercased())", + name: name, + category: category.isEmpty ? "General" : category, + dueText: "Due \(formatter.string(from: dueDate))", + status: .onTrack, + collection: .client, + systemImage: "folder", + ownerInitials: ["AM"], + tasks: [] + ) + projects.insert(project, at: 0) + return project + } + + func toggleTask(_ taskID: String, in projectID: String) { + guard let projectIndex = projects.firstIndex(where: { $0.id == projectID }), + let taskIndex = projects[projectIndex].tasks.firstIndex(where: { $0.id == taskID }) else { return } + projects[projectIndex].tasks[taskIndex].isComplete.toggle() + if projects[projectIndex].tasks.allSatisfy(\.isComplete) { + projects[projectIndex].status = .complete + } else if projects[projectIndex].status == .complete { + projects[projectIndex].status = .onTrack + } + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioTabContent.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioTabContent.swift index b54e1db..cbdb147 100644 --- a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioTabContent.swift +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioTabContent.swift @@ -4,7 +4,8 @@ import SwiftUI struct StudioTabContent: View { let tab: StudioTab let navigation: AdaptiveShellNavigation - @Binding var selectedProjectID: String + let store: StudioStore + let coordinator: StudioCoordinator @Binding var themeID: AdaptiveShellThemeID @Binding var presentedSheet: StudioSheet? @@ -13,45 +14,50 @@ struct StudioTabContent: View { switch tab { case .overview: OverviewView( - navigation: navigation, - selectedProjectID: $selectedProjectID + store: store, + coordinator: coordinator ) case .projects: ProjectsView( title: "Projects", - projects: StudioFixtures.projects.filter { $0.collection != .archive }, + projects: store.projects.filter { $0.collection != .archive }, navigation: navigation, - selectedProjectID: $selectedProjectID + store: store, + coordinator: coordinator ) case .activity: ActivityView() case .search: StudioSearchView( navigation: navigation, - selectedProjectID: $selectedProjectID + store: store, + coordinator: coordinator ) case .settings: - SettingsView(themeID: $themeID) + SettingsView(themeID: $themeID, store: store) case .clientWork: ProjectsView( title: "Client work", - projects: StudioFixtures.projects.filter { $0.collection == .client }, + projects: store.projects.filter { $0.collection == .client }, navigation: navigation, - selectedProjectID: $selectedProjectID + store: store, + coordinator: coordinator ) case .internalWork: ProjectsView( title: "Internal", - projects: StudioFixtures.projects.filter { $0.collection == .internalWork }, + projects: store.projects.filter { $0.collection == .internalWork }, navigation: navigation, - selectedProjectID: $selectedProjectID + store: store, + coordinator: coordinator ) case .archive: ProjectsView( title: "Archive", - projects: StudioFixtures.projects.filter { $0.collection == .archive }, + projects: store.projects.filter { $0.collection == .archive }, navigation: navigation, - selectedProjectID: $selectedProjectID + store: store, + coordinator: coordinator ) } } @@ -64,6 +70,7 @@ struct StudioTabContent: View { } label: { Label("New project", systemImage: "plus") } + .accessibilityIdentifier("new-project") Button { presentedSheet = .profile @@ -98,13 +105,21 @@ struct StudioTabContent: View { struct StudioRouteView: View { let route: StudioRoute + let store: StudioStore var body: some View { switch route { case .project(let id): - ProjectDetailView(project: StudioFixtures.project(id: id)) - case .task(let id): - TaskDetailView(taskID: id) + if let project = store.project(id: id) { + ProjectDetailView(project: project, store: store) + } else { + StudioEmptyState( + title: "Project not found", message: "This project is not available in this session.", + systemImage: "folder.badge.questionmark" + ) + } + case .task(let projectID, let taskID): + TaskDetailView(projectID: projectID, taskID: taskID, store: store) } } } diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioThemeTransition.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioThemeTransition.swift new file mode 100644 index 0000000..7cb5c8f --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemo/StudioThemeTransition.swift @@ -0,0 +1,82 @@ +import Observation +import SwiftUI +import UIKit + +@MainActor +final class StudioWindowReference { + weak var window: UIWindow? +} + +struct StudioWindowReader: UIViewRepresentable { + let reference: StudioWindowReference + + func makeUIView(context: Context) -> WindowProbe { + WindowProbe(reference: reference) + } + + func updateUIView(_ uiView: WindowProbe, context: Context) { + reference.window = uiView.window + } + + final class WindowProbe: UIView { + let reference: StudioWindowReference + + init(reference: StudioWindowReference) { + self.reference = reference + super.init(frame: .zero) + isUserInteractionEnabled = false + accessibilityElementsHidden = true + } + + required init?(coder: NSCoder) { return nil } + + override func didMoveToWindow() { + super.didMoveToWindow() + reference.window = window + } + } +} + +@MainActor +enum WindowSnapshot { + static func capture(from window: UIWindow?) -> UIImage? { + guard let window, !window.isHidden, !window.bounds.isEmpty else { return nil } + return UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) + } + } +} + +@MainActor +@Observable +final class StudioThemeTransition { + private(set) var snapshot: UIImage? + private(set) var opacity = 0.0 + @ObservationIgnored private var task: Task? + + func start(in window: UIWindow?) { + cancel() + guard let snapshot = WindowSnapshot.capture(from: window) else { return } + self.snapshot = snapshot + opacity = 1 + task = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .milliseconds(70)) + guard let self else { return } + withAnimation(.easeInOut(duration: 0.52)) { self.opacity = 0 } + try await Task.sleep(for: .milliseconds(560)) + self.snapshot = nil + self.task = nil + } catch { + return + } + } + } + + func cancel() { + task?.cancel() + task = nil + snapshot = nil + opacity = 0 + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioRegressionTests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioRegressionTests.swift new file mode 100644 index 0000000..0b80901 --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioRegressionTests.swift @@ -0,0 +1,9 @@ +import XCTest +@testable import AdaptiveShellDemo + +final class StudioRegressionTests: XCTestCase { + func testUnknownFixtureNeverResolvesToAnotherProject() { + let project = StudioFixtures.project(id: "does-not-exist") + XCTAssertNil(project) + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioWindowTests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioWindowTests.swift new file mode 100644 index 0000000..bf273dc --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioWindowTests.swift @@ -0,0 +1,39 @@ +import UIKit +import XCTest +@testable import AdaptiveShellDemo + +@MainActor +final class StudioWindowTests: XCTestCase { + func testMissingWindowDoesNotCaptureAnUnrelatedScene() { + XCTAssertNil(WindowSnapshot.capture(from: nil)) + } + + func testTransitionCancellationReleasesSnapshotAndStopsDelayedWork() async throws { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.first as? UIWindowScene) + let window = UIWindow(windowScene: scene) + window.rootViewController = UIViewController() + window.isHidden = false + defer { window.isHidden = true } + let transition = StudioThemeTransition() + transition.start(in: window) + XCTAssertNotNil(transition.snapshot) + transition.cancel() + XCTAssertNil(transition.snapshot) + try await Task.sleep(for: .milliseconds(750)) + XCTAssertNil(transition.snapshot) + XCTAssertEqual(transition.opacity, 0) + } + + func testSnapshotCapturesItsHostingWindowEvenWhenAnotherWindowIsKey() throws { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.first as? UIWindowScene) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: 160, height: 120) + window.rootViewController = UIViewController() + window.rootViewController?.view.backgroundColor = .blue + window.isHidden = false + defer { window.isHidden = true } + let snapshot = try XCTUnwrap(WindowSnapshot.capture(from: window)) + XCTAssertEqual(snapshot.size, window.bounds.size) + XCTAssertFalse(window.isKeyWindow) + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioWorkflowTests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioWorkflowTests.swift new file mode 100644 index 0000000..8e9ca2e --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoTests/StudioWorkflowTests.swift @@ -0,0 +1,125 @@ +import AdaptiveAppShell +import XCTest +@testable import AdaptiveShellDemo + +@MainActor +final class StudioWorkflowTests: XCTestCase { + func testScopedSearchUsesVisibleResultsForItsEmptyCondition() { + let store = StudioStore() + store.searchQuery = " spring " + store.searchScope = .people + XCTAssertTrue(store.searchResults.isEmpty) + XCTAssertEqual(store.searchResults.people.count, 0) + store.searchScope = .projects + XCTAssertEqual(store.searchResults.projects.map(\.id), ["spring-catalogue"]) + } + + func testEveryEmptyQueryScopeIncludesOnlyItsOwnResults() { + let store = StudioStore() + store.searchQuery = " \n " + for scope in StudioSearchScope.allCases { + store.searchScope = scope + let result = store.searchResults + XCTAssertFalse(result.isEmpty, scope.rawValue) + XCTAssertEqual(result.projects.isEmpty, scope == .tasks || scope == .people) + XCTAssertEqual(result.tasks.isEmpty, scope == .projects || scope == .people) + XCTAssertEqual(result.people.isEmpty, scope == .projects || scope == .tasks) + } + } + + func testSearchTrimsQueryAndIgnoresCase() { + let store = StudioStore() + store.searchQuery = " WEBSITE " + XCTAssertEqual(store.searchResults.projects.map(\.id), ["website-qa"]) + store.searchScope = .tasks + XCTAssertEqual(store.searchResults.tasks.map(\.task.id), ["forms", "mobile", "launch"]) + store.searchScope = .people + store.searchQuery = " sAm " + XCTAssertEqual(store.searchResults.people.map(\.id), ["SC"]) + for scope in StudioSearchScope.allCases { + store.searchScope = scope + store.searchQuery = "no-such-result" + XCTAssertTrue(store.searchResults.isEmpty) + } + } + + func testCreationRejectsBlankNamesAndPreservesStableIdentity() throws { + let store = StudioStore() + let count = store.projects.count + XCTAssertNil(store.createProject(name: " \n ", category: "", dueDate: .now)) + XCTAssertEqual(store.projects.count, count) + let project = try XCTUnwrap(store.createProject(name: " Local project ", category: " Web ", dueDate: .now)) + XCTAssertEqual(project.name, "Local project") + XCTAssertEqual(project.category, "Web") + XCTAssertEqual(store.project(id: project.id)?.id, project.id) + store.searchQuery = "local PROJECT" + XCTAssertEqual(store.searchResults.projects.map(\.id), [project.id]) + } + + func testTaskCompletionUpdatesTheSharedProjectAndProgress() throws { + let store = StudioStore() + XCTAssertEqual(store.project(id: "spring-catalogue")?.progress, 0.4) + XCTAssertEqual(store.upcomingTasks.first?.task.id, "images") + store.toggleTask("images", in: "spring-catalogue") + let project = try XCTUnwrap(store.project(id: "spring-catalogue")) + XCTAssertEqual(project.progress, 0.6) + XCTAssertEqual(store.upcomingTasks.first?.task.id, "proof") + XCTAssertTrue(try XCTUnwrap(project.tasks.first { $0.id == "images" }).isComplete) + XCTAssertEqual(store.project(id: "website-qa")?.progress, 0) + store.toggleTask("missing", in: "spring-catalogue") + XCTAssertEqual(store.project(id: "spring-catalogue")?.progress, 0.6) + } + + func testProjectOpeningUsesVisiblePathsRegardlessOfInspectorOrPresentation() { + for presentation in [AdaptiveShellPresentation.compact, .regular] { + let store = StudioStore() + let state = AdaptiveShellState(selection: .overview) + let coordinator = StudioCoordinator(store: store, state: state) + for source in [StudioTab.overview, .projects, .search] { + XCTAssertTrue(coordinator.openProject("website-qa", from: source, presentation: presentation)) + XCTAssertEqual(state.selection, source) + XCTAssertEqual(state.paths[source]?.last, .project("website-qa")) + XCTAssertFalse(state.isInspectorPresented) + } + store.keepInspectorOpen = true + coordinator.openProject("spring-catalogue", from: .projects, presentation: presentation) + XCTAssertTrue(state.isInspectorPresented) + XCTAssertEqual(state.paths[.overview], [.project("website-qa")]) + } + } + + func testKnownRepeatedLinksReplaceOnlyTheirOwnPathAndUnknownLinksPreserveHistory() { + let store = StudioStore() + let state = AdaptiveShellState( + selection: .overview, paths: [.overview: [.project("website-qa")]] + ) + let coordinator = StudioCoordinator(store: store, state: state) + let known = URL(string: "adaptiveshell://project/spring-catalogue")! + coordinator.handleDeepLink(known) + coordinator.handleDeepLink(known) + XCTAssertEqual(state.paths[.projects], [.project("spring-catalogue")]) + XCTAssertEqual(state.paths[.overview], [.project("website-qa")]) + coordinator.handleDeepLink(URL(string: "adaptiveshell://project/does-not-exist")!) + XCTAssertEqual(state.paths[.projects], [.project("spring-catalogue")]) + XCTAssertEqual(coordinator.selectedProjectID, "spring-catalogue") + XCTAssertEqual(coordinator.navigationMessage?.title, "Project not found") + coordinator.handleDeepLink(URL(string: "adaptiveshell://project/spring-catalogue/extra")!) + XCTAssertEqual(state.paths[.projects], [.project("spring-catalogue")]) + XCTAssertEqual(coordinator.navigationMessage?.title, "Invalid link") + } + + func testDeepLinkParserRejectsAmbiguousURLsBeforeNavigation() { + let malformed = [ + "https://project/spring-catalogue", "adaptiveshell://project", "adaptiveshell://project/", + "adaptiveshell://project/spring-catalogue/extra", "adaptiveshell://project//spring-catalogue", + "adaptiveshell://project/spring%2Fcatalogue", "adaptiveshell://project/spring-catalogue?extra=1", + "adaptiveshell://project/spring-catalogue#part", "adaptiveshell://user@project/spring-catalogue", + "adaptiveshell://project:123/spring-catalogue", "adaptiveshell://search/extra" + ] + for value in malformed { + XCTAssertEqual(StudioDeepLink.parse(URL(string: value)!), .invalid, value) + } + XCTAssertEqual(StudioDeepLink.parse(URL(string: "adaptiveshell://search")!), .search) + XCTAssertEqual(StudioDeepLink.parse(URL(string: "adaptiveshell://project/website-qa")!), .project("website-qa")) + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioDataUITests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioDataUITests.swift new file mode 100644 index 0000000..62a2602 --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioDataUITests.swift @@ -0,0 +1,80 @@ +import UIKit +import XCTest + +@MainActor +final class StudioDataUITests: XCTestCase { + func testBlankAndCancelledCreationDoNotCreateThenValidProjectAppearsInListAndSearch() { + let app = launchStudio() + app.buttons["new-project"].tap() + XCTAssertFalse(app.buttons["Create"].isEnabled) + app.textFields["new-project-name"].tap() + app.textFields["new-project-name"].typeText(" ") + XCTAssertFalse(app.buttons["Create"].isEnabled) + app.buttons["Cancel"].tap() + app.buttons["new-project"].tap() + app.textFields["new-project-name"].tap() + app.textFields["new-project-name"].typeText("Created locally") + app.buttons["Create"].tap() + XCTAssertTrue(app.navigationBars["Created locally"].waitForExistence(timeout: 5)) + app.navigationBars["Created locally"].buttons.firstMatch.tap() + let created = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Created locally")).firstMatch + XCTAssertTrue(created.waitForExistence(timeout: 5)) + selectStudioTab("Search", in: app) + replaceSearchQuery("created locally", in: app) + let searchResult = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Created locally")).firstMatch + XCTAssertTrue(searchResult.waitForExistence(timeout: 5)) + searchResult.tap() + XCTAssertTrue(app.navigationBars["Created locally"].exists) + } + + func testTaskChangesUpdateProgressAndSettingsHaveVisibleEffects() { + defer { XCUIDevice.shared.orientation = .portrait } + let app = launchStudio(inspectorColumn: true) + app.buttons["project-spring-catalogue"].tap() + XCTAssertEqual(app.staticTexts["detail-progress"].label, "40%") + let task = app.buttons["task-spring-catalogue-images"] + XCTAssertEqual(task.value as? String, "Incomplete") + task.tap() + XCTAssertEqual(task.value as? String, "Complete") + XCTAssertEqual(app.staticTexts["detail-progress"].label, "60%") + selectStudioTab("Settings", in: app) + toggleStudioSetting("show-project-progress", in: app) + XCTAssertEqual(app.switches["show-project-progress"].value as? String, "0") + selectStudioTab("Overview", in: app) + XCTAssertFalse(app.staticTexts["detail-progress"].exists) + XCTAssertEqual(app.buttons["task-spring-catalogue-images"].value as? String, "Complete") + selectStudioTab("Settings", in: app) + toggleStudioSetting("keep-inspector-open", in: app) + XCTAssertEqual(app.switches["keep-inspector-open"].value as? String, "1") + selectStudioTab("Projects", in: app) + app.buttons["project-website-qa"].tap() + XCTAssertTrue(app.navigationBars["Website QA"].exists) + } + + func testInspectorReadsTheSameSelectedProjectOwnersTasksAndProgress() throws { + guard UIDevice.current.userInterfaceIdiom == .pad else { + throw XCTSkip("This test requires the independently selected regular iPad destination.") + } + defer { XCUIDevice.shared.orientation = .portrait } + let app = launchStudio(["--inspector"]) + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertTrue(app.buttons["adaptive-shell-inspector-toggle"].waitForExistence(timeout: 10)) + app.buttons["project-spring-catalogue"].tap() + XCTAssertTrue(app.buttons["adaptive-shell-inspector-toggle"].waitForExistence(timeout: 5)) + let progress = app.staticTexts["inspector-progress"] + XCTAssertTrue(progress.waitForExistence(timeout: 5)) + XCTAssertTrue(progress.isHittable) + let frame = app.windows.firstMatch.frame + XCTAssertGreaterThan(frame.width, frame.height) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.lifetime = .keepAlways + add(screenshot) + app.buttons["task-spring-catalogue-images"].tap() + XCTAssertEqual(app.staticTexts["inspector-progress"].label, "60%") + selectStudioTab("Projects", in: app) + app.buttons["project-website-qa"].tap() + XCTAssertTrue(app.staticTexts["Sam Campbell"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["Noah Williams"].exists) + XCTAssertTrue(app.staticTexts["Test contact forms · 18 Jul"].exists) + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioNavigationUITests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioNavigationUITests.swift new file mode 100644 index 0000000..58f7713 --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioNavigationUITests.swift @@ -0,0 +1,117 @@ +import UIKit +import XCTest + +@MainActor +final class StudioNavigationUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testUpcomingProjectOpensItsVisibleDetail() { + let app = launchStudio() + let upcoming = app.buttons["upcoming-project-spring-catalogue"] + if !upcoming.isHittable { app.swipeUp() } + XCTAssertTrue(upcoming.waitForExistence(timeout: 5)) + upcoming.tap() + XCTAssertTrue(app.navigationBars["Spring catalogue"].waitForExistence(timeout: 5)) + } + + func testProjectsAndSearchOpenCorrectDetailsWithInspectorInitiallyClosed() { + let app = launchStudio() + let inspector = app.buttons["adaptive-shell-inspector-toggle"] + if inspector.exists { + closeStudioInspector(in: app) + XCTAssertEqual(inspector.label, "Show inspector") + XCTAssertFalse(app.staticTexts["inspector-progress"].isHittable) + } + selectStudioTab("Projects", in: app) + app.buttons["project-packaging-review"].tap() + XCTAssertTrue(app.navigationBars["Packaging review"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["inspector-progress"].isHittable) + selectStudioTab("Search", in: app) + app.buttons["project-website-qa"].tap() + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 5)) + selectStudioTab("Projects", in: app) + XCTAssertTrue(app.navigationBars["Packaging review"].exists) + } + + func testProjectOpeningWithInspectorInitiallyOpen() { + defer { XCUIDevice.shared.orientation = .portrait } + let app = launchStudio(["--inspector"], inspectorColumn: true) + if UIDevice.current.userInterfaceIdiom == .pad { + XCTAssertTrue(app.staticTexts["inspector-progress"].isHittable) + } + app.buttons["project-spring-catalogue"].tap() + XCTAssertTrue(app.navigationBars["Spring catalogue"].waitForExistence(timeout: 5)) + selectStudioTab("Search", in: app) + app.buttons["project-website-qa"].tap() + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 5)) + if UIDevice.current.userInterfaceIdiom == .pad { + verifyPortraitInspectorLifecycle() + } + } + + private func verifyPortraitInspectorLifecycle() { + XCUIDevice.shared.orientation = .portrait + let app = launchStudio(["--inspector"]) + let frame = app.windows.firstMatch.frame + XCTAssertLessThan(frame.width, frame.height) + let initialInspector = app.scrollViews["project-inspector-spring-catalogue"] + XCTAssertTrue(initialInspector.waitForExistence(timeout: 5)) + XCTAssertTrue(initialInspector.isHittable) + closeStudioInspector(in: app) + let project = app.buttons["project-website-qa"] + XCTAssertTrue(project.isHittable) + project.tap() + let updatedInspector = app.scrollViews["project-inspector-website-qa"] + XCTAssertTrue(updatedInspector.waitForExistence(timeout: 5)) + XCTAssertTrue(updatedInspector.isHittable) + XCTAssertTrue(app.staticTexts["Sam Campbell"].isHittable) + XCTAssertFalse(app.staticTexts["Noah Williams"].isHittable) + closeStudioInspector(in: app) + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 5)) + } + + func testColdKnownDeepLinkOpensOneCorrectDestination() { + let known = URL(string: "adaptiveshell://project/website-qa")! + let app = openStudioURL(known) + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 15)) + if app.buttons["inspector-close"].isHittable { closeStudioInspector(in: app) } + let back = app.navigationBars["Website QA"].buttons.matching( + NSPredicate(format: "identifier == %@ OR label == %@", "BackButton", "Projects") + ).firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 5)) + XCTAssertTrue(back.isHittable) + back.tap() + XCTAssertTrue(app.navigationBars["Projects"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["project-website-qa"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.navigationBars["Website QA"].exists) + } + + func testColdUnknownAndMalformedDeepLinksDoNotOpenAnotherProject() { + let app = openStudioURL(URL(string: "adaptiveshell://project/does-not-exist")!) + XCTAssertTrue(app.alerts["Project not found"].waitForExistence(timeout: 15)) + app.alerts.buttons["OK"].tap() + XCTAssertTrue(app.navigationBars["Overview"].waitForExistence(timeout: 5)) + _ = openStudioURL(URL(string: "adaptiveshell://project/spring-catalogue/extra")!) + XCTAssertTrue(app.alerts["Invalid link"].waitForExistence(timeout: 15)) + app.alerts.buttons["OK"].tap() + XCTAssertTrue(app.navigationBars["Overview"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.navigationBars["Spring catalogue"].exists) + } + + func testAccessibilityTextKeepsProjectAndCreationControlsReachable() { + let app = launchStudio(["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]) + let project = app.buttons["project-spring-catalogue"] + XCTAssertTrue(project.waitForExistence(timeout: 5)) + XCTAssertTrue(project.label.contains("Spring catalogue")) + project.tap() + XCTAssertTrue(app.buttons["task-spring-catalogue-cover"].waitForExistence(timeout: 5)) + app.navigationBars["Spring catalogue"].buttons.firstMatch.tap() + app.buttons["new-project"].tap() + XCTAssertTrue(app.textFields["new-project-name"].isHittable) + XCTAssertTrue(app.buttons["Cancel"].isHittable) + app.buttons["Cancel"].tap() + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioSearchUITests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioSearchUITests.swift new file mode 100644 index 0000000..c56db08 --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioSearchUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +@MainActor +final class StudioSearchUITests: XCTestCase { + func testPeopleScopeShowsEmptyStateAndRetainsQueryAcrossRotationAndTabs() { + defer { XCUIDevice.shared.orientation = .portrait } + let app = launchStudio(["--search"]) + replaceSearchQuery("spring", in: app) + app.buttons["People"].tap() + XCTAssertTrue(app.staticTexts["No results"].waitForExistence(timeout: 5)) + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertEqual(app.searchFields.firstMatch.value as? String, "spring") + XCTAssertTrue(app.staticTexts["No results"].exists) + selectStudioTab("Overview", in: app) + selectStudioTab("Search", in: app) + XCTAssertEqual(app.searchFields.firstMatch.value as? String, "spring") + XCTAssertTrue(app.staticTexts["No results"].exists) + } + + func testTaskSearchOpensAnEditableTaskAndPeopleSearchShowsNamedPerson() { + let app = launchStudio(["--search"]) + replaceSearchQuery("forms", in: app) + app.buttons["Tasks"].tap() + app.buttons["search-task-website-qa/forms"].tap() + XCTAssertTrue(app.buttons["task-detail-toggle"].waitForExistence(timeout: 5)) + app.buttons["task-detail-toggle"].tap() + XCTAssertEqual(app.buttons["task-detail-toggle"].value as? String, "Complete") + app.navigationBars["Task"].buttons.firstMatch.tap() + replaceSearchQuery("sam", in: app) + app.buttons["People"].tap() + XCTAssertTrue(app.descendants(matching: .any)["search-person-SC"].waitForExistence(timeout: 5)) + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioTestSupport.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioTestSupport.swift new file mode 100644 index 0000000..591925f --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioTestSupport.swift @@ -0,0 +1,96 @@ +import UIKit +import XCTest + +@MainActor +extension XCTestCase { + func openStudioURL(_ url: URL) -> XCUIApplication { + continueAfterFailure = false + let app = XCUIApplication() + app.terminate() + let sender = XCUIApplication(bundleIdentifier: "dev.liquidglasstemplates.adaptiveshell.adaptation") + sender.launchArguments = ["--open-url", url.absoluteString] + sender.launch() + sender.buttons["open-demo-link"].tap() + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let open = springboard.buttons.matching( + NSPredicate(format: "label IN %@", ["Open", "Открыть"]) + ).firstMatch + if open.waitForExistence(timeout: 3) { open.tap() } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + return app + } + + func launchStudio(_ arguments: [String] = [], inspectorColumn: Bool = false) -> XCUIApplication { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = arguments + app.launch() + XCTAssertTrue(app.buttons["new-project"].waitForExistence(timeout: 10)) + if inspectorColumn && UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + let landscape = XCTNSPredicateExpectation( + predicate: NSPredicate { object, _ in + guard let window = object as? XCUIElement else { return false } + return window.frame.width > window.frame.height + }, + object: app.windows.firstMatch + ) + wait(for: [landscape], timeout: 10) + let frame = app.windows.firstMatch.frame + XCTAssertGreaterThan(frame.width, frame.height) + } + if !arguments.contains("--inspector") && app.buttons["inspector-close"].isHittable { + closeStudioInspector(in: app) + } + return app + } + + func selectStudioTab(_ title: String, in app: XCUIApplication) { + let tab = app.tabBars.buttons[title] + if tab.exists { + tab.tap() + } else { + let sidebar = app.buttons[title].firstMatch + if !sidebar.isHittable { + let showSidebar = app.buttons["Show Sidebar"] + if showSidebar.exists { showSidebar.tap() } + } + XCTAssertTrue(sidebar.waitForExistence(timeout: 5), title) + sidebar.tap() + } + } + + func closeStudioInspector(in app: XCUIApplication) { + let toggle = app.buttons["adaptive-shell-inspector-toggle"] + guard toggle.waitForExistence(timeout: 5) else { + XCTFail("The regular window must expose its inspector control.") + return + } + let close = app.buttons["inspector-close"] + if toggle.label == "Hide inspector" || close.isHittable { + XCTAssertTrue(close.waitForExistence(timeout: 5)) + XCTAssertTrue(close.isHittable) + close.tap() + let hidden = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "hittable == false"), object: close + ) + wait(for: [hidden], timeout: 5) + } + XCTAssertEqual(toggle.label, "Show inspector") + } + + func replaceSearchQuery(_ query: String, in app: XCUIApplication) { + let field = app.searchFields.firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 5)) + field.tap() + let value = field.value as? String ?? "" + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count) + query) + field.typeText("\n") + } + + func toggleStudioSetting(_ identifier: String, in app: XCUIApplication) { + let row = app.switches[identifier] + let control = row.switches.firstMatch + if control.exists { control.tap() } else { row.tap() } + } +} diff --git a/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioUITests.swift b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioUITests.swift new file mode 100644 index 0000000..aebf75e --- /dev/null +++ b/Examples/AdaptiveShellDemo/AdaptiveShellDemoUITests/StudioUITests.swift @@ -0,0 +1,25 @@ +import XCTest + +@MainActor +final class StudioUITests: XCTestCase { + func testOverviewProjectOpensVisibleDetail() { + let app = XCUIApplication() + app.launch() + let project = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Spring catalogue")).firstMatch + XCTAssertTrue(project.waitForExistence(timeout: 10)) + project.tap() + XCTAssertTrue(app.navigationBars["Spring catalogue"].waitForExistence(timeout: 5)) + } + + func testCreationOpensTheEnteredProject() { + let app = XCUIApplication() + app.launch() + app.buttons["New project"].tap() + let field = app.textFields["Name"] + XCTAssertTrue(field.waitForExistence(timeout: 5)) + field.tap() + field.typeText("Session project") + app.buttons["Create"].tap() + XCTAssertTrue(app.navigationBars["Session project"].waitForExistence(timeout: 5)) + } +} diff --git a/Examples/AdaptiveShellDemo/README.md b/Examples/AdaptiveShellDemo/README.md index a7024b1..6b0fc7f 100644 --- a/Examples/AdaptiveShellDemo/README.md +++ b/Examples/AdaptiveShellDemo/README.md @@ -1,16 +1,25 @@ # AdaptiveShellDemo -This application demonstrates the package in a realistic studio workspace. +A sample Studio workspace with real session-only local interactions. No account or network connection is required. Each window owns its own store; data and settings reset when that window session ends. -Open `AdaptiveShellDemo.xcodeproj`, choose the `AdaptiveShellDemo` scheme, and run on an iPhone or iPad simulator. +Open `AdaptiveShellDemo.xcodeproj`, select the shared `AdaptiveShellDemo` scheme and run on an iPhone or iPad simulator. The scheme includes model, hosted window and UI tests. -Launch arguments used by the capture script: +## Walkthrough -- `--showcase` -- `--search` -- `--graphite` -- `--stone` -- `--inspector` -- `--profile` +1. Open **Spring catalogue** from Overview. The project opens in a detail stack on iPhone and iPad, including when the inspector starts closed. +2. Toggle **Image selection**. Completed tasks change from 2/5 to 3/5, and project progress changes from 40% to 60%. The inspector reads the same tasks and progress, with that project's owners and next unfinished deadline. +3. Return to a root screen and choose **New project**. Empty or whitespace-only names cannot be created. Enter a name/category/date and choose **Create**: the new record appears in Projects and opens immediately. **Cancel** discards the form. +4. Open Search and find that name. Scopes are All, Projects, Tasks and People. Empty or whitespace queries show all available records in the chosen scope; text queries ignore case. People match name/role. `spring` in People shows **No results**. Task results open an editable task qualified by its project ID. +5. In Settings, disable **Show project progress**: cards, detail and inspector hide their percentage/bar. Enable **Keep inspector open**: opening a project presents its inspector in regular width. The toolbar can still hide it; the next project opening presents it again. Disabling the setting closes the inspector. Compact width retains the preference for later regular presentation. +6. Open `adaptiveshell://project/website-qa`. It selects Projects and replaces only its path with one correct destination. Repeat the URL to get the same one-level path. Unknown IDs or malformed links show a message and leave valid history intact. `adaptiveshell://search` selects Search. +7. Change tabs, rotate, or adapt the window. Route arrays, tasks and app-owned search query/scope retain their context. Child form `@State` is not a general restoration contract; persistent storage remains an app integration choice. -The demo data is deterministic and does not require a network connection or account. +## Capture and test arguments + +- `--showcase`: cancellable theme/navigation walkthrough. +- `--search`: start on Search. +- `--graphite` / `--stone`: initial theme. +- `--inspector`: start with the regular inspector preference enabled. +- `--profile`: present the sample profile. + +The fixture names, people and activity are illustrative. New project IDs are stable UUIDs for the session. [Validation](../../Docs/Validation.md) separates simulator evidence from physical VoiceOver, keyboard, Stage Manager and adoption checks. diff --git a/Examples/AdaptiveShellDemo/StudioAdaptationHost/StudioAdaptationApp.swift b/Examples/AdaptiveShellDemo/StudioAdaptationHost/StudioAdaptationApp.swift new file mode 100644 index 0000000..968fc9c --- /dev/null +++ b/Examples/AdaptiveShellDemo/StudioAdaptationHost/StudioAdaptationApp.swift @@ -0,0 +1,42 @@ +import SwiftUI +import UIKit + +@main +struct StudioAdaptationApp: App { + var body: some Scene { + WindowGroup { + if let url = launchURL { + Button("Open demo link") { UIApplication.shared.open(url) } + .accessibilityIdentifier("open-demo-link") + } else { + StudioAdaptationHarness() + } + } + } + + private var launchURL: URL? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "--open-url"), index + 1 < arguments.count else { return nil } + return URL(string: arguments[index + 1]) + } +} + +private struct StudioAdaptationHarness: View { + @State private var compact = true + + var body: some View { + DemoRootView() + .environment(\.horizontalSizeClass, compact ? .compact : .regular) + .safeAreaInset(edge: .bottom) { + HStack { + Text(compact ? "Compact presentation" : "Regular presentation") + .accessibilityIdentifier("adaptation-mode") + Spacer() + Button(compact ? "Use regular" : "Use compact") { compact.toggle() } + } + .font(.caption) + .padding(12) + .background(.regularMaterial) + } + } +} diff --git a/Examples/AdaptiveShellDemo/StudioAdaptationUITests/StudioAdaptationUITests.swift b/Examples/AdaptiveShellDemo/StudioAdaptationUITests/StudioAdaptationUITests.swift new file mode 100644 index 0000000..74df5b3 --- /dev/null +++ b/Examples/AdaptiveShellDemo/StudioAdaptationUITests/StudioAdaptationUITests.swift @@ -0,0 +1,60 @@ +import UIKit +import XCTest + +@MainActor +final class StudioAdaptationUITests: XCTestCase { + func testSearchContextResultsAndDestinationSurviveBothPresentationReplacements() throws { + continueAfterFailure = false + guard UIDevice.current.userInterfaceIdiom == .pad else { + throw XCTSkip("Regular replacement needs the independently selected wide iPad test host.") + } + let app = XCUIApplication() + app.launchArguments = ["--search"] + app.launch() + replaceQuery("spring", in: app) + app.buttons["People"].tap() + XCTAssertTrue(app.staticTexts["No results"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.tabBars.buttons["Search"].exists) + + app.buttons["Use regular"].tap() + XCTAssertEqual(app.staticTexts["adaptation-mode"].label, "Regular presentation") + XCTAssertFalse(app.tabBars.firstMatch.exists) + assertPeopleContext(in: app) + app.buttons["Use compact"].tap() + XCTAssertEqual(app.staticTexts["adaptation-mode"].label, "Compact presentation") + XCTAssertTrue(app.tabBars.buttons["Search"].exists) + assertPeopleContext(in: app) + + app.buttons["All"].tap() + replaceQuery("website", in: app) + app.buttons["project-website-qa"].tap() + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 5)) + app.buttons["Use regular"].tap() + XCTAssertFalse(app.tabBars.firstMatch.exists) + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 5)) + app.buttons["Use compact"].tap() + XCTAssertTrue(app.tabBars.firstMatch.exists) + XCTAssertTrue(app.navigationBars["Website QA"].waitForExistence(timeout: 5)) + app.navigationBars["Website QA"].buttons.firstMatch.tap() + XCTAssertEqual(app.searchFields.firstMatch.value as? String, "website") + XCTAssertTrue(app.buttons["project-website-qa"].exists) + } + + private func assertPeopleContext(in app: XCUIApplication) { + let field = app.searchFields.firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 5)) + XCTAssertEqual(field.value as? String, "spring") + field.tap() + field.typeText("\n") + XCTAssertTrue(app.buttons["People"].isSelected) + XCTAssertTrue(app.staticTexts["No results"].exists) + } + + private func replaceQuery(_ query: String, in app: XCUIApplication) { + let field = app.searchFields.firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 5)) + field.tap() + let value = field.value as? String ?? "" + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count) + query + "\n") + } +} diff --git a/Examples/AdaptiveShellDemo/project.yml b/Examples/AdaptiveShellDemo/project.yml index e7f2558..72baa89 100644 --- a/Examples/AdaptiveShellDemo/project.yml +++ b/Examples/AdaptiveShellDemo/project.yml @@ -64,12 +64,76 @@ targets: CURRENT_PROJECT_VERSION: "1" GENERATE_INFOPLIST_FILE: NO INFOPLIST_FILE: Info.plist - MARKETING_VERSION: "1.0.3" + MARKETING_VERSION: "1.0.4" PRODUCT_BUNDLE_IDENTIFIER: dev.liquidglasstemplates.adaptiveshell.demo PRODUCT_NAME: "$(TARGET_NAME)" SUPPORTED_PLATFORMS: "iphoneos iphonesimulator" SUPPORTS_MACCATALYST: NO TARGETED_DEVICE_FAMILY: "1,2" - scheme: - configVariants: [] - gatherCoverageData: false + AdaptiveShellDemoTests: + type: bundle.unit-test + platform: iOS + sources: + - path: AdaptiveShellDemoTests + dependencies: + - target: AdaptiveShellDemo + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.liquidglasstemplates.adaptiveshell.demo.tests + StudioAdaptationHost: + type: application + platform: iOS + sources: + - path: AdaptiveShellDemo + excludes: + - AdaptiveShellDemoApp.swift + - path: StudioAdaptationHost + dependencies: + - package: AdaptiveAppShell + product: AdaptiveAppShell + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.liquidglasstemplates.adaptiveshell.adaptation + TARGETED_DEVICE_FAMILY: "1,2" + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + StudioAdaptationUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: StudioAdaptationUITests + dependencies: + - target: StudioAdaptationHost + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.liquidglasstemplates.adaptiveshell.adaptation.uitests + AdaptiveShellDemoUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: AdaptiveShellDemoUITests + dependencies: + - target: AdaptiveShellDemo + - target: StudioAdaptationHost + embed: false + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: dev.liquidglasstemplates.adaptiveshell.demo.uitests + TEST_TARGET_NAME: AdaptiveShellDemo + +schemes: + AdaptiveShellDemo: + build: + targets: + AdaptiveShellDemo: all + StudioAdaptationHost: [test] + test: + gatherCoverageData: true + targets: + - AdaptiveShellDemoTests + - AdaptiveShellDemoUITests + - StudioAdaptationUITests diff --git a/README.md b/README.md index 9484cca..fe5cc34 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Part of the [Apple Design Templates](https://github.com/mikonyaa/Apple-Design-Te | iPad · Stone | iPad · Classic with Inspector | | --- | --- | -| ![Stone iPad sidebar](Assets/Screenshots/ipad-stone.png) | ![Classic iPad inspector](Assets/Screenshots/ipad-landscape-inspector.png) | +| ![Stone iPad sidebar](Assets/Screenshots/ipad-stone.png) | ![Classic iPad inspector](Assets/Screenshots/ipad-inspector.png) | ## What this solves @@ -28,7 +28,8 @@ Part of the [Apple Design Templates](https://github.com/mikonyaa/Apple-Design-Te - Optional contextual inspector without duplicating feature state - Independent `NavigationStack` path for every destination - Sidebar-only collections that do not overload the iPhone tab bar -- Enum-based routes with deterministic deep-link path replacement +- App-owned typed routes and deterministic path replacement +- Reactive section changes with predictable selection reconciliation - Native Liquid Glass system chrome on iOS 26 - Opaque, readable fallback chrome on iOS 17–25 - Dynamic Type, VoiceOver, Increase Contrast, and Reduce Transparency support @@ -38,12 +39,12 @@ The package contains the reusable shell only. The studio workspace in `Examples` ## Requirements -- Xcode 26 or newer +- Xcode 16.4 or newer - Swift 6 - iOS 17 or newer - macOS 14 or newer for package consumers -The source supports iOS 17, but Xcode 26 is required to compile the guarded iOS 26 Liquid Glass APIs. +Xcode 16.4 builds the compatibility branch. Xcode 26 or newer also compiles the guarded native Liquid Glass APIs. The deployment floors remain iOS 17 and macOS 14. ## Run the demo @@ -72,7 +73,7 @@ Add the package in Xcode using its GitHub URL, or declare it in `Package.swift`: ```swift .package( url: "https://github.com/mikonyaa/AdaptiveAppShell.git", - from: "1.0.2" + from: "1.0.3" ) ``` @@ -157,6 +158,8 @@ struct RootView: View { Use two to five items with `.tab` compact placement. Extra regular-width destinations belong in sidebar sections with `.hidden` compact placement. +The shell reconciles additions, removals, reordering and compact placement changes. It keeps a valid selection, otherwise chooses the first visible item in configuration order. Empty configurations show **No destinations**. Paths remain app-owned when an item is hidden or removed, so reinserting the same ID restores its history. For duplicate section or item IDs, the first occurrence wins. See [migration and configuration rules](Docs/Migration-1.0.4.md). + ## Inspector integration Use the full initializer when selected content benefits from a regular-width inspector: @@ -177,7 +180,7 @@ AdaptiveAppShell( ) ``` -The inspector starts closed so the sidebar remains available in portrait iPad layouts. The user controls it from the system toolbar. +The inspector is optional and controlled from the system toolbar. The demo's **Keep inspector open** preference opens it when a project is opened; turning the preference off permits manual control. ## Themes @@ -210,11 +213,17 @@ AdaptiveAppShell/ - [Demo-level state restoration recipe](Docs/StateRestoration.md) - [Accessibility](Docs/Accessibility.md) - [Template quality checklist](Docs/QualityChecklist.md) +- [Interactive Studio walkthrough](Examples/AdaptiveShellDemo/README.md) +- [Candidate release notes](Docs/ReleaseNotes-1.0.4.md) +- [Validation matrix and remaining device checks](Docs/Validation.md) + +The demo stores projects, task changes, settings and search context in memory for the current window session. **New project** creates and opens a real local record; no account or backend is required. Project actions open a detail stack in both presentations, and the optional inspector reads the same model. Unknown links show an explicit message and preserve the existing navigation. ## Verification ```bash swift test +swiftlint lint --strict xcodebuild \ -project Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj \ @@ -227,8 +236,13 @@ xcodebuild \ cd Examples/AdaptiveShellDemo xcodegen generate ) + +Scripts/run_ui_tests.sh iphone +Scripts/run_ui_tests.sh ipad ``` +The UI script creates and removes only its own simulator and builds a separate public-API consumer outside the checkout. XcodeGen **2.45.4** and SwiftLint **0.65.0** are pinned in CI; the full workflow keeps Xcode 16.4 and 26.5 gates. Physical VoiceOver, keyboard, Stage Manager and adoption checks are tracked separately in the validation matrix. + ## License MIT. See [LICENSE](LICENSE). diff --git a/README.ru.md b/README.ru.md index 77b74ba..1aaac71 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,6 +1,6 @@ # Adaptive App Shell -Готовая SwiftUI-оболочка приложения: на iPhone она использует нижний tab bar, а на iPad — sidebar и опциональный inspector. Навигационное состояние остаётся общим и не теряется при изменении размера окна. +SwiftUI-оболочка приложения: на iPhone она использует нижний tab bar, а на iPad — sidebar и опциональный inspector. Каждая вкладка хранит свой typed route path в общем app-owned состоянии. ![Adaptive App Shell](Assets/GIFs/adaptive-app-shell.gif) @@ -9,6 +9,7 @@ - Системный `TabView` для компактной ширины - `NavigationSplitView` для iPad и широких окон - Независимая история навигации каждой вкладки +- Реакция на добавление, удаление, скрытие и перестановку разделов - Sidebar-only коллекции без перегруженного tab bar - Контекстный inspector - Enum-маршруты для deep links @@ -45,7 +46,15 @@ Shell управляет только presentation и навигацией. Он Каждая вкладка получает отдельный route path. Если пользователь открыл detail внутри Projects, перешёл в Search и вернулся, Projects останется на прежнем экране. -Inspector изначально закрыт, чтобы в portrait-режиме iPad не вытеснять sidebar. На широком экране он открывается системной кнопкой. +Inspector опционален и управляется системной кнопкой. Настройка **Keep inspector open** открывает его при переходе в проект; выключенная настройка позволяет управлять им вручную. + +При изменении разделов shell сохраняет допустимый выбор. Если выбранный ID удалён или скрыт в compact, выбирается первый доступный элемент. Пустая конфигурация показывает **No destinations**. Paths удалённых и временно скрытых элементов сохраняются до явной очистки приложением; для повторяющихся ID используется первое вхождение. Публичные initializer’ы сохранены. + +В Studio demo кнопка **New project** валидирует название, создаёт локальный проект и открывает его детали. Overview, Projects и Search открывают выбранный проект и в compact, и в regular, независимо от inspector. Tasks, progress и inspector читают один `StudioStore`. Поиск сохраняет query и scope в app-owned состоянии; пустой запрос показывает все данные выбранной категории. Проекты и настройки действуют только в текущей сессии окна. + +`adaptiveshell://project/website-qa` открывает известный проект. Неизвестный ID или malformed URL показывает сообщение и сохраняет текущую историю, без подстановки другого fixture. + +Требования: Swift 6, Xcode 16.4+, iOS 17+, macOS 14+. Xcode 26+ также собирает ветку с native Liquid Glass. В CI закреплены XcodeGen 2.45.4 и SwiftLint 0.65.0. ## Документация @@ -55,11 +64,15 @@ Inspector изначально закрыт, чтобы в portrait-режиме - [App-owned state restoration](Docs/StateRestoration.md) - [Доступность](Docs/Accessibility.md) - [Проверка качества](Docs/QualityChecklist.md) +- [Полный demo walkthrough](Examples/AdaptiveShellDemo/README.md) +- [Правила конфигурации и migration](Docs/Migration-1.0.4.md) +- [Проверки и оставшиеся device gates](Docs/Validation.md) ## Проверка ```bash swift test +swiftlint lint --strict xcodebuild \ -project Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj \ @@ -67,6 +80,11 @@ xcodebuild \ -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO \ build + +Scripts/run_ui_tests.sh iphone +Scripts/run_ui_tests.sh ipad ``` +UI-проверки используют собственные временные симуляторы и отдельный consumer, импортирующий только публичный продукт. Проверки на физических устройствах, VoiceOver, hardware keyboard, Stage Manager и реальный adoption pilot отмечаются отдельно; generic build их не заменяет. + Лицензия: MIT. diff --git a/Scripts/create_simulator.py b/Scripts/create_simulator.py new file mode 100755 index 0000000..9a83659 --- /dev/null +++ b/Scripts/create_simulator.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Create one owned simulator from the installed iOS runtimes and device types.""" + +import argparse +import json +import subprocess +import sys +import uuid + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("kind", choices=["iphone", "ipad"]) +parser.add_argument("--runtime", help="Installed iOS version, for example 26.5") +args = parser.parse_args() + + +def inventory(kind): + result = subprocess.run(["xcrun", "simctl", "list", kind, "--json"], check=True, capture_output=True, text=True) + return json.loads(result.stdout)[kind] + + +def version(value): + return (tuple(int(part) for part in value.split(".")) + (0, 0, 0))[:3] + + +sdk = subprocess.run( + ["xcrun", "--sdk", "iphonesimulator", "--show-sdk-version"], + check=True, capture_output=True, text=True +).stdout.strip() +runtimes = [ + runtime for runtime in inventory("runtimes") + if runtime.get("isAvailable") and ".iOS-" in runtime["identifier"] + and version(runtime["version"]) <= version(sdk) + and (args.runtime is None or runtime["version"] == args.runtime) +] +if not runtimes: + sys.exit(f"No matching installed iOS runtime supported by SDK {sdk}. Install one in Xcode Settings > Components.") +runtime = max(runtimes, key=lambda item: version(item["version"])) +parts = version(runtime["version"]) + (0, 0) +runtime_number = (parts[0] << 16) | (parts[1] << 8) | parts[2] +family = "iPhone" if args.kind == "iphone" else "iPad" +devices = [ + device for device in inventory("devicetypes") + if device.get("productFamily") == family + and device.get("minRuntimeVersion", 0) <= runtime_number <= device.get("maxRuntimeVersion", 2**32) +] +if not devices: + sys.exit(f"No {family} device type supports iOS {runtime['version']}.") +preferred = [device for device in devices if "Pro" in device["name"] and "Max" not in device["name"]] +device = (preferred or devices)[0] +name = f"AdaptiveShell {family} {uuid.uuid4().hex[:8]}" +result = subprocess.run( + ["xcrun", "simctl", "create", name, device["identifier"], runtime["identifier"]], + check=True, capture_output=True, text=True +) +print(f"Created {name}: {device['name']}, iOS {runtime['version']}", file=sys.stderr) +print(result.stdout.strip()) diff --git a/Scripts/prepare_consumer.py b/Scripts/prepare_consumer.py new file mode 100755 index 0000000..9301aba --- /dev/null +++ b/Scripts/prepare_consumer.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Create a standalone public-API consumer outside the package checkout.""" + +import argparse +import json +from pathlib import Path +import shutil + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("output", type=Path) +parser.add_argument("--version", help="Resolve a released version instead of the local checkout") +args = parser.parse_args() +root = Path(__file__).resolve().parents[1] +output = args.output.resolve() +if output == root or root in output.parents: + parser.error("consumer output must be outside the library checkout") +if output.exists(): + parser.error("consumer output already exists; choose a fresh directory") +source = output / "Sources" / "ConsumerBoundary" +source.mkdir(parents=True) +fixture = root / "Tests" / "ConsumerFixture" +shutil.copy2(fixture / "ConsumerView.swift", source) +(output / "ConsumerHost").mkdir() +(output / "ConsumerUITests").mkdir() +shutil.copy2(fixture / "ConsumerApp.swift", output / "ConsumerHost") +shutil.copy2(fixture / "ConsumerUITests.swift", output / "ConsumerUITests") +repository = "https://github.com/mikonyaa/AdaptiveAppShell.git" +dependency = ( + f".package(url: {json.dumps(repository)}, exact: {json.dumps(args.version)})" + if args.version else f".package(path: {json.dumps(str(root))})" +) +(output / "Package.swift").write_text(f"""// swift-tools-version: 6.0 +import PackageDescription +let package = Package( + name: "ConsumerBoundary", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [.library(name: "ConsumerBoundary", targets: ["ConsumerBoundary"])], + dependencies: [{dependency}], + targets: [.target(name: "ConsumerBoundary", dependencies: [ + .product(name: "AdaptiveAppShell", package: "AdaptiveAppShell") + ])] +) +""") +package_spec = ( + f"url: {repository}\n exactVersion: {args.version}" + if args.version else f"path: {json.dumps(str(root))}" +) +(output / "project.yml").write_text(f"""name: ConsumerHost +options: + deploymentTarget: + iOS: "17.0" + xcodeVersion: "26.0" +packages: + AdaptiveAppShell: + {package_spec} +settings: + base: + SWIFT_VERSION: "6.0" + GENERATE_INFOPLIST_FILE: YES + CODE_SIGNING_ALLOWED: NO + TARGETED_DEVICE_FAMILY: "1,2" +targets: + ConsumerHost: + type: application + platform: iOS + sources: [ConsumerHost, Sources/ConsumerBoundary] + dependencies: + - package: AdaptiveAppShell + product: AdaptiveAppShell + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.adaptiveshell.consumer + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + ConsumerHostUITests: + type: bundle.ui-testing + platform: iOS + sources: [ConsumerUITests] + dependencies: + - target: ConsumerHost + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.adaptiveshell.consumer.uitests +schemes: + ConsumerHost: + build: + targets: + ConsumerHost: all + test: + targets: [ConsumerHostUITests] +""") +print(output) diff --git a/Scripts/record_showcase.sh b/Scripts/record_showcase.sh index 6c6779c..c8672ee 100755 --- a/Scripts/record_showcase.sh +++ b/Scripts/record_showcase.sh @@ -1,18 +1,45 @@ -#!/bin/zsh +#!/bin/bash set -euo pipefail -ROOT="${0:A:h:h}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" PROJECT="$ROOT/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj" -DERIVED_DATA="$ROOT/DerivedData" +DERIVED_DATA="$ROOT/DerivedData-showcase" APP="$DERIVED_DATA/Build/Products/Debug-iphonesimulator/AdaptiveShellDemo.app" BUNDLE_ID="dev.liquidglasstemplates.adaptiveshell.demo" -DEVICE_ID="${ADAPTIVE_SHELL_DEVICE_ID:-58A90F38-36E7-4A46-93ED-E2E7714CD0A4}" -RECORDING="$ROOT/Assets/GIFs/showcase-source.mov" +DEVICE_ID="" +RECORDING_PID="" +CAPTURE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/adaptive-shell-capture.XXXXXX")" +RECORDING="$CAPTURE_DIR/showcase-source.mov" GIF="$ROOT/Assets/GIFs/adaptive-app-shell.gif" BUILD_LOG="$DERIVED_DATA/showcase-build.log" +cleanup() { + if [ -n "$RECORDING_PID" ]; then + kill -INT "$RECORDING_PID" >/dev/null 2>&1 || true + wait "$RECORDING_PID" >/dev/null 2>&1 || true + fi + if [ -n "$DEVICE_ID" ]; then + xcrun simctl shutdown "$DEVICE_ID" >/dev/null 2>&1 || true + xcrun simctl delete "$DEVICE_ID" >/dev/null 2>&1 || true + fi + rm -rf "$CAPTURE_DIR" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +for tool in xcodebuild xcrun python3 ffmpeg; do + command -v "$tool" >/dev/null || { echo "Missing prerequisite: $tool" >&2; exit 1; } +done mkdir -p "$DERIVED_DATA" "$ROOT/Assets/GIFs" +if [ -n "${ADAPTIVE_SHELL_RUNTIME:-}" ]; then + DEVICE_ID="$(python3 "$ROOT/Scripts/create_simulator.py" iphone --runtime "$ADAPTIVE_SHELL_RUNTIME")" +else + DEVICE_ID="$(python3 "$ROOT/Scripts/create_simulator.py" iphone)" +fi +xcrun simctl boot "$DEVICE_ID" +xcrun simctl bootstatus "$DEVICE_ID" -b xcodebuild \ -project "$PROJECT" \ @@ -23,9 +50,6 @@ xcodebuild \ CODE_SIGNING_ALLOWED=NO \ build > "$BUILD_LOG" -xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true -xcrun simctl bootstatus "$DEVICE_ID" -b -xcrun simctl uninstall "$DEVICE_ID" "$BUNDLE_ID" >/dev/null 2>&1 || true xcrun simctl install "$DEVICE_ID" "$APP" xcrun simctl terminate "$DEVICE_ID" "$BUNDLE_ID" >/dev/null 2>&1 || true xcrun simctl launch "$DEVICE_ID" "$BUNDLE_ID" >/dev/null @@ -40,7 +64,9 @@ RECORDING_PID=$! sleep 13 kill -INT "$RECORDING_PID" || true wait "$RECORDING_PID" || true +RECORDING_PID="" +cp "$RECORDING" "$ROOT/Assets/GIFs/showcase-source.mov" ffmpeg -y -i "$RECORDING" \ -filter_complex "fps=20,scale=400:-1:flags=lanczos,split[frames][palette_input];[palette_input]palettegen=max_colors=128:stats_mode=diff[palette];[frames][palette]paletteuse=dither=none:diff_mode=rectangle" \ -loop 0 "$GIF" >/dev/null 2>&1 diff --git a/Scripts/run_ui_tests.sh b/Scripts/run_ui_tests.sh new file mode 100755 index 0000000..b904983 --- /dev/null +++ b/Scripts/run_ui_tests.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +KIND="${1:-iphone}" +OUTPUT_BASE="${ADAPTIVE_SHELL_TEST_OUTPUT_DIR:-$ROOT/DerivedData-qa-$KIND}" +OUTPUT="$OUTPUT_BASE/$(date -u '+%Y%m%dT%H%M%SZ')-$$" +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/adaptive-shell-tests.XXXXXX")" +DEVICE_ID="" + +cleanup() { + if [ -n "$DEVICE_ID" ]; then + xcrun simctl shutdown "$DEVICE_ID" >/dev/null 2>&1 || true + xcrun simctl delete "$DEVICE_ID" >/dev/null 2>&1 || true + fi + rm -rf "$TEMP_ROOT" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +for tool in xcodebuild xcrun python3 xcodegen swift; do + command -v "$tool" >/dev/null || { echo "Missing prerequisite: $tool" >&2; exit 1; } +done +mkdir -p "$OUTPUT/results" +DEVICE_ID="$(python3 "$ROOT/Scripts/create_simulator.py" "$KIND")" +xcrun simctl boot "$DEVICE_ID" +xcrun simctl bootstatus "$DEVICE_ID" -b + +run_hosted() { + local project="$1" scheme="$2" name="$3" + local derived="$OUTPUT/$name" run_file + xcodebuild -project "$project" -scheme "$scheme" -destination "platform=iOS Simulator,id=$DEVICE_ID" \ + -derivedDataPath "$derived" CODE_SIGNING_ALLOWED=NO build-for-testing + run_file="$(python3 -c 'import pathlib,sys; files=list(pathlib.Path(sys.argv[1]).glob("*.xctestrun")); assert len(files)==1, files; print(files[0])' "$derived/Build/Products")" + xcodebuild -xctestrun "$run_file" -destination "platform=iOS Simulator,id=$DEVICE_ID" \ + -resultBundlePath "$OUTPUT/results/$name.xcresult" -collect-test-diagnostics never \ + -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test-without-building +} + +run_hosted "$ROOT/Examples/AdaptiveShellDemo/AdaptiveShellDemo.xcodeproj" AdaptiveShellDemo demo + +CONSUMER="$TEMP_ROOT/consumer" +python3 "$ROOT/Scripts/prepare_consumer.py" "$CONSUMER" +swift package --package-path "$CONSUMER" resolve +swift build --package-path "$CONSUMER" +( + cd "$CONSUMER" + xcodegen generate +) +run_hosted "$CONSUMER/ConsumerHost.xcodeproj" ConsumerHost consumer diff --git a/Sources/AdaptiveAppShell/Components/AdaptiveAppShellView.swift b/Sources/AdaptiveAppShell/Components/AdaptiveAppShellView.swift index d1aaa5f..2a835c1 100644 --- a/Sources/AdaptiveAppShell/Components/AdaptiveAppShellView.swift +++ b/Sources/AdaptiveAppShell/Components/AdaptiveAppShellView.swift @@ -56,6 +56,9 @@ public struct AdaptiveAppShell .onChange(of: state.selection) { _, _ in normalizeSelection() } + .onChange(of: sections, initial: true) { _, _ in + normalizeSelection() + } } private var isCompact: Bool { @@ -63,7 +66,11 @@ public struct AdaptiveAppShell } private var allItems: [AdaptiveShellItem] { - sections.flatMap(\.items) + uniqueSections.flatMap(\.items) + } + + private var uniqueSections: [AdaptiveShellSection] { + AdaptiveShellSelectionResolver.uniqueSections(sections) } private var compactItems: [AdaptiveShellItem] { @@ -124,22 +131,20 @@ public struct AdaptiveAppShell } detail: { NavigationStack(path: pathBinding(for: selectedItem.id)) { content(selectedItem, navigation(for: selectedItem.id)) - .navigationDestination(for: Route.self, destination: destination) - .toolbar { - if showsInspector(selectedItem) { - ToolbarItem(placement: .primaryAction) { - inspectorToggle - } - } + .toolbar { inspectorToolbar(for: selectedItem) } + .navigationDestination(for: Route.self) { route in + destination(route) + .toolbar { inspectorToolbar(for: selectedItem) } } } + .id(selectedItem.id) .background(theme.canvas) - .inspector(isPresented: inspectorBinding(for: selectedItem)) { - inspector(selectedItem) - .inspectorColumnWidth(min: 280, ideal: 330, max: 420) - } } .navigationSplitViewStyle(.balanced) + .inspector(isPresented: inspectorBinding(for: selectedItem)) { + inspector(selectedItem) + .inspectorColumnWidth(min: 280, ideal: 330, max: 420) + } .environment(\.adaptiveShellPresentation, .regular) } else { emptyShell @@ -148,7 +153,7 @@ public struct AdaptiveAppShell private var sidebar: some View { List { - ForEach(sections) { section in + ForEach(uniqueSections) { section in if let sectionTitle = section.title { Section(sectionTitle) { sidebarRows(section.items) @@ -213,6 +218,15 @@ public struct AdaptiveAppShell .accessibilityIdentifier("adaptive-shell-inspector-toggle") } + @ToolbarContentBuilder + private func inspectorToolbar(for item: AdaptiveShellItem) -> some ToolbarContent { + if showsInspector(item) { + ToolbarItem(placement: .primaryAction) { + inspectorToggle + } + } + } + private var emptyShell: some View { ContentUnavailableView( "No destinations", diff --git a/Sources/AdaptiveAppShell/Models/AdaptiveShellSelectionResolver.swift b/Sources/AdaptiveAppShell/Models/AdaptiveShellSelectionResolver.swift index e7d96b0..6623094 100644 --- a/Sources/AdaptiveAppShell/Models/AdaptiveShellSelectionResolver.swift +++ b/Sources/AdaptiveAppShell/Models/AdaptiveShellSelectionResolver.swift @@ -1,12 +1,31 @@ enum AdaptiveShellSelectionResolver { + static func uniqueItems(_ items: [AdaptiveShellItem]) -> [AdaptiveShellItem] { + var seen = Set() + return items.filter { seen.insert($0.id).inserted } + } + + static func uniqueSections( + _ sections: [AdaptiveShellSection] + ) -> [AdaptiveShellSection] { + var sectionIDs = Set() + var itemIDs = Set() + return sections.compactMap { section in + guard sectionIDs.insert(section.id).inserted else { return nil } + let items = section.items.filter { itemIDs.insert($0.id).inserted } + guard !items.isEmpty else { return nil } + return AdaptiveShellSection(id: section.id, title: section.title, items: items) + } + } + static func resolve( current: ID, items: [AdaptiveShellItem], isCompact: Bool ) -> ID? { + let unique = uniqueItems(items) let candidates = isCompact - ? items.filter { $0.compactPlacement == .tab } - : items + ? unique.filter { $0.compactPlacement == .tab } + : unique guard let first = candidates.first else { return nil } return candidates.contains { $0.id == current } ? current : first.id diff --git a/Tests/AdaptiveAppShellTests/AdaptiveShellTests.swift b/Tests/AdaptiveAppShellTests/AdaptiveShellTests.swift index 5b55655..d530d70 100644 --- a/Tests/AdaptiveAppShellTests/AdaptiveShellTests.swift +++ b/Tests/AdaptiveAppShellTests/AdaptiveShellTests.swift @@ -2,6 +2,32 @@ import XCTest @testable import AdaptiveAppShell final class AdaptiveShellItemTests: XCTestCase { + func testDuplicateItemIDsUseFirstOccurrenceForCompactPlacement() { + let items = [ + AdaptiveShellItem(id: "alpha", title: "Hidden", systemImage: "a.circle", compactPlacement: .hidden), + AdaptiveShellItem(id: "beta", title: "Beta", systemImage: "b.circle"), + AdaptiveShellItem(id: "alpha", title: "Duplicate", systemImage: "a.circle") + ] + XCTAssertEqual( + AdaptiveShellSelectionResolver.resolve(current: "alpha", items: items, isCompact: true), + "beta" + ) + } + + func testDuplicateSectionAndItemIDsHaveDeterministicFirstOccurrence() { + let alpha = AdaptiveShellItem(id: "alpha", title: "Alpha", systemImage: "a.circle") + let beta = AdaptiveShellItem(id: "beta", title: "Beta", systemImage: "b.circle") + let sections = [ + AdaptiveShellSection(id: "first", title: "First", items: [alpha]), + AdaptiveShellSection(id: "second", items: [alpha, beta]), + AdaptiveShellSection(id: "first", items: [beta]) + ] + let result = AdaptiveShellSelectionResolver.uniqueSections(sections) + XCTAssertEqual(result.map(\.id), ["first", "second"]) + XCTAssertEqual(result.flatMap(\.items).map(\.id), ["alpha", "beta"]) + XCTAssertEqual(result.first?.title, "First") + } + func testItemUsesFallbackSelectedSymbol() { let item = AdaptiveShellItem( id: "overview", diff --git a/Tests/ConsumerFixture/ConsumerApp.swift b/Tests/ConsumerFixture/ConsumerApp.swift new file mode 100644 index 0000000..3f8d7d1 --- /dev/null +++ b/Tests/ConsumerFixture/ConsumerApp.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct ConsumerApp: App { + var body: some Scene { + WindowGroup { ConsumerView() } + } +} diff --git a/Tests/ConsumerFixture/ConsumerUITests.swift b/Tests/ConsumerFixture/ConsumerUITests.swift new file mode 100644 index 0000000..22d4262 --- /dev/null +++ b/Tests/ConsumerFixture/ConsumerUITests.swift @@ -0,0 +1,57 @@ +import XCTest + +@MainActor +final class ConsumerUITests: XCTestCase { + private var app: XCUIApplication! + + @MainActor + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + func testRemovingSelectedSectionShowsRemainingPublicDestination() { + XCTAssertTrue(app.staticTexts["consumer-root-alpha"].waitForExistence(timeout: 10)) + configure("Remove selected") + XCTAssertTrue(app.staticTexts["consumer-root-beta"].waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["consumer-selection"].label, "Selection: beta") + } + + func testEmptyToLoadedAndReinsertPreservesUnrelatedDetail() { + app.buttons["Select Beta"].tap() + app.buttons["Open Beta detail"].tap() + XCTAssertTrue(app.staticTexts["consumer-detail-beta"].waitForExistence(timeout: 5)) + configure("Remove other") + configure("Clear sections") + XCTAssertTrue(app.staticTexts["No destinations"].waitForExistence(timeout: 5)) + configure("Load sections") + XCTAssertTrue(app.staticTexts["consumer-detail-beta"].waitForExistence(timeout: 5)) + configure("Reverse order") + configure("Duplicate Alpha") + XCTAssertTrue(app.staticTexts["consumer-detail-beta"].exists) + } + + func testBothHistoriesSurvivePresentationAndHiddenPlacementChanges() { + app.buttons["Open Alpha detail"].tap() + app.buttons["Select Beta"].tap() + app.buttons["Open Beta detail"].tap() + if app.buttons["Use compact"].exists { app.buttons["Use compact"].tap() } + XCTAssertTrue(app.staticTexts["consumer-detail-beta"].waitForExistence(timeout: 5)) + app.buttons["Select Alpha"].tap() + XCTAssertTrue(app.staticTexts["consumer-detail-alpha"].waitForExistence(timeout: 5)) + configure("Hide Alpha") + XCTAssertTrue(app.staticTexts["consumer-detail-beta"].waitForExistence(timeout: 5)) + app.buttons["Use regular"].tap() + app.buttons["Select Alpha"].tap() + XCTAssertTrue(app.staticTexts["consumer-detail-alpha"].waitForExistence(timeout: 5)) + } + + private func configure(_ action: String) { + let configuration = app.buttons["Configuration"] + XCTAssertTrue(configuration.waitForExistence(timeout: 5)) + XCTAssertTrue(configuration.isHittable) + configuration.tap() + app.buttons[action].tap() + } +} diff --git a/Tests/ConsumerFixture/ConsumerView.swift b/Tests/ConsumerFixture/ConsumerView.swift new file mode 100644 index 0000000..1a5a5fd --- /dev/null +++ b/Tests/ConsumerFixture/ConsumerView.swift @@ -0,0 +1,83 @@ +import AdaptiveAppShell +import SwiftUI + +public struct ConsumerView: View { + @Environment(\.horizontalSizeClass) private var sizeClass + @State private var state = AdaptiveShellState(selection: "alpha") + @State private var items = Self.initialItems + @State private var compactOverride: Bool? + + private var compact: Bool { compactOverride ?? (sizeClass == .compact) } + + public init() {} + + private static let initialItems = [ + AdaptiveShellItem(id: "alpha", title: "Alpha", systemImage: "a.circle"), + AdaptiveShellItem(id: "beta", title: "Beta", systemImage: "b.circle") + ] + + public var body: some View { + VStack(spacing: 0) { + configurationControls + shell + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var shell: some View { + AdaptiveAppShell( + title: "Public consumer", + sections: items.enumerated().map { index, item in + AdaptiveShellSection(id: "section-\(index)", items: [item]) + }, + state: state, + content: { item, navigation in + VStack(spacing: 24) { + Text("Root \(item.title)") + .accessibilityIdentifier("consumer-root-\(item.id)") + Button("Open \(item.title) detail") { navigation.push(item.id) } + } + .navigationTitle(item.title) + }, + destination: { route in + Text("Detail \(route)") + .accessibilityIdentifier("consumer-detail-\(route)") + .navigationTitle("Detail \(route)") + } + ) + .environment(\.horizontalSizeClass, compact ? .compact : .regular) + } + + private var configurationControls: some View { + VStack(spacing: 6) { + HStack { + Menu("Configuration") { + Button("Remove selected") { items.removeAll { $0.id == state.selection } } + Button("Clear sections") { items = [] } + Button("Load sections") { items = Self.initialItems } + Button("Remove other") { items.removeAll { $0.id != state.selection } } + Button("Reverse order") { items.reverse() } + Button("Hide Alpha") { + items = [ + AdaptiveShellItem( + id: "alpha", title: "Alpha", systemImage: "a.circle", compactPlacement: .hidden + ), + Self.initialItems[1] + ] + } + Button("Duplicate Alpha") { items.append(Self.initialItems[0]) } + } + Button("Select Alpha") { state.select("alpha") } + Button("Select Beta") { state.select("beta") } + } + Button(compact ? "Use regular" : "Use compact") { compactOverride = !compact } + Text("Selection: \(state.selection)") + .accessibilityIdentifier("consumer-selection") + } + .font(.caption) + .buttonStyle(.bordered) + .padding(8) + .frame(maxWidth: .infinity) + .background(.regularMaterial) + } +}