diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..246cf3423 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [isaac-udy] +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +# polar: # Replace with a single Polar username +# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +# thanks_dev: # Replace with a single thanks.dev username +# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 34477c7e2..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: -- package-ecosystem: gradle - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ef34925c..69a3015c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,85 @@ name: CI on: push: + paths-ignore: + - '**.md' branches: - main pull_request: branches: - main + workflow_dispatch: + +# Superseded PR runs are wasted compute: when a new push lands on a PR, cancel +# the in-flight run for the previous commit. Pushes to main are never +# cancelled — every main commit keeps its complete CI record for bisecting. +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - run-ui-tests: - name: Run Tests - runs-on: macOS-latest + ci-linux: + name: CI / Linux (Android, Desktop, wasmJs) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Changes + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + isCodeChange: + - '**/*.kt' + - '**/*.kts' + - '**/*.toml' + + - name: Set up JDK 21 + if: steps.changes.outputs.isCodeChange == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Setup gradle + if: steps.changes.outputs.isCodeChange == 'true' + uses: gradle/actions/setup-gradle@v4 + + - name: Run continuous integration + if: steps.changes.outputs.isCodeChange == 'true' + env: + EW_API_TOKEN: ${{ secrets.EW_API_TOKEN }} + run: ./gradlew continuousIntegration --continue + + ci-macos: + name: CI / macOS (iOS) + runs-on: macos-latest steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up JDK 11 - uses: actions/setup-java@v2.5.0 - with: - distribution: 'zulu' - java-version: 11 - - - name: Run Enro UI Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :enro:connectedCheck - - - name: Run Enro UI Tests (Hilt) - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :enro:hilt-test:connectedCheck - - - name: Run Enro Unit Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :enro:testDebugUnitTest - - - name: Run Modularised Example Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :modularised-example:app:testDebugUnitTest + - name: Checkout + uses: actions/checkout@v4 + + - name: Changes + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + isCodeChange: + - '**/*.kt' + - '**/*.kts' + - '**/*.toml' + + - name: Set up JDK 21 + if: steps.changes.outputs.isCodeChange == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Setup gradle + if: steps.changes.outputs.isCodeChange == 'true' + uses: gradle/actions/setup-gradle@v4 + + - name: Run continuous integration (macOS) + if: steps.changes.outputs.isCodeChange == 'true' + run: ./gradlew continuousIntegrationMacOs --continue diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 000000000..ad683e869 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,65 @@ +name: Deploy docs to gh-pages + +# Manual deploy of docs/ghpages/ from main to the gh-pages branch. +# +# What it does: +# 1. Check out main. +# 2. Check out gh-pages alongside, as a git worktree. +# 3. On gh-pages: delete index.md, assets/, and docs/ at the root. +# (Anything else on gh-pages — CNAME, _config.yml, .nojekyll, etc. — +# is preserved.) +# 4. Copy main's docs/ghpages/{index.md, assets, docs} into the gh-pages +# worktree's root. +# 5. Commit and push if anything changed. +# +# Trigger this from the Actions tab → "Deploy docs to gh-pages" → Run +# workflow. + +on: + workflow_dispatch: + +jobs: + deploy: + name: Deploy docs/ghpages to the gh-pages branch + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Add gh-pages as a worktree + run: | + git fetch origin gh-pages + git worktree add gh-pages-deploy gh-pages + + - name: Replace docs content on gh-pages + run: | + cd gh-pages-deploy + # Remove only the three paths we manage. Other files on + # gh-pages (CNAME, _config.yml, .nojekyll, etc.) are preserved. + rm -f index.md + rm -rf assets docs + # Copy main's docs/ghpages/* into the worktree root. The + # `./.` syntax includes hidden files alongside the regular + # ones. + cp -r ../docs/ghpages/. . + + - name: Commit and push + working-directory: gh-pages-deploy + run: | + git add -A index.md assets docs + if git diff --cached --quiet; then + echo "No changes to deploy." + exit 0 + fi + git commit -m "Deploy docs from main (${GITHUB_SHA::7})" + git push origin gh-pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e30f8305..7defbd197 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,47 +6,26 @@ on: description: 'Version Name' required: true default: '' - changes: - description: 'Release notes' - required: true - default: '' jobs: release: name: Release runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v2 - - - name: Set up JDK 11 - uses: actions/setup-java@v2.5.0 - with: - distribution: 'zulu' - java-version: 11 - - - name: Run Enro UI Tests - uses: reactivecircus/android-emulator-runner@v2 + uses: actions/checkout@v4 with: - api-level: 29 - script: ./gradlew :enro:connectedCheck + # add-and-commit pushes the release commit using the checkout + # credentials, so check out with the publish token. + token: ${{ secrets.PUBLISH_GITHUB_TOKEN }} - - name: Run Enro UI Tests (Hilt) - uses: reactivecircus/android-emulator-runner@v2 + - name: Set up JDK 21 + uses: actions/setup-java@v4 with: - api-level: 29 - script: ./gradlew :enro:hilt-test:connectedCheck - - - name: Run Enro Unit Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :enro:testDebugUnitTest + distribution: 'zulu' + java-version: 21 - - name: Run Modularised Example Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :modularised-example:app:testDebugUnitTest + - name: Setup gradle + uses: gradle/actions/setup-gradle@v4 - name: Install gpg secret key run: cat <(echo -e "${{ secrets.PUBLISH_SIGNING_KEY_LITERAL }}") | gpg --batch --import @@ -57,8 +36,13 @@ jobs: - name: Export gpg secret key run: sudo gpg --export-secret-keys --pinentry-mode loopback --passphrase=${{ secrets.PUBLISH_SIGNING_KEY_PASSWORD }} ${{ secrets.PUBLISH_SIGNING_KEY_ID }} > ${{ secrets.PUBLISH_SIGNING_KEY_LOCATION }} - - name: Update Version Name - run: ./gradlew updateVersion -PversionName=${{ github.event.inputs.versionname }} + # Stamps version.properties AND the changelog: the standing + # "## Unreleased" section becomes "## ()", a fresh empty + # "## Unreleased" is inserted above it, and the released section's body + # is written to build/release-notes.md for the Create Release step. + # Fails (before writing anything) if the Unreleased section is empty. + - name: Update Version Name + Changelog + run: ./gradlew updateVersion -PversionName=${{ inputs.versionname }} - name: Publish Release env: @@ -69,24 +53,18 @@ jobs: PUBLISH_SIGNING_KEY_ID: ${{ secrets.PUBLISH_SIGNING_KEY_ID }} PUBLISH_SIGNING_KEY_PASSWORD: ${{ secrets.PUBLISH_SIGNING_KEY_PASSWORD }} PUBLISH_SIGNING_KEY_LOCATION: ${{ secrets.PUBLISH_SIGNING_KEY_LOCATION }} - run: ./gradlew publishAllPublicationsToSonatypeRepository --no-parallel # publishAllPublicationsToGitHubPackagesRepository + run: ./gradlew publishAllPublicationsToMavenCentralRepository -PmavenCentralUsername="${{ secrets.PUBLISH_SONATYPE_USER }}" -PmavenCentralPassword="${{ secrets.PUBLISH_SONATYPE_PASSWORD }}" --no-parallel --stacktrace --continue --exclude-task :enro-common:publishJsPublicationToMavenCentralRepository - name: Update Repo - uses: EndBug/add-and-commit@v5 - env: - GITHUB_TOKEN: ${{ secrets.PUBLISH_GITHUB_TOKEN }} + uses: EndBug/add-and-commit@v9 with: - add: "./version.properties" - message: ${{ format('Released {0}', github.event.inputs.versionname) }} + add: | + version.properties + CHANGELOG.md + message: ${{ format('Released {0}', inputs.versionname) }} push: true - name: Create Release - uses: actions/create-release@v1 env: - GITHUB_TOKEN: ${{ secrets.PUBLISH_GITHUB_TOKEN }} - with: - tag_name: ${{ github.event.inputs.versionname }} - release_name: Release ${{ github.event.inputs.versionname }} - body: ${{ github.event.inputs.changes }} - draft: false - prerelease: false + GH_TOKEN: ${{ secrets.PUBLISH_GITHUB_TOKEN }} + run: gh release create "${{ inputs.versionname }}" --title "Release ${{ inputs.versionname }}" --notes-file build/release-notes.md diff --git a/.gitignore b/.gitignore index eb9dc4383..c397c7bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store # Built application files *.apk *.aar @@ -74,3 +75,26 @@ lint/outputs/ lint/tmp/ # lint/reports/ private.properties +/.kotlin/ + +/.codebuddy/ + +### Xcode ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata/ +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings +**/*.xcuserstate +*.xcscheme +*.xcworkspace +xcuserdata/ + +# CocoaPods +Pods/ + +## iOS App packaging +*.ipa +*.dSYM.zip +*.dSYM diff --git a/.run/Enro [_enro-core_desktopTest].run.xml b/.run/Enro [_enro-core_desktopTest].run.xml new file mode 100644 index 000000000..49a6a744c --- /dev/null +++ b/.run/Enro [_enro-core_desktopTest].run.xml @@ -0,0 +1,26 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/.run/_enro Tests.run.xml b/.run/_enro Tests.run.xml new file mode 100644 index 000000000..f9a19e624 --- /dev/null +++ b/.run/_enro Tests.run.xml @@ -0,0 +1,64 @@ + + + + + \ No newline at end of file diff --git a/.run/_tests_application Tests.run.xml b/.run/_tests_application Tests.run.xml new file mode 100644 index 000000000..cb14e4ac1 --- /dev/null +++ b/.run/_tests_application Tests.run.xml @@ -0,0 +1,64 @@ + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..9aee84a84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,268 @@ +# Changelog + +## Unreleased + +## 3.0.0-beta03 (2026-06-11) + +### Web browser history (WasmJS) + +* Rewrote `WebHistoryPlugin`'s history synchronisation. Updates are now + processed on a serial queue instead of being dropped while a sync was in + flight (the cause of a single browser back traversing multiple screens), + self-initiated `history.go()` traversals await and consume their popstate + echo instead of racing a `delay(1)` (the cause of double-pops), and + full-backstack replacements (e.g. a loading gate resolving to home, or a + section switch) now `replaceState` instead of `pushState`, so transient + screens can no longer survive as browser back targets. +* History entries whose recorded state can't be decoded (e.g. written by an + older build of an app — tab history survives deploys) now restore through + the entry's URL via the `@NavigationPath` bindings instead of doing + nothing, and self-heal the entry's stored state. +* Serialized history state is verified to round-trip at write time; a state + that can't restore is a hard error rather than a silently broken back + button later. + +### Serialization + +* `EnroController.jsonConfiguration` now uses kotlinx's default + (`POLYMORPHIC`) class-discriminator mode instead of `ALL_JSON_OBJECTS`. + With kotlinx 1.11, `ALL_JSON_OBJECTS` breaks both Json encoders for + realistic `NavigationKey` shapes under polymorphic dispatch: the streaming + encoder leaks a value-class field's deferred discriminator into the + next-opened object (corrupting `Instance.metadata` so persisted state + could not be decoded) and emits invalid JSON for collection fields, while + the tree encoder crashes on collection fields. `POLYMORPHIC` mode writes + discriminators exactly where polymorphic deserialization reads them and + handles all of these shapes correctly. `HistoryStateSerializationTests` + documents the upstream kotlinx failures + ([Kotlin/kotlinx.serialization#3022](https://github.com/Kotlin/kotlinx.serialization/issues/3022)) + so a fixed kotlinx release is detected. SavedState serialization (`ALL_OBJECTS`, androidx encoder) is + unaffected and covered by `SavedStateSerializationTests`. + +## 3.0.0-beta02 + +### Predictive back + +* Predictive back on iOS now starts smoothly and tracks the gesture + one-to-one, instead of jumping to partial progress when the system back + gesture is first recognised. + +## 3.0.0-beta01 + +First beta of the 3.x releases. Enro is now a Kotlin Multiplatform navigation +library targeting **Android, iOS, JVM Desktop, and WasmJS** through Compose +Multiplatform, with a redesigned API. For Android applications migrating from Enro 2.x, +we provide a first-class compatibility layer through the `enro-compat` library. +The full migration guide lives at [enro.dev/docs/migrating-from-v2.html](https://enro.dev/docs/migrating-from-v2.html). + +### Important changes from Enro 2.x + +* `NavigationKey.SupportsPush` / `SupportsPresent` collapsed to a flat + `NavigationKey`, with `NavigationKey.WithResult` for result-bearing + destinations. Presentation behaviour (dialog, overlay, bottom sheet) + moved to destination metadata. +* `@Parcelize` keys → `@Serializable` (kotlinx). Required for KMP; works + on every supported target. +* `navigation.push(key)` / `navigation.present(key)` → `navigation.open(key)`. +* `navigation.closeWithResult(value)` → `navigation.complete(value)`. +* `NavigationApplication` is gone. Install the controller from + `Application.onCreate` (or the platform equivalent) by calling + `installNavigationController(...)` on the component generated by + `@NavigationComponent`. +* `NavigationInstruction` → `NavigationOperation`. Same shape, more cases: + `Open` / `Close` / `Complete` / `CompleteFrom` / `SetBackstack` / + `AggregateOperation` / `SideEffect`. +* `NavigationExecutor` and executor overrides removed — scene strategies + handle dispatch. If you relied on a custom executor, open an issue so we + can map your case onto the new model. +* Annotation processing moved from kapt to KSP. Use + `ksp("dev.enro:enro-processor:...")` and the + `com.google.devtools.ksp` Gradle plugin. + +Fragment / Activity destinations continue to work through `enro-compat` +— see the compat module README and the migration guide. + +### Kotlin Multiplatform + Compose Multiplatform + +* New targets: Android, iOS (arm64 + simulator arm64), JVM Desktop, WasmJS. + Single source set for all four. +* `enro-common` carries non-UI types (`NavigationKey` and friends) so they + can be referenced from non-UI KMP targets such as a NodeJS backend. + +### Web URL routing + +* `@NavigationPath("/pattern/{placeholders}")` on a `NavigationKey` binds + it to a URL. Query parameters, optionals, value classes, and hand-written + bindings via `@NavigationPath.FromBinding` are all supported. +* `EnroBrowserContent { … }` is the wasmJs entry point; + `InstallWebHistoryPlugin` wires the root container into browser history; + `rememberInitialBackstackFromUrl` parses the address bar at cold load. +* See [Path Bindings](https://enro.dev/docs/advanced/path-bindings) and + [Web Platform Guide](https://enro.dev/docs/platform/web). + +### Scenes and decorators + +* `NavigationSceneStrategy` controls how a backstack is presented: + `SinglePane`, `Dialog`, `DirectOverlay` ship in the runtime; custom + strategies can layer two-pane / list-detail / managed-flow scenes on + top (see the recipes module for examples). +* `SceneDecoratorStrategy` wraps the active scene with persistent chrome + (sidebars, bottom bars, app shells) using `movableContentOf` + + `sharedElement` for stable cross-section transitions. +* Per-scene transition metadata (`TransitionKey`, `PopTransitionKey`, + `PredictivePopTransitionKey`) lets a scene customise its enter/exit + animations. +* Hoistable scene state via `rememberNavigationSceneState` for tests and + conditional rendering. + +### Synthetic destinations + +* Redesigned dispatch with explicit outcomes (`SyntheticOutcome.Open` / + `Close` / `Complete` / `CompleteFrom` / `SideEffect`). In-place rewrite, + no deferred execution — fixes ordering bugs for synthetics in initial + backstacks. +* Unit-testable in isolation via `testSyntheticDestination(key)` and an + assertion DSL in `enro-test`. + +### Results and flows + +* `NavigationKey` contracts without an explicit result type can now be used + with `registerForNavigationResult`, `complete` indicates a successful completion, + `close` indicates the user canceled/backed-out-of a screen. Removes the need to + use `NavigationKey.WithResult` to indicate user intention. + +### enro-test + +* `runEnroTest { … }` harness installs an isolated controller per test on + every supported target. +* `TestNavigationHandle` records every executed operation. Fluent + assertion DSL: `assertOpened`, `assertClosed`, `assertCompleted`, + `assertOperationExecuted`, `assertOperationSequence`, + `lastOperationOfType`, `lastOperation`. +* Backstack / path / module helpers: + `assertBackstackKeys`, `assertBackstackSize`, `assertBackstackContains`, + `assertBackstackDoesNotContain`, `assertBackstackEmpty`, + `installNavigationModule`, `installPathBindings`, + `assertPathResolvesTo`, `assertPathDoesNotResolve`, `assertPathFor`. +* `testSyntheticDestination(key)` / `testSyntheticDestination(key, provider)` + sandboxes synthetic dispatch with full outcome assertions. + +### enro-compat + +* Drop-in compat shim for Enro 2.x apps with Fragment / Activity + destinations. Adopt 3.x incrementally — keep existing Fragments, port + new screens to Compose. + +## 2.8.3 +* Resolved a bug with animation changes to `BottomSheetDestination` that caused animation snapping for these destinations + +## 2.8.2 +* Removed deprecated DialogDestination and BottomSheetDestination interfaces, and associated functions. Please use the Composable `DialogDestination` and `BottomSheetDestination` functions instead. Example usage can be found in the test application. +* Deprecated the `OverrideNavigationAnimations` function that does not take a content lambda, in favour of the version that does take a content lambda. +* `ModalBottomSheetState.bindToNavigationHandle` no longer overrides navigation animations. + +## 2.8.1 +* Fixed a bug with ComposableDestinationSavedStateOwner that was causing lists of primitives (such as List) to not get saved/restored correctly + +## 2.8.0 +* Updated Compose to 1.7.1 +* Added support for NavigationKey.WithExtras to `NavigationResultChannel` and `NavigationFlowScope` +* Updated `enro-test` methods to provide more descriptive error messages when assert/expect methods fail, and added kdoc comments to many of the functions +* Updated Composable navigation animations to use SeekableTransitionState, as a step towards supporting predictive back navigation animations +* Fixed a bug where managed flows (`registerForFlowResult`) that launch embedded flows (`deliverResultFromPush/Present`) were not correctly handling the result of the embedded flow +* Added `FragmentSharedElements` to provide a way to define shared elements for Fragment navigation, including a compatibility layer for Composable NavigationDestinations that want to use AndroidViews as shared elements with Fragments. See `FragmentsWithSharedElements.kt` in the test application for examples of how to use `FragmentSharedElements` +* Added `acceptFromFlow` as a `NavigationContainerFilter` for use on screens that build managed flows using `registerForFlowResult`. This filter will cause the `NavigationContainer` to only accept instructions that have been created as part a managed flow, and will reject instructions that are not part of a managed flow. +* Removed `isAnimating` from `ComposableNavigationContainer`, as it was unused internally, did not appear to be useful for external use cases, and was complicating Compose animation code. If this functionality *was* important to your use case, please create a Github issue to discuss your use case. +* Removed the requirement to provide a SavedStateHandle to `registerForFlowResult`. This should not affect any existing code, but if you were passing a SavedStateHandle to `registerForFlowResult`, you can now remove this parameter. + * NavigationHandles now have access to a SavedStateHandle internally, which removes the requirement to pass this through to `registerForFlowResult` +* Added `managedFlowDestination` as a way to create a managed flow as a standalone destination + * `managedFlowDestination` works in the same way you'd use `registerForFlowResult` to create a managed flow, but allows you to define the flow as a standalone destination that can be pushed or presented from other destinations, without the need to define a ViewModel and regular destination for the flow. + * `managedFlowDestination` is currently marked as an `@ExperimentalEnroApi`, and may be subject to change in future versions of Enro. + * For an example of a `managedFlowDestination`, see `dev.enro.tests.application.managedflow.UserInformationFlow` in the test application + +* ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ + * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. + +* ⚠️ Updated NavigationContainer handling of NavigationInstructionFilter ⚠️ + * In versions of Enro before 2.8.0, NavigationContainers would always accept destinations that were presented (`NavigationInstruction.Present(...)`, `navigationHandle.present(...)`, etc), and would only enforce their instructionFilter for pushed instructions (`NavigationInstruction.Push(...)`, `navigationHandle.push(...)`, etc). This is no longer the default behavior, and NavigationContainers will apply their instructionFilter to all instructions. + * This behavior can be reverted to the previous behavior by setting `useLegacyContainerPresentBehavior` when creating a NavigationController for your application using `createNavigationController`. + * `useLegacyContainerPresentBehavior` will be removed in a future version of Enro, and it is recommended that you update your NavigationContainers to explicitly declare their instructionFilter for all instructions, not just pushed instructions. + +## 2.7.0 +* ⚠️ Updated to androidx.lifecycle 2.8.1 ⚠️ + * There are breaking changes introduced in androidx.lifecycle 2.8.0; if you use Enro 2.7.0, you must upgrade your project to androidx.lifecycle 2.8+, otherwise you are likely to encounter runtime errors + +## 2.6.0 +* Added `isManuallyStarted` to the `registerForFlowResult` API, which allows for the flow to be started manually with a call to `update` rather than performing this automatically when the flow is created. +* Added `async` to `NavigationFlowScope`, which allows the execution of suspending lambdas as part of the steps in a flow. + +## 2.5.0 +* Added `update` to the public API for `NavigationFlow`, as this is required for some use cases where the flow needs to be updated after changes in external state which may affect the logic of the flow. This function was previously named `next`, and removed from the public API in 2.4.0. +* Moved `NavigationContext.getViewModel` and `requireViewModel` extensions to the `dev.enro.viewmodel` package. +* Added `NavigationResultScope` as a receiver for all registerForNavigationResult calls, to allow for more advanced handling of results and inspection of the instruction and navigation key that was used to open the result request. + +## 2.4.1 +* Added `EnroBackConfiguration`, which can be set when creating a `NavigationController`. This controls how Enro handles back presses. + * EnroBackConfiguration.Default will use the behavior that has been standard in Enro until this point + * EnroBackConfiguration.Manual disables all back handling via Enro, and allows developers to set their own back pressed handling for individual destinations + * EnroBackConfiguration.Predictive is experimental, but adds support for predictive back gestures and animations. This is not yet fully implemented, and is not recommended for production use. Once this is stabilised, EnroBackNavigation.Default will be renamed to EnroBackNavigation.Legacy, and EnroBackNavigation.Predictive will become the default. +* Removed `ContainerRegistrationStrategy` from the "core" `rememberNavigationContainer` methods, to stop the requirement to opt-in for `AdvancedEnroApi` when using the standard `rememberNavigationContainer` APIs. This was introduced accidentally with 2.4.0. +* Added `EmbeddedNavigationDestination` as an experimental API, which allows a `NavigationKey.SupportsPush` to be rendered as an embedded destination within another Composable. + +## 2.4.0 +* Updated dependency versions +* Added `instruction` property directly to `NavigationContext`, to provide easy access to the instruction +* Added extensions `getViewModel` and `requireViewModel` to `NavigationContext` to access `ViewModels` directly from a context reference +* Added extensions for `findContext` and `findActiveContext` to `NavigationContext` to allow for finding other NavigationContexts from a context reference +* Updated `NavigationContainer` to add `getChildContext` which allows finding specific Active/ActivePushed/ActivePresented/Specific contexts from a container reference +* Added `instruction` property to `NavigationContext`, and marked `NavigationContext` as `@AdvancedEnroApi` +* Updated `NavigationContext` and `NavigationHandle` to bind each other to allow for easier access to the other from either reference, and to ensure the lazy references are still available while the context is being referenced +* Updated result handling for forwarding results to fix several bugs and improve behaviour (including correctly handling forwarded results through Activities) +* Added `transient` configuration to NavigationFlow steps, which allows a step to only be re-executed if it's dependencies have changed +* Added `navigationFlowReference` as a parcealble object which can be passed to NavigationKeys, and then later used to retrieve the parent navigation flow +* Prevent more than one registerForNavigationResult from occurring within the context of a single NavigationHandle +* Remove `next` from the public API of NavigationFlow, in favour of doing this automatically on creation of the flow +* Added a new version of `OverrideNavigationAnimations`, which provides a way to override animations and receive an `AnimatedVisibilityScope` which is useful for shared element transitions. + +## 2.3.0 +* Updated NavigationFlow to return from `next` after `onCompleted` is called, rather than continuing to set the backstack from the flow +* Updated NavigationContainer to take a `filter` of type NavigationContainerFilter instead of an `accept: (NavigationKey) -> Boolean` lambda. This allows for more advanced filtering of NavigationKeys, and this API will likely be expanded in the future. + * For containers that pass an argument of `accept = { }` a quick replacement is `filter = acceptKey { }`, which will have the same behavior. +* Updated EmptyBehavior to use `requestClose` for the CloseParent behavior, and added ForceCloseParent as a method for retaining the old behavior which will close the parent of the container without going through that destination's `onRequestClose`. +* Fixed a bug with nested Composable NavigationContainers and the active container being changed while the parent Composable was not active. + +## 2.2.0 +* Removed NavigationAnimationOverrideBuilder methods that did not take a `returnEntering` or `returnExiting` parameter, in favour of defaulting these parameters to `entering` and `exiting` respectively. If you do not want to override return animations, you are able to pass null for these parameters to override the defaults. +* Removed default `EmptyBehavior` parameter for `rememberNavigationContainer`; an explicit EmptyBehaviour is now required. The default was previously `EmptyBehavior.AllowEmpty`, and usages of `rememberNavigationContainer` that were relying on this default parameter should be updated to pass this explicitly. +* Fixed a bug with `EnroTestRule` incorrectly capturing back presses for DialogFragments that are not bound into Enro + +## 2.1.1 +* Fixed a bug with `EnroTestRule`/`runEnroTest` that would cause instrumented `androidTest` tests to fail when including both tests that use `EnroTestRule`/`runEnroTest` and tests that do not in the same test suite + +## 2.1.0 +* Update to Compose 1.5.x +* Moved Activity/Fragment integrations out of the core of Enro and into independent plugins (which are still installed by default) +* Fixed a bug with NavigationResult channels not using the correct result channel id in some cases + +## 2.0.0 +Enro 2.0.0 introduces some important changes from the 1.x.x branch: +* Compose destinations are now stable +* The BottomSheetDestination and DialogDestination interfaces have been deprecated + * Replace these with using the Composables named BottomSheetDestination and DialogDestination + * See [DialogDestination.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2Fdestinations%2Fcompose%2FDialogComposable.kt) + * See [BottomSheetComposable.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2Fdestinations%2Fcompose%2FBottomSheetComposable.kt) +* Synthetic destinations can be defined as properties + * See [SimpleMessage.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2Fdestinations%2Fsynthetic%2FSimpleMessage.kt) +* Forward/Replace instructions have been deprecated + * Usages of Forward should be replaced with a mix of Push and/or Present + * See https://enro.dev/docs/frequently-asked-questions.html for an explanation of Push vs. Present + * Usages of Replace should be replaced with a `push/present` followed by a `close` +* Both Composables and Fragments now use a shared NavigationContainer type to host navigation + * See [MainActivity.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2FMainActivity.kt) or [RootFragment.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2FRootFragment.kt) for an example of Fragment containers + * See [ListDetailComposable.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2Fdestinations%2Flistdetail%2Fcompose%2FListDetailComposable.kt) for an example of Composable containers + * The `OnContainer` Navigation Instruction has been added, which allows direct backstack manipulation of NavigationContainers + * NavigationContainers allow advanced functionality such as interceptors and animation overrides +* `deliverResultFromPush`/`deliverResultFromPresent` are new extension functions which allow a screen to delegate it's result to another screen + * See the [embedded flow](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2Fdestinations%2Fresult%2Fflow%2Fembedded) for examples +* `activityResultDestination` is a new function which allows ActivityResultContracts to be used directly as destinations + * See [ActivityResults.kt](example%2Fsrc%2Fmain%2Fjava%2Fdev%2Fenro%2Fexample%2Fdestinations%2Factivity%2FActivityResults.kt) \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..bf97eae81 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands +- Build the project: `./gradlew build` +- Test the project: `./gradlew test` +- Run instrumented tests: `./gradlew connectedAndroidTest` +- Run a specific test: `./gradlew :module:test --tests "full.class.name.TestName"` +- Run a specific instrumented test: `./gradlew :module:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=full.class.name.TestName` +- Check code style: `./gradlew lintKotlin` + +## Code Style +- Follow Kotlin official code style with explicit API mode +- Use 4 space indentation +- Follow multiplatform practices, moving code to common module when possible +- Use Kotlin Serialization for serialization +- Navigation components should be annotated with `@NavigationDestination` +- Test classes should use `@RunWith(AndroidJUnit4::class)` and `EnroTestRule` +- Prefer immutable properties with val over var +- Use proper exception handling with runCatching when appropriate +- Follow standard naming conventions: ClassNames in PascalCase, variables/functions in camelCase +- Use meaningful names that describe purpose clearly +- When using annotations like @ExperimentalEnroApi, document reason for usage + +## Architecture +- This is a navigation framework for Kotlin multiplatform (focusing on Android) +- Core components include: NavigationKey, NavigationHandle, NavigationContainer, NavigationOperation, NavigationContext \ No newline at end of file diff --git a/README.md b/README.md index 485834ce0..d2579e90e 100644 --- a/README.md +++ b/README.md @@ -1,353 +1,147 @@ [![Maven Central](https://img.shields.io/maven-central/v/dev.enro/enro.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22dev.enro%22) -# Enro 🗺️ -A simple navigation library for Android - -*"The novices’ eyes followed the wriggling path up from the well as it swept a great meandering arc around the hillside. Its stones were green with moss and beset with weeds. Where the path disappeared through the gate they noticed that it joined a second track of bare earth, where the grass appeared to have been trampled so often that it ceased to grow. The dusty track ran straight from the gate to the well, marred only by a fresh set of sandal-prints that went down, and then up, and ended at the feet of the young monk who had fetched their water." - [The Garden Path](http://thecodelesscode.com/case/156)* - -## Features - -- Navigate between Fragments or Activities seamlessly - -- Describe navigation destinations through annotations or a simple DSL - -- Create beautiful transitions between specific destinations +> **Note** +> +> Please see the [CHANGELOG](./CHANGELOG.md) to understand the latest changes in Enro. -- Remove navigation logic from Fragment or Activity implementations - -- (Experimental) @Composable functions as navigation destinations, with full interoperability with Fragments and Activities +# Enro 🗺️ +### [enro.dev](https://enro.dev) -## Using Enro -#### Gradle -Enro is published to [Maven Central](https://search.maven.org/). Make sure your project includes the `mavenCentral()` repository, and then include the following in your module's build.gradle: -```gradle -dependencies { - implementation "dev.enro:enro:1.17.1" - kapt "dev.enro:enro-processor:1.17.1" -} -``` -
-Information on migration from JCenter and versions of Enro before 1.3.0 -

-Enro was previously published on JCenter, under the group name `nav.enro`. With the move to Maven Central, the group name has been changed to `dev.enro`, and the packages within the project have been updated to reflect this. +Enro is a powerful navigation library for Kotlin Multiplatform, based on a simple +idea: **screens within an application should behave like functions**. -Previously older versions of Enro were available on Gituhb, but these have now been removed. If you require pre-built artifacts, and are unable to build older versions of Enro yourself, please contact Isaac Udy via LinkedIn, and he will be happy to provide you with older versions of Enro as compiled artifacts. -

-
+A `NavigationKey` is the signature of a screen — it declares the screen's inputs, +and optionally a typed result. Calling code never needs to know how the screen is +implemented; it just invokes the contract. -#### 1. Define your NavigationKeys ```kotlin -@Parcelize -data class MyListKey(val listType: String): NavigationKey - -@Parcelize -data class MyDetailKey(val itemId: String, val isReadOnly): NavigationKey - -@Parcelize -data class MyComposeKey(val name: String): NavigationKey +@Serializable +data class ShowProfile(val userId: String) : NavigationKey + +@Serializable +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.WithResult ``` -#### 2. Define your NavigationDestinations -```kotlin -@NavigationDestination(MyListKey::class) -class ListFragment : Fragment() - -@NavigationDestination(MyDetailKey::class) -class DetailActivity : AppCompatActivity() - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { } -``` +If you read those as function signatures: -#### 3. Annotate your Application as a NavigationComponent, and implement the NavigationApplication interface ```kotlin -@NavigationComponent -class MyApplication : Application(), NavigationApplication { - override val navigationController = navigationController() -} +fun showProfile(userId: String): Unit +fun selectDate(minDate: LocalDate? = null, maxDate: LocalDate? = null): LocalDate ``` -#### 4. Navigate! -```kotlin -@NavigationDestination(MyListKey::class) -class ListFragment : ListFragment() { - val navigation by navigationHandle() - - fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val listType = navigation.key.listType - view.findViewById(R.id.list_title_text).text = "List: $listType" - } - - fun onListItemSelected(selectedId: String) { - val key = MyDetailKey(itemId = selectedId) - navigation.forward(key) - } -} - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { - val navigation = navigationHandle() - - Button( - content = { Text("Hello, ${navigation.key}") }, - onClick = { - navigation.forward(MyListKey(...)) - } - ) -} - -``` +Enro targets **Android, iOS, Desktop and Web** through Compose Multiplatform. +A first-class compatibility layer (`enro-compat`) keeps Fragments and Activities +working on Android, so you can adopt Enro in an existing app without rewriting it. -## Applications using Enro -

- - - -

+## Gradle quick-start -## FAQ -#### Minimum SDK Version -Enro supports a minimum SDK version of 16. However, support for SDK 16 was only recently added and targetting any SDK below 21 should be considered experimental. If you experience issues running on an SDK below 21, please report a GitHub issue. +Enro is published to [Maven Central](https://search.maven.org/). Add the +`mavenCentral()` repository and depend on: -#### How well does Enro work alongside "normal" Android Activity/Fragment navigation? -Enro is designed to integrate well with Android's default navigation. It's easy to manually open a Fragment or Activity as if Enro itself had performed the navigation. Create a NavigationInstruction object that represents the navigation, and then add it to the arguments of a Fragment, or the Intent for an Activity, and then open the Fragment/Activity as you normally would. - -Example: ```kotlin -val instruction = NavigationInstruction.Forward( - navigationKey = MyNavigationKey(...) -) -val intent = Intent(this, MyActivity::class).addOpenInstruction(instruction) -startActivity(intent) +dependencies { + implementation("dev.enro:enro:3.0.0-beta01") + ksp("dev.enro:enro-processor:3.0.0-beta01") + testImplementation("dev.enro:enro-test:3.0.0-beta01") +} ``` -#### How does Enro decide if a Fragment, or the Activity should receive a back button press? -Enro considers the primaryNavigationFragment to be the "active" navigation target, or the current Activity if there is no primaryNavigationFragment. In a nested Fragment situation, the primaryNavigationFragment of the primaryNavigationFragment of the ... is considered "active". - -#### What kind of navigation instructions does Enro support? -Enro supports three navigation instructions: `forward`, `replace` and `replaceRoot`. - -If the current navigation stack is `A -> B -> C ->` then: -`forward(D)` = `A -> B -> C -> D ->` -`replace(D)` = `A -> B -> D ->` -`replaceRoot(D)` = `D ->` +## A working screen -Enro supports multiple arguments to these instructions. -`forward(X, Y, Z)` = `A -> B -> C -> X -> Y -> Z ->` -`replace(X, Y, Z)` = `A -> B -> X -> Y -> Z ->` -`replaceRoot(X, Y, Z)` = `X -> Y -> Z ->` +Define a key, implement a destination, and navigate. That's the loop. -#### How does Enro support Activities navigating to Fragments? -When an Activity executes a navigation instruction that resolves to a Fragment, one of two things will happen: -1. The Activity's navigator defines a "container" that accepts the Fragment's type, in which case, the Fragment will be opened into the container view defined by that container. -2. The Activity's navigation **does not** define a fragment host that acccepts the Fragment's type, in which case, the Fragment will be opened into a new, full screen Activity. - -#### How do I deal with Activity results? -Enro supports any NavigationKey/NavigationDestination providing a result. Instead of implementing the NavigationKey interface on the NavigationKey that provides the result, implement NavigationKey.WithResult where T is the type of the result. Once you're ready to navigate to that NavigationKey and consume a result, you'll want to call "registerForNavigationResult" in your Fragment/Activity/ViewModel. This API is very similar to the AndroidX Activity 1.2.0 ActivityResultLauncher. - -Example: ```kotlin -@Parcelize -class RequestDataKey(...) : NavigationKey.WithResult() +@Serializable +data object Home : NavigationKey -@NavigationDestination(RequestDataKey::class) -class MyResultActivity : AppCompatActivity() { - val navigation by navigationHandle() +@Serializable +data class Profile(val userId: String) : NavigationKey - fun onSendResultButtonClicked() { - navigation.closeWithResult(false) +@Composable +@NavigationDestination(Home::class) +fun HomeScreen() { + val navigation = navigationHandle() + Button(onClick = { navigation.open(Profile("user-123")) }) { + Text("View profile") } } -@NavigationDestination(...) -class MyActivity : AppCompatActivity() { - val requestData by registerForNavigationResult { - // do something! - } - - fun onRequestDataButtonClicked() { - requestData.open(RequestDataKey(/*arguments*/)) +@Composable +@NavigationDestination(Profile::class) +fun ProfileScreen() { + val navigation = navigationHandle() + Column { + Text("Profile for ${navigation.key.userId}") + Button(onClick = { navigation.close() }) { Text("Back") } } } ``` -#### How do I do Master/Detail navigation -Enro has a built in component for this. If you want to build something more complex than what the built-in component provides, you'll be able to use the built-in component as a reference/starting point, as it is built purely on Enro's public API - -#### How do I handle multiple backstacks on each page of a BottomNavigationView? -Enro has a built in component for this. If you want to build something more complex than what the built-in component provides, you'll be able to use the built-in component as a reference/starting point, as it is built purely on Enro's public API +Wire Enro into your application once: -#### I'd like to do shared element transitions, or do something special when navigating between certain screens -Enro allows you to define "NavigationExecutors" as overrides for the default behaviour, which handle these situations. - -There will be an example project that shows how this all works in the future, but for now, here's a basic explanation: -1. A NavigationExecutor is typed for a "From", an "Opens", and a NavigationKey type. -2. Enro performs navigation on a "NavigationContext", which is basically either a Fragment or a FragmentActivity -3. A NavigationExecutor defines two methods - * `open`, which takes a NavigationContext of the "From" type, a Navigator for the "Opens" type, and a NavigationInstruction (i.e. the From context is attempting to open the Navigator with the input NavigationInstruction) - * `close`, which takes a NavigationContext of the "Opens" type (i.e. you're closing what you've already opened) -4. By creating a NavigationExecutor between two specific screens and registering this with the NavigationController, you're able to override the default navigation behaviour (although you're still able to call back to the DefaultActivityExecutor or DefaultFragmentExecutor if you need to) -5. See the method in NavigationControllerBuilder for `override` -6. When a NavigationContext decides what NavigationExecutor to execute an instruction on, Enro will look at the NavigationContext originating the NavigationInstruction and then walk up toward's it's root NavigationContext (i.e. a Fragment will check itself, then its parent Fragment, and then that parent Fragment's Activity), checking for an appropriate override along the way. If it finds no override, the default will be used. NavigationContexts that are the children of the current NavigationContext will not be searched, only the parents. - -Example: ```kotlin -// This override will place the "DetailFragment" into the container R.id.detail, -// and when it's closed, will set whatever Fragment is in the R.id.master container as the primary navigation fragment -override( - launch = { - val fragment = DetailFragment().addOpenInstruction(it.instruction) - it.fromContext.childFragmentManager.beginTransaction() - .replace(R.id.detail, fragment) - .setPrimaryNavigationFragment(fragment) - .commitNow() - }, - close = { context -> - context.fragment.parentFragmentManager.beginTransaction() - .remove(context.fragment) - .setPrimaryNavigationFragment(context.parentActivity.supportFragmentManager.findFragmentById(R.id.master)) - .commitNow() - } +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { } ) -``` - -#### I'd like to add a custom animation (using an override) for a @Composable @NavigationDestination -Unlike Activities and Fragments, when you want to write an override for a @Composable @NavigationDestination (particularly to specify custom animations), you don't have a class to reference in the To or From type arguments to the `override()` function. At first glance, it may appear that it is not possible to create an override for a @Composable @NavigationDestination. - -However, when you define a @Composable @NavigationDestination, Enro generates a class, called `Destination`. This class can be used when specifying overrides for @Composable @NavigationDestinations. - -Example: -```kotlin -val navigationController = navigationController { - /** - * This example assumes you have a @Composable function that is also a @NavigationDestination, and that the name - * of the @Composable function is `MyComposableScreen`. - * - * This example will set both the open and close animations for this screen to be the default "no animation" animation - * that Enro provides. - */ - override { - animation { DefaultAnimations.none } - closeAnimation { DefaultAnimations.none } - } -} -``` - -Please note, that the `Destination` is a generated class, and will not be available until you've compiled the project at least once since defining your @Composable @NavigationDestination (similar to how Dagger generates Components). - -#### My Activity crashes on launch, what's going on?! -It's possible for an Activity to be launched from multiple places. Most of these can be controlled by Enro, but some of them cannot. For example, an Activity that's declared in the manifest as a MAIN/LAUNCHER Activity might be launched by the Android operating system when the user opens your application for the first time. Because Enro hasn't launched the Activity, it's not going to know what the NavigationKey for that Activity is, and won't be able to read it from the Activity's intent. - -Luckily, there's an easy solution! When you declare an Activty or Fragment, you are able to do a small amount of configuration inside the `navigationHandle` block using the `defaultKey` method. This method takes a `NavigationKey` as an argument, and if the Fragment or Activity is opened without being passed a `NavigationKey` as part of its arguments, the value passed will be treated as the `NavigationKey`. This could occur because of an Activity being launched via a MAIN/LAUNCHER intent filter, via a standard `Intent`, or via a `Fragment` being added directly to a `FragmentManager` without any `NavigationInstruction` being applied. In other words, any situation where Enro is not used to launch the Activity or Fragment. -Example: -```kotlin -@Parcelize -class MainKey(isDefaultKey: Boolean = false) : NavigationKey - -@NavigationDestination(MainKey::class) -class MainActivity : AppCompatActivity() { - private val navigation by navigationHandle { - defaultKey( - MainKey(isDefaultKey = true) - ) +// Android — install in your Application +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + MyComponent.installNavigationController(this) } } ``` -## Why would I want to use Enro? -#### Support the navigation requirements of large multi-module Applications, while allowing flexibility to define rich transitions between specific destinations -A multi-module application has different requirements to a single-module application. Individual modules will define Activities and Fragments, and other modules will want to navigate to these Activities and Fragments. By detatching the NavigationKeys from the destinations themselves, this allows NavigationKeys to be defined in a common/shared module which all other modules depend on. Any module is then able to navigate to another by using one of the NavigationKeys, without knowing about the Activity or Fragment that it is going to. FeatureOneActivity and FeatureTwoActivity don't know about each other, but they both know that FeatureOneKey and FeatureTwoKey exist. A simple version of this solution can be created in less than 20 lines of code. - -However, truly beautiful navigation requires knowledge of both the originator and the destination. Material design's shared element transitions are an example of this. If FeatureOneActivity and FeatureTwoActivity don't know about each other, how can they collaborate on a shared element transition? Enro allows transitions between two navigation destinations to be overridden for that specific case, meaning that FeatureOneActivity and FeatureTwoActivity might know nothing about each other, but the application that uses them will be able to define a navigation override that adds shared element transitions between the two. - -#### Allow navigation to be triggered at the ViewModel layer of an Application -Enro provides a custom extension function similar to AndroidX's `by viewModels()`, called `by enroViewModels()`, which works in the exact same way. However, when you use `by enroViewModels()` to construct a ViewModel, you are able to use a `by navigationHandle()` statement within your ViewModel. This `NavigationHandle` works in the exact same way as an Activity or Fragment's `NavigationHandle`, and can be used in the exact same way. - -This means that your ViewModel can be put in charge of the flow through your Application, rather than needing to use a `LiveData()` (or similar) in your ViewModel. When we use things like `LiveData()` we are able to test the ViewModel's intent to navigate, but there's still the reliance on the Activity/Fragment implementing the response to the navigation event correctly. In the case of retrieving a result from another screen, this gap grows even wider, and there becomes an invisible contract between the ViewModel and Activity/Fragment: The ViewModel expects that if it sets a particular `NavigationEvent` in the `LiveData`, that the Activity/Fragment will navigate to the correct place, and then once the navigation has been successful and a result has been returned, that the Activity/Fragment will call the correct method on the ViewModel to provide the result. This invisible contract results in extra boilerplate "wiring" code, and a gap for bugs to slip through. Instead, using Enro's ViewModel integration, you allow your ViewModel to be precise and clear about it's intention, and about how to handle a result. - -## Experimental Compose Support -The most recent version of Enro (1.4.0-beta04) adds experimental support for directly marking `@Composable` functions as Navigation Destinations. - -To support a Composable destination, you will need to add both an `@NavigationDestination` annotation, and a `@ExperimentalComposableDestination` annotation. Once the Composable support moves from the "experimental" stage into a stable state, the `@ExperimentalComposableDestination` annotation will be removed. +Then host a backstack anywhere in your UI: -Here is an example of a Composable function being used as a NavigationDestination: ```kotlin -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { - val navigation = navigationHandle() - - Button( - content = { Text("Hello, ${navigation.key}") }, - onClick = { - navigation.forward(MyListKey(...)) - } - ) -} +val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), +) +NavigationDisplay(state = container) ``` -#### Nested Composables -Enro's Composable support is based around the idea of an "EnroContainer" Composable, which can be added to a Fragment, Activity or another Composable. The EnroContainer works much like a FrameLayout being used as a container for Fragments. +That's the whole flow. See [enro.dev](https://enro.dev) for the full guide. -Here is an example of creating a Composable that supports nested Composable navigation in Enro: - -```kotlin -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyNestedComposableScreen() { - val navigation = navigationHandle() - val containerController = rememberEnroContainerController( - accept = { it is NestedComposeKey } - ) +## Learn Enro - Column { - EnroContainer( - controller = containerController - ) - Button( - content = { Text("Open Nested") }, - onClick = { - navigation.forward(NestedComposeKey()) - } - ) - } -} +- **Documentation:** [enro.dev](https://enro.dev) — installation, concepts, results, animations, testing, platform guides, and the migration guide from Enro 2. +- **Recipes:** [`recipes/`](./recipes/common/src/commonMain/kotlin/dev/enro/recipes) — every concept (dialogs, bottom sheets, list-detail, tabs, deep links, managed flows, shared view models, custom animations) is a small runnable sample. +- **API reference:** generate with `./gradlew dokkaGenerate`; the multi-module site lands at `build/dokka/html/index.html`. Also published as a `-javadoc.jar` alongside each artifact on Maven Central. +- **Changelog:** [CHANGELOG.md](./CHANGELOG.md). -@Composable -@ExperimentalComposableDestination -@NavigationDestination(NestedComposeKey::class) -fun NestedComposableScreen() = Text("Nested Screen!") -``` +## Migrating from Enro 2 -In the example above, we have defined an Enro Container Controller which will accept Navigation Keys of type "NestedComposeKey". When the user clicks on the button "Open Nested", we execute a forward instruction to a NestedComposeKey. Because there is an available container which accepts NestedComposeKey instructions, the Composable for the NestedComposeKey (NestedComposableScreen in the example above) will be placed inside the EnroContainer defined in MyNestedComposableScreen. +Enro 3 is a significant rewrite that targets Kotlin Multiplatform and a +Compose-first model. The migration guide at +[enro.dev/docs/migrating-from-v2.html](https://enro.dev/docs/migrating-from-v2.html) +covers the API delta in detail. Highlights: -EnroContainerControllers can be configured to have some instructions pre-launched as their initial state, can be configured to accept some/all/no keys, and can be configured with an "EmptyBehavior" which defines what will happen when the container becomes empty due to a close action. The default close behavior is "AllowEmpty", but this can be set to "CloseParent", which will pass the close instruction up to the Container's parent, or "Action", which will allow any custom action to occur when the container becomes empty. +- `NavigationKey.SupportsPush` / `SupportsPresent` → flat `NavigationKey` (+ optional `WithResult`) +- `@Parcelize` → `@Serializable` (kotlinx) +- `push()` / `present()` → `open(key)`; dialog/overlay behaviour now lives in destination metadata +- `closeWithResult(r)` → `complete(r)` +- `NavigationApplication` interface is gone; install the component directly from `Application.onCreate` +- Fragments and Activities continue to work via the `enro-compat` module -#### Dialog and BottomSheet support -Composable functions declared as NavigationDestinations can be used as Dialog or ModalBottomSheet type destinations. To do this, make the Composable function an extension function on either `DialogDestination` or `BottomSheetDestination`. This will cause the Composable to be launched as a dialog, escaping the current navigation context of the screen. +## Applications using Enro -Here's an example: +

+ + + +   +   + + + +

-```kotlin -@Composable -@ExperimentalComposableDestination -@NavigationDestination(DialogComposableKey::class) -fun DialogDestination.DialogComposableScreen() { - configureDialog { ... } -} +--- -@Composable -@OptIn(ExperimentalMaterialApi::class) -@ExperimentalComposableDestination -@NavigationDestination(BottomSheetComposableKey::class) -fun BottomSheetDestination.BottomSheetComposableScreen() { - configureBottomSheet { ... } -} -``` \ No newline at end of file +*"The novices' eyes followed the wriggling path up from the well as it swept a great meandering arc around the hillside. Its stones were green with moss and beset with weeds. Where the path disappeared through the gate they noticed that it joined a second track of bare earth, where the grass appeared to have been trampled so often that it ceased to grow. The dusty track ran straight from the gate to the well, marred only by a fresh set of sandal-prints that went down, and then up, and ended at the feet of the young monk who had fetched their water." — [The Garden Path](http://thecodelesscode.com/case/156)* diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 50fb8d3b5..000000000 --- a/build.gradle +++ /dev/null @@ -1,92 +0,0 @@ -buildscript { - repositories { - mavenLocal() - google() - mavenCentral() - } - dependencies { - classpath deps.android.gradle - classpath deps.kotlin.gradle - classpath deps.hilt.gradle - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.0' - } -} - -allprojects { - repositories { - mavenLocal() - google() - mavenCentral() - } -} - -subprojects { - apply from: "$rootDir/common.gradle" - apply from: "$rootDir/common_publish.gradle" -} - -task clean(type: Delete) { - delete rootProject.buildDir -} - -task updateVersion { - doLast { - if (!project.hasProperty("versionName")) { - throw new IllegalStateException("The updateVersion task requires a versionName property to be passed as an argument") - } - def versionPropertiesFile = rootProject.file("version.properties") - def existingProperties = new Properties() - existingProperties.load(new FileInputStream(versionPropertiesFile)) - - def versionName = project.getProperties().get("versionName") - def versionCode = (existingProperties.versionCode as int) + 1 - - if(versionName == existingProperties.versionName) { - throw new IllegalStateException("The versionName '$versionName' is the current versionName") - } - - versionPropertiesFile.write("versionName=$versionName\nversionCode=$versionCode") - } -} - -task disableConnectedDeviceAnimations { - doLast { - exec { - commandLine( - "adb", "shell", "\"settings put global window_animation_scale 0.00\"" - ) - } - - exec { - commandLine( - "adb", "shell", "\"settings put global transition_animation_scale 0.00\"" - ) - } - exec { - commandLine( - "adb", "shell", "\"settings put global animator_duration_scale 0.00\"" - ) - } - } -} - -task enableConnectedDeviceAnimations { - doLast { - exec { - commandLine( - "adb", "shell", "\"settings put global window_animation_scale 1.00\"" - ) - } - - exec { - commandLine( - "adb", "shell", "\"settings put global transition_animation_scale 1.00\"" - ) - } - exec { - commandLine( - "adb", "shell", "\"settings put global animator_duration_scale 1.00\"" - ) - } - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..aadc913f2 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,180 @@ +import java.io.FileInputStream +import java.util.Properties + +buildscript { + repositories { + mavenLocal() + google() + mavenCentral() + } + dependencies { + classpath(libs.android.gradle) + classpath(libs.kotlin.gradle) + classpath(libs.kotlin.serialization.gradle) + classpath(libs.processing.ksp.gradle) + classpath(libs.emulator.wtf.gradle) + classpath(libs.maven.publish.gradle) + classpath(libs.dokka.gradle) + } +} + +// Aggregate API reference site: each published module is registered as +// a `dokka(...)` dependency below, and the root `dokkaGenerate` task +// combines their per-module outputs into one multi-module HTML site at +// `build/dokka/html`. Per-module Dokka is wired up in +// `ConfigurePublishing` — anything that doesn't apply +// `configure-publishing` is excluded from the API reference. +apply(plugin = "org.jetbrains.dokka") + +dependencies { + "dokka"(project(":enro")) + "dokka"(project(":enro-annotations")) + "dokka"(project(":enro-common")) + "dokka"(project(":enro-compat")) + "dokka"(project(":enro-processor")) + "dokka"(project(":enro-runtime")) + "dokka"(project(":enro-test")) +} + +allprojects { + repositories { + mavenLocal() + google() + mavenCentral() + } + + configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("dev.enro:enro-common")) + .using(project(":enro-common")) + + substitute(module("dev.enro:enro-runtime")) + .using(project(":enro-runtime")) + + substitute(module("dev.enro:enro-test")) + .using(project(":enro-test")) + + substitute(module("dev.enro:enro-compat")) + .using(project(":enro-compat")) + + substitute(module("dev.enro:enro-annotations")) + .using(project(":enro-annotations")) + + substitute(module("dev.enro:enro-processor")) + .using(project(":enro-processor")) + + substitute(module("dev.enro:enro")) + .using(project(":enro")) + } + } +} + +subprojects { + afterEvaluate { + tasks.register("continuousIntegration") { + val continuousIntegration = this + tasks.findByName("lintDebug")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("testAndroidHostTest")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("desktopTest")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("wasmJsBrowserTest")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("testWithEmulatorWtf")?.let { continuousIntegration.dependsOn(it) } + // Compile-only fallbacks so modules without tests (e.g. recipes) + // are still build-checked by CI. For modules with tests, these are + // no-ops — the test tasks above already depend on compilation. + tasks.findByName("compileKotlinDesktop")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("compileAndroidMain")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("compileKotlinWasmJs")?.let { continuousIntegration.dependsOn(it) } + } + + // Separate aggregate task for the macOS CI runner — runs only the + // iOS-platform tests (the rest already runs on Linux via + // continuousIntegration). Keeps the macOS minutes spend tight. + tasks.register("continuousIntegrationMacOs") { + val ci = this + tasks.findByName("iosSimulatorArm64Test")?.let { ci.dependsOn(it) } + // Compile-only fallback for modules without iOS tests, so a + // K/Native compile failure still shows up. + tasks.findByName("compileKotlinIosSimulatorArm64")?.let { ci.dependsOn(it) } + } + } +} + +tasks.register("updateVersion") { + doLast { + if (!project.hasProperty("versionName")) { + error("The updateVersion task requires a versionName property to be passed as an argument") + } + val versionPropertiesFile = rootProject.file("version.properties") + val existingProperties = Properties() + existingProperties.load(FileInputStream(versionPropertiesFile)) + + val versionName = project.properties["versionName"] + val versionCode = (existingProperties["versionCode"].toString().toInt()) + 1 + + if(versionName == existingProperties["versionName"]) { + error("The versionName '$versionName' is the current versionName") + } + + // ------------------------------------------------------------------ + // Changelog: changes stack under a standing "## Unreleased" header. + // Releasing stamps that header with the version (and date), inserts a + // fresh empty "## Unreleased" above it, and writes the released + // section's body to build/release-notes.md for the release workflow + // to attach to the GitHub release. All validation happens before any + // file is written, so a failed release leaves the repo untouched. + // ------------------------------------------------------------------ + val changelogFile = rootProject.file("CHANGELOG.md") + val changelog = changelogFile.readText() + val unreleasedHeader = "## Unreleased" + val headerIndex = changelog.indexOf(unreleasedHeader) + if (headerIndex == -1) { + error("CHANGELOG.md must contain an '## Unreleased' section to release from") + } + val bodyStart = headerIndex + unreleasedHeader.length + val nextHeaderIndex = changelog.indexOf("\n## ", bodyStart) + .let { if (it == -1) changelog.length else it } + val releaseNotesBody = changelog.substring(bodyStart, nextHeaderIndex).trim() + if (releaseNotesBody.isEmpty()) { + error( + "The 'Unreleased' section of CHANGELOG.md is empty — " + + "add release notes before releasing $versionName" + ) + } + + val releaseNotesFile = rootProject.layout.buildDirectory.file("release-notes.md").get().asFile + releaseNotesFile.parentFile.mkdirs() + releaseNotesFile.writeText(releaseNotesBody + "\n") + + val releaseDate = java.time.LocalDate.now() + changelogFile.writeText( + changelog.replaceFirst( + unreleasedHeader, + "## Unreleased\n\n## $versionName ($releaseDate)", + ) + ) + + versionPropertiesFile.writeText("versionName=$versionName\nversionCode=$versionCode") + } +} + +tasks.register("publishEnroLocal") { + group = "publishing" + description = "Publishes Enro libraries to Maven Local" + + workingDir = rootProject.projectDir + commandLine( + "./gradlew", + ":enro-processor:publishMavenPublicationToMavenLocal", + ":enro-annotations:publishAndroidPublicationToMavenLocal", + ":enro-annotations:publishDesktopPublicationToMavenLocal", + + "publishKotlinMultiplatformPublicationToMavenLocal", + "publishAndroidPublicationToMavenLocal", + "publishDesktopPublicationToMavenLocal", +// "publishFrontendJsPublicationToMavenLocal", + "publishIosArm64PublicationToMavenLocal", + "publishIosSimulatorArm64PublicationToMavenLocal", + + "--no-parallel", "-Dorg.gradle.workers.max=1" + ) +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..d7dbc90e4 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,59 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +repositories { + mavenLocal() + google() + mavenCentral() +} + +plugins { + `kotlin-dsl` +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} +tasks.withType() { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) + + implementation(libs.android.gradle) + implementation(libs.kotlin.gradle) + implementation(libs.compose.compiler.gradle) + implementation(libs.compose.gradle) + implementation(libs.emulator.wtf.gradle) + implementation(libs.maven.publish.gradle) + implementation(libs.dokka.gradle) +} + + +gradlePlugin { + plugins { + register("configure-application") { + id = "configure-application" + implementationClass = "ConfigureMultiplatformApplication" + } + register("configure-library") { + id = "configure-library" + implementationClass = "ConfigureMultiplatformLibrary" + } + register("configure-library-with-js") { + id = "configure-library-with-js" + implementationClass = "ConfigureMultiplatformLibraryWithJs" + } + register("configure-publishing") { + id = "configure-publishing" + implementationClass = "ConfigurePublishing" + } + register("configure-compose") { + id = "configure-compose" + implementationClass = "ConfigureCompose" + } + } +} \ No newline at end of file diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 000000000..0b9fe860d --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../libs.versions.toml")) + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ComposeExtensions.kt b/buildSrc/src/main/kotlin/ComposeExtensions.kt new file mode 100644 index 000000000..5946aa7fd --- /dev/null +++ b/buildSrc/src/main/kotlin/ComposeExtensions.kt @@ -0,0 +1,10 @@ +import org.gradle.api.plugins.ExtensionAware +import org.jetbrains.compose.ComposePlugin + +val org.gradle.api.artifacts.dsl.DependencyHandler.compose: ComposePlugin.Dependencies + get() = + (this as ExtensionAware).extensions.getByName("compose") as ComposePlugin.Dependencies + +val org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension.`compose`: org.jetbrains.compose.ComposePlugin.Dependencies + get() = + (this as org.gradle.api.plugins.ExtensionAware).extensions.getByName("compose") as org.jetbrains.compose.ComposePlugin.Dependencies diff --git a/buildSrc/src/main/kotlin/ConfigureCompose.kt b/buildSrc/src/main/kotlin/ConfigureCompose.kt new file mode 100644 index 000000000..070e975fd --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigureCompose.kt @@ -0,0 +1,84 @@ +import com.android.build.gradle.BaseExtension +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.getValue +import org.gradle.kotlin.dsl.getting +import org.gradle.kotlin.dsl.invoke +import org.gradle.kotlin.dsl.the +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class ConfigureCompose : Plugin { + override fun apply(project: Project) { + val isMultiplatform = project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") + when { + isMultiplatform -> project.configureComposeMultiplatform() + else -> project.configureComposeAndroid() + } + } +} + +internal fun Project.configureComposeAndroid() { + plugins.apply("org.jetbrains.compose") + plugins.apply("org.jetbrains.kotlin.plugin.compose") + val libs = the() + extensions.configure { + buildFeatures.compose = true + } + + dependencies { + add("implementation", libs.compose.compiler) + add("implementation", libs.compose.foundation) + add("implementation", libs.compose.foundationLayout) + add("implementation", libs.compose.ui) + add("implementation", libs.compose.uiTooling) + add("implementation", libs.compose.runtime) + add("implementation", libs.compose.viewmodel) + add("implementation", libs.compose.livedata) + add("implementation", libs.compose.activity) + add("implementation", libs.compose.material) + } +} + +internal fun Project.configureComposeMultiplatform() { + plugins.apply("org.jetbrains.compose") + plugins.apply("org.jetbrains.kotlin.plugin.compose") + + val libs = the() + val kotlinMultiplatformExtension = extensions.getByType(KotlinMultiplatformExtension::class.java) + + kotlinMultiplatformExtension.apply { + sourceSets { + val desktopMain by getting + + androidMain.dependencies { + implementation(compose.preview) + implementation(libs.compose.activity) + // The KMP library plugin has no build variants, so ui-tooling is added to + // androidMain rather than a debug-only configuration. + implementation(compose.uiTooling) + } + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(libs.compose.viewmodel) + implementation(libs.compose.bundle) + implementation(compose.material3) + implementation(compose.material) + implementation(compose.materialIconsExtended) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + } + desktopMain.dependencies { + implementation(compose.desktop.currentOs) + } + } + } + // No `buildFeatures { compose = true }` needed here — the Compose compiler is applied + // across all KMP targets (incl. Android) by the `org.jetbrains.kotlin.plugin.compose` + // plugin above. +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatformApplication.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatformApplication.kt new file mode 100644 index 000000000..5ea36b818 --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatformApplication.kt @@ -0,0 +1,49 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.the +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.compose.desktop.DesktopExtension +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +class ConfigureMultiplatformApplication : Plugin { + override fun apply(project: Project) { + project.configureMultiplatformApplication() + } +} + +internal fun Project.configureMultiplatformApplication() { + val libs = project.the() + project.plugins.apply("com.android.application") + project.configureKotlinMultiplatform( + js = false, + ) + project.plugins.apply("configure-compose") + + val compose = project.extensions.getByType(ComposeExtension::class.java) + compose as ExtensionAware + compose.extensions.configure("desktop") { + application { + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = project.projectName.packageName + packageVersion = "1.0.0" + } + } + } + + val androidExtension = project.extensions.getByType(ApplicationExtension::class.java) + androidExtension.apply { + defaultConfig { + applicationId = project.projectName.packageName + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + multiDexEnabled = true + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatformLibrary.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatformLibrary.kt new file mode 100644 index 000000000..277b236ca --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatformLibrary.kt @@ -0,0 +1,26 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project + +class ConfigureMultiplatformLibrary : Plugin { + override fun apply(project: Project) { + project.configureMultiplatformLibrary(js = false) + } +} + +class ConfigureMultiplatformLibraryWithJs : Plugin { + override fun apply(project: Project) { + project.configureMultiplatformLibrary(js = true) + } +} + +internal fun Project.configureMultiplatformLibrary( + js: Boolean, +) { + // KMP libraries with an Android target use the dedicated Android-KMP library plugin. + // The KMP plugin is applied first so the `androidLibrary {}` DSL it contributes is + // available; minSdk/compileSdk/namespace are configured there (see + // configureKotlinMultiplatform). + project.plugins.apply("org.jetbrains.kotlin.multiplatform") + project.plugins.apply("com.android.kotlin.multiplatform.library") + project.configureKotlinMultiplatform(js = js) +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ConfigurePublishing.kt b/buildSrc/src/main/kotlin/ConfigurePublishing.kt new file mode 100644 index 000000000..4a9610830 --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigurePublishing.kt @@ -0,0 +1,95 @@ + +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.plugin.extraProperties +import java.io.FileInputStream +import java.util.Properties + +class ConfigurePublishing : Plugin { + override fun apply(target: Project) { + val versionProperties = Properties() + versionProperties.load(FileInputStream(target.rootProject.file("version.properties"))) + val versionName = versionProperties.getProperty("versionName") + + val groupName = "dev.enro" + val moduleName = target.projectName.kebabCase + + target.group = groupName + target.version = versionName + + with(target) { + with(pluginManager) { + apply("com.vanniktech.maven.publish") + apply("signing") + apply("org.jetbrains.dokka") + } + + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(FileInputStream(rootProject.file("local.properties"))) + } else { + localProperties.setProperty( + "sonatypeUser", + System.getenv("PUBLISH_SONATYPE_USER") ?: "MISSING" + ) + localProperties.setProperty( + "sonatypePassword", + System.getenv("PUBLISH_SONATYPE_PASSWORD") ?: "MISSING" + ) + + localProperties.setProperty( + "signingKeyId", + System.getenv("PUBLISH_SIGNING_KEY_ID") ?: "MISSING" + ) + localProperties.setProperty( + "signingKeyPassword", + System.getenv("PUBLISH_SIGNING_KEY_PASSWORD") ?: "MISSING" + ) + localProperties.setProperty( + "signingKeyLocation", + System.getenv("PUBLISH_SIGNING_KEY_LOCATION") ?: "MISSING" + ) + } + extraProperties["signing.keyId"] = localProperties["signingKeyId"] + extraProperties["signing.password"] = localProperties["signingKeyPassword"] + extraProperties["signing.secretKeyRingFile"] = localProperties["signingKeyLocation"] + + configure { + publishToMavenCentral(automaticRelease = false) + + if (localProperties["signingKeyId"] != null && localProperties["signingKeyId"] != "MISSING") { + signAllPublications() + } + + coordinates(groupName, moduleName, versionName) + + pom { + name.set(moduleName) + description.set("A component of Enro, a small navigation library for Android") + url.set("https://github.com/isaac-udy/Enro") + licenses { + license { + name.set("Enro License") + url.set("https://github.com/isaac-udy/Enro/blob/main/LICENSE") + } + } + developers { + developer { + id.set("isaac.udy") + name.set("Isaac Udy") + email.set("isaac.udy@gmail.com") + } + } + scm { + connection.set("scm:git:github.com/isaac-udy/Enro.git") + developerConnection.set("scm:git:ssh://github.com/isaac-udy/Enro.git") + url.set("https://github.com/isaac-udy/Enro/tree/main") + } + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/Project.configureAndroid.kt b/buildSrc/src/main/kotlin/Project.configureAndroid.kt new file mode 100644 index 000000000..d24f5b456 --- /dev/null +++ b/buildSrc/src/main/kotlin/Project.configureAndroid.kt @@ -0,0 +1,98 @@ + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.gradle.BaseExtension +import com.android.build.gradle.LibraryExtension +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.the +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.io.FileInputStream +import java.util.* + +fun Project.configureAndroidLibrary( + namespace: String +) { + commonAndroidConfig(namespace = namespace) + extensions.configure { + buildFeatures { + buildConfig = true + viewBinding = false + } + } +} + +fun Project.configureAndroidApp( + namespace: String +) { + commonAndroidConfig(namespace = namespace) + extensions.configure { + buildFeatures { + buildConfig = true + viewBinding = false + } + } +} + +private fun Project.commonAndroidConfig( + namespace: String +) { + val versionProperties = Properties() + versionProperties.load(FileInputStream(rootProject.file("version.properties"))) + + extensions.configure { + val libs = project.the() + this@configure.namespace = namespace + compileSdkVersion(libs.versions.android.compileSdk.get().toInt()) + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = versionProperties.getProperty("versionCode").toInt() + versionName = versionProperties.getProperty("versionName") + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + getByName("release") { + minifyEnabled(false) + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + } + + tasks.withType() { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + + // We want to disable the automatic inclusion of the `dev.enro.annotations.AdvancedEnroApi` and `dev.enro.annotations.ExperimentalEnroApi` + // opt-ins when we're compiling the test application, so that we're not accidentally making changes that might break the public API by + // requiring the opt-ins. + if (path.startsWith(":tests:application")) { + return@compilerOptions + } + freeCompilerArgs.add("-Xopt-in=dev.enro.annotations.AdvancedEnroApi") + freeCompilerArgs.add("-Xopt-in=dev.enro.annotations.ExperimentalEnroApi") + freeCompilerArgs.add("-Xopt-in=kotlin.uuid.ExperimentalUuidApi") + } + } + + val libs = the() + dependencies { + add("implementation", libs.kotlin.stdLib) + } +} diff --git a/buildSrc/src/main/kotlin/Project.configureEmulatorWtf.kt b/buildSrc/src/main/kotlin/Project.configureEmulatorWtf.kt new file mode 100644 index 000000000..a98804e8e --- /dev/null +++ b/buildSrc/src/main/kotlin/Project.configureEmulatorWtf.kt @@ -0,0 +1,61 @@ +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.konan.properties.hasProperty +import wtf.emulator.DeviceModel +import wtf.emulator.EwExtension +import java.io.FileInputStream +import java.util.* + +fun Project.configureEmulatorWtf(numShards: Int = 2) { + extensions.configure { + + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(FileInputStream(localPropertiesFile)) + } + + when { + project.hasProperty("ewApiToken") -> { + token.set(project.properties["ewApiToken"].toString()) + } + localProperties.hasProperty("ewApiToken") -> { + token.set(localProperties["ewApiToken"].toString()) + } + else -> { + token.set(java.lang.System.getenv()["EW_API_TOKEN"]) + } + } + + this.numShards.set(numShards) + + device { + model.set(DeviceModel.PIXEL_2_ATD) + version.set(36) + } + device { + model.set(DeviceModel.PIXEL_2_ATD) + version.set(35) + } + device { + model.set(DeviceModel.PIXEL_2_ATD) + version.set(34) + } + device { + model.set(DeviceModel.PIXEL_2_ATD) + version.set(33) + } + device { + model.set(DeviceModel.PIXEL_2_ATD) + version.set(30) + } + device { + model.set(DeviceModel.PIXEL_2) + version.set(27) + } + device { + model.set(DeviceModel.PIXEL_2) + version.set(23) + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Project.configureKotlinMultiplatform.kt b/buildSrc/src/main/kotlin/Project.configureKotlinMultiplatform.kt new file mode 100644 index 000000000..e2318617c --- /dev/null +++ b/buildSrc/src/main/kotlin/Project.configureKotlinMultiplatform.kt @@ -0,0 +1,176 @@ + +import com.android.build.api.dsl.* +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.* +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.dsl.JsSourceMapEmbedMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +private val optIns = arrayOf( + "dev.enro.annotations.AdvancedEnroApi", + "dev.enro.annotations.ExperimentalEnroApi", + "kotlin.uuid.ExperimentalUuidApi", + "kotlin.io.encoding.ExperimentalEncodingApi", + "kotlin.experimental.ExperimentalObjCName", + "kotlinx.serialization.ExperimentalSerializationApi", +) + +internal fun Project.configureKotlinMultiplatform( + android: Boolean = true, + ios: Boolean = true, + wasmJs: Boolean = true, + js: Boolean = true, + desktop: Boolean = true, +) { + + project.plugins.apply("org.jetbrains.kotlin.multiplatform") + if (android) { + project.plugins.apply("org.jetbrains.kotlin.plugin.parcelize") + } + + val libs = project.the() + + val kotlinMultiplatformExtension = + project.extensions.getByType(KotlinMultiplatformExtension::class.java) + kotlinMultiplatformExtension.apply { + explicitApi = ExplicitApiMode.Strict + + if (desktop) { + jvm("desktop") { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.addAll("-Xexpect-actual-classes") + optIn.addAll(*optIns) + } + } + } + + if (wasmJs) { + wasmJs { + outputModuleName.set(project.projectName.camelCase) + browser { + commonWebpackConfig { + outputFileName = "${project.projectName.camelCase}.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + static = (static ?: mutableListOf()).apply { + // Serve sources to debug inside browser + add(project.projectDir.path) + } + } + } + compilerOptions { + sourceMap.set(true) + sourceMapEmbedSources.set(JsSourceMapEmbedMode.SOURCE_MAP_SOURCE_CONTENT_ALWAYS) + } + } + binaries.executable() + compilerOptions { + freeCompilerArgs.addAll("-Xexpect-actual-classes", "-Xwasm-attach-js-exception") + freeCompilerArgs.add("-Xwasm-kclass-fqn") + optIn.addAll(*optIns) + } + } + } + + if (js) { + js { + nodejs() + binaries.executable() + compilations["main"].packageJson { + main = "$projectName-backend.js" + version = "1.0.0" + customField("engines", mapOf("node" to "22")) + private = true + } + compilerOptions { + sourceMap.set(true) + sourceMapEmbedSources.set(JsSourceMapEmbedMode.SOURCE_MAP_SOURCE_CONTENT_ALWAYS) + } + compilerOptions.sourceMap.set(true) + compilerOptions.sourceMapEmbedSources.set(JsSourceMapEmbedMode.SOURCE_MAP_SOURCE_CONTENT_ALWAYS) + } + } + + + if (ios) { + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = project.projectName.pascalCase + isStatic = true + compilerOptions { + freeCompilerArgs.addAll("-Xexpect-actual-classes") + optIn.addAll(*optIns) + } + } + } + } + + sourceSets { + commonMain.dependencies { + implementation(kotlin("stdlib-common")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + if (android) { + androidMain.dependencies { + implementation(kotlin("stdlib")) + } + } + + if (desktop) { + val desktopMain by getting + desktopMain.dependencies { + } + } + } + } + + if (android) { + // Configure the Android library target contributed by the + // `com.android.kotlin.multiplatform.library` plugin. In build scripts this is the + // `kotlin { androidLibrary { ... } }` accessor; from a binary convention plugin we + // reach the same target via the KMP extension's ExtensionAware container. + val androidNamespace = project.projectName.packageName + val compileSdkVersion = libs.versions.android.compileSdk.get().toInt() + val minSdkVersion = libs.versions.android.minSdk.get().toInt() + // "androidLibrary" (rather than the "android" alias) so this resolves on AGP 9.0, + // whose Android plugin is the latest currently supported by IntelliJ. Both names + // refer to the same target on AGP 9.1+. + val androidLibrary = (kotlinMultiplatformExtension as ExtensionAware).extensions + .getByName("androidLibrary") as KotlinMultiplatformAndroidLibraryTarget + androidLibrary.apply { + namespace = androidNamespace + compileSdk = compileSdkVersion + minSdk = minSdkVersion + + // The KMP library plugin disables Android resource processing (and the + // generated R class) by default; enable it so modules with src/androidMain/res + // (and code that references R) build. No-op for modules without resources. + androidResources { + enable = true + } + + // Android test components (`withHostTest`/`withDeviceTest`) can each be + // enabled at most once, so they're opted into per-module rather than here + // (only enro-runtime currently runs Android host tests for its commonTest). + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.addAll( + "-P", + "plugin:org.jetbrains.kotlin.parcelize:additionalAnnotation=dev.enro.annotations.Parcelize" + ) + freeCompilerArgs.addAll("-Xexpect-actual-classes") + optIn.addAll(*optIns) + } + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Project.enroVersionName.kt b/buildSrc/src/main/kotlin/Project.enroVersionName.kt new file mode 100644 index 000000000..cec784e63 --- /dev/null +++ b/buildSrc/src/main/kotlin/Project.enroVersionName.kt @@ -0,0 +1,11 @@ +import org.gradle.api.Project +import java.io.FileInputStream +import java.util.* + + +val Project.enroVersionName: String get() { + val versionPropertiesFile = rootProject.file("version.properties") + val versionProperties = Properties() + versionProperties.load(FileInputStream(versionPropertiesFile)) + return versionProperties.getProperty("versionName") +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ProjectName.kt b/buildSrc/src/main/kotlin/ProjectName.kt new file mode 100644 index 000000000..82ee6bb02 --- /dev/null +++ b/buildSrc/src/main/kotlin/ProjectName.kt @@ -0,0 +1,105 @@ +import org.gradle.api.Project + +/** + * ProjectName will take a Gradle project path, and make it easy to use this name in different formats. Formats + * available are `packageName`, `camelCase`, and `pascalCase`. + * + * Examples: + * `:enro-runtime` + * - packageName: `dev.enro.runtime` + * - camelCase: `enroRuntime` + * - pascalCase: `EnroRuntime` + * + * `:enro:platforms:android-fragment` + * - packageName: `dev.enro.platforms.android.fragment` + * - camelCase: `enroPlatformsAndroidFragment` + * - pascalCase: `EnroPlatformsAndroidFragment` + */ +@Suppress("CanBeParameter") +class ProjectName(projectPath: String) { + + /** + * This is the package name of the project, based on the project's gradle path. + * This is the project's gradle path with colons and dashes replaced with dots. + * + * If the project path starts with "enro", it will be replaced with "dev.enro". + * + * Examples: + * `:enro-runtime` -> `dev.enro.runtime` + * `:enro:platforms:android-fragment` -> `dev.enro.platforms.android.fragment` + * `:tests:application` -> `dev.enro.tests.application` + */ + val packageName = projectPath + .replace(":", ".") + .replace("-", ".") + .dropWhile { it == '.' } + .let { + when { + it.startsWith("dev.enro") -> it + it.startsWith("enro") -> "dev.$it" + else -> "dev.enro.$it" + } + } + + /** + * This is a camelCase version of the project's package name; it is the package name with underscores and dots + * removed, and the first letter of each word capitalized. + * + * Examples: + * `:enro-runtime` -> `enroRuntime` + * `:enro:platforms:android-fragment` -> `enroPlatformsAndroidFragment` + */ + val camelCase = packageName + .removePrefix("dev.") + .fold("") { acc, c -> + val isUnderscore = acc.lastOrNull() == '_' + when { + c.isLetterOrDigit() -> when { + isUnderscore -> acc.dropLast(1) + c.uppercase() + else -> acc + c + } + + else -> acc + "_" + } + } + + /** + * This is a pascalCase version of the project's package name; it is the camelCase version with the first letter + * capitalized. + * + * Examples: + * `:enro-runtime` -> `EnroRuntime` + * `:enro:platforms:android-fragment` -> `EnroPlatformsAndroidFragment` + */ + val pascalCase = camelCase + .first() + .uppercase() + .plus(camelCase.drop(1)) + + /** + * This is a kebabCase version of the project's package name; it is the package name with dots replaced with dashes. + * + * Examples: + * `:enro-runtime` -> `enro-runtime` + * `:enro:platforms:android-fragment` -> `enro-platforms-core-fragment` + */ + val kebabCase = packageName + .removePrefix("dev.") + .replace(".", "-") + + companion object { + /** + * Creates a ProjectName object from a Gradle project. + */ + fun fromProject(project: Project): ProjectName { + return ProjectName(project.path) + } + } +} + +/** + * Creates a ProjectName object from a Gradle project. + */ +val Project.projectName: ProjectName + get() = ProjectName.fromProject(this) + diff --git a/common.gradle b/common.gradle deleted file mode 100644 index adc34d042..000000000 --- a/common.gradle +++ /dev/null @@ -1,76 +0,0 @@ -def versionProperties = new Properties() -versionProperties.load(new FileInputStream(rootProject.file("version.properties"))) - -ext.androidLibrary = { - apply plugin: 'com.android.library' - apply plugin: 'kotlin-android' - apply plugin: 'kotlin-parcelize' - - android { - compileSdkVersion 32 - - defaultConfig { - minSdkVersion 21 - targetSdkVersion 32 - versionCode versionProperties.getProperty("versionCode").toInteger() - versionName versionProperties.getProperty("versionName") - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } - - buildFeatures { - buildConfig = false - viewBinding = true - } - } - - kotlin { - explicitApi() - } - - dependencies { - implementation deps.kotlin.stdLib - } -} - -ext.useCompose = { - android { - buildFeatures { - compose true - } - composeOptions { - kotlinCompilerVersion "1.7.0" - kotlinCompilerExtensionVersion "1.2.0" - } - } - - dependencies { - implementation deps.compose.compiler - implementation deps.compose.foundation - implementation deps.compose.foundationLayout - implementation deps.compose.ui - implementation deps.compose.uiTooling - implementation deps.compose.runtime - implementation deps.compose.viewmodel - implementation deps.compose.livedata - implementation deps.compose.activity - implementation deps.compose.material - } -} diff --git a/common_publish.gradle b/common_publish.gradle deleted file mode 100644 index 940650f8a..000000000 --- a/common_publish.gradle +++ /dev/null @@ -1,191 +0,0 @@ - -def versionProperties = new Properties() -versionProperties.load(new FileInputStream(rootProject.file("version.properties"))) - -ext.versionCode = versionProperties.getProperty("versionCode").toInteger() -ext.versionName = versionProperties.getProperty("versionName") - -def privateProperties = new Properties() -def privatePropertiesFile = rootProject.file("private.properties") -if (privatePropertiesFile.exists()) { - privateProperties.load(new FileInputStream(rootProject.file("private.properties"))) -} else { - privateProperties.setProperty("githubUser", System.getenv("PUBLISH_GITHUB_USER") ?: "MISSING") - privateProperties.setProperty("githubToken", System.getenv("PUBLISH_GITHUB_TOKEN") ?: "MISSING") - - privateProperties.setProperty("sonatypeUser", System.getenv("PUBLISH_SONATYPE_USER") ?: "MISSING") - privateProperties.setProperty("sonatypePassword", System.getenv("PUBLISH_SONATYPE_PASSWORD") ?: "MISSING") - - privateProperties.setProperty("signingKeyId", System.getenv("PUBLISH_SIGNING_KEY_ID") ?: "MISSING") - privateProperties.setProperty("signingKeyPassword", System.getenv("PUBLISH_SIGNING_KEY_PASSWORD") ?: "MISSING") - privateProperties.setProperty("signingKeyLocation", System.getenv("PUBLISH_SIGNING_KEY_LOCATION") ?: "MISSING") -} - -ext.publishAndroidModule = { String groupName, String moduleName, String versionSuffix = "" -> - publishModule(true, groupName, moduleName, versionSuffix) -} - -ext.publishJavaModule = { String groupName, String moduleName, String versionSuffix = "" -> - publishModule(false, groupName, moduleName, versionSuffix) -} - -ext.publishModule = { Boolean isAndroid, String groupName, String moduleName, String versionSuffix = "" -> - apply plugin: 'maven-publish' - apply plugin: 'signing' - - ext["signing.keyId"] = privateProperties['signingKeyId'] - ext["signing.password"] = privateProperties['signingKeyPassword'] - ext["signing.secretKeyRingFile"] = privateProperties['signingKeyLocation'] - - if(isAndroid) { - task androidSourcesJar(type: Jar) { - archiveClassifier.set('sources') - from android.sourceSets.main.java.srcDirs - } - - artifacts { - archives androidSourcesJar - } - } - else { - javadoc { - source = sourceSets.main.allJava - classpath = configurations.compileClasspath - options { - setMemberLevel JavadocMemberLevel.PUBLIC - setAuthor true - links "https://docs.oracle.com/javase/8/docs/api/" - } - } - task sourcesJar(type: Jar) { - archiveClassifier.set('sources') - from sourceSets.main.java.srcDirs - } - task javadocJar(type: Jar) { - archiveClassifier.set('javadoc') - from javadoc - } - artifacts { - archives sourcesJar - archives javadocJar - } - } - - afterEvaluate { - group = groupName - version = versionName + versionSuffix - - publishing { - publications { - release(MavenPublication) { - if(isAndroid) { - from components.release - } - else { - from components.java - } - - groupId groupName - artifactId moduleName - version versionName + versionSuffix - - if(isAndroid) { - artifact androidSourcesJar - } - else { - artifact sourcesJar - artifact javadocJar - } - - pom { - name = moduleName - description = "A component of Enro, a small navigation library for Android" - url = "https://github.com/isaac-udy/Enro" - licenses { - license { - name = 'Enro License' - url = 'https://github.com/isaac-udy/Enro/blob/main/LICENSE' - } - } - developers { - developer { - id = 'isaac.udy' - name = 'Isaac Udy' - email = 'isaac.udy@gmail.com' - } - } - scm { - connection = 'scm:git:github.com/isaac-udy/Enro.git' - developerConnection = 'scm:git:ssh://github.com/isaac-udy/Enro.git' - url = 'https://github.com/isaac-udy/Enro/tree/main' - } - - if(isAndroid) { - withXml { - def dependenciesNode = asNode().getAt('dependencies')[0] ?: asNode().appendNode('dependencies') - - // Iterate over the implementation dependencies (we don't want the test ones), adding a node for each - configurations.implementation.allDependencies.each { - // Ensure dependencies such as fileTree are not included. - if (it.name != 'unspecified') { - def dependencyNode = dependenciesNode.appendNode('dependency') - dependencyNode.appendNode('groupId', it.group) - dependencyNode.appendNode('artifactId', it.name) - dependencyNode.appendNode('version', it.version) - } - } - } - } - } - } - } - - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/isaac-udy/Enro") - credentials { - username = privateProperties['githubUser'] - password = privateProperties['githubToken'] - } - } - } - - repositories { - maven { - // This is an arbitrary name, you may also use "mavencentral" or - // any other name that's descriptive for you - name = "sonatype" - url = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" - credentials { - username privateProperties['sonatypeUser'] - password privateProperties['sonatypePassword'] - } - } - } - } - - if (privateProperties['signingKeyId'] != "MISSING") { - signing { - sign publishing.publications - } - } - } - - afterEvaluate { - if(isAndroid) { - tasks.findByName("publishToMavenLocal") - .dependsOn("assembleRelease") - } - else { - tasks.findByName("publishToMavenLocal") - .dependsOn("assemble") - } - - tasks.findByName("publish") - .dependsOn("publishToMavenLocal") - - tasks.findByName("publishAllPublicationsToSonatypeRepository") - .dependsOn("publishToMavenLocal") - } -} \ No newline at end of file diff --git a/docs/NAV3-COMPARISON.md b/docs/NAV3-COMPARISON.md new file mode 100644 index 000000000..22c5e7984 --- /dev/null +++ b/docs/NAV3-COMPARISON.md @@ -0,0 +1,185 @@ +# Enro ↔ AndroidX Navigation3 alignment + +Enro's rendering, scene model, and decorator infrastructure is intentionally +kept close to [AndroidX Navigation3](https://developer.android.com/jetpack/androidx/releases/navigation3) +so that: + +1. Nav3 patterns translate to Enro almost line-for-line — if you know how a + Nav3 `Scene`, `SceneStrategy`, or `NavEntryDecorator` works, you can + write the Enro equivalent without re-learning the shape. +2. Bug fixes and performance improvements in Nav3 can be ported into Enro + with minimal translation work. + +What Enro **adds on top of** Nav3 — typed `NavigationHandle`s, typed results, +`NavigationOperation`s, interceptors, plugins, a controller-mediated binding +system, `NavigationContainer` hierarchy, `NavigationFlow`s — is documented +separately and not addressed here. + +## How to read this document + +For each subsystem, this doc maps the Nav3 type/API onto its Enro +counterpart, and lists every place Enro **deliberately diverges**. If you +find a divergence in code that isn't listed here, treat it as a candidate +for alignment — either fix it, or add it to this doc with a justification. + +Code that knowingly diverges from Nav3 carries an inline comment of the form: + +```kotlin +// Differs from Nav3's : . +// See docs/NAV3-COMPARISON.md#
. +``` + +## Type mapping (quick reference) + +| Nav3 | Enro | Notes | +|--|--|--| +| `NavKey` | `NavigationKey` | Marker interface for keys. | +| `NavEntry` | `NavigationDestination` | Carries metadata + content. Enro wraps `NavigationKey.Instance` instead of taking a raw key. | +| `NavEntry.contentKey` | `NavigationDestination.id` (from `instance.id`) | Stable id used for `movableContent`, `sharedElement` keys, etc. | +| `NavEntry.Content()` | `NavigationDestination.Content()` | Capital C; identical invocation pattern. | +| `NavEntryDecorator` | `NavigationDestinationDecorator` | Same constructor shape: `onPop` + `decorate`. | +| `rememberDecoratedNavEntries(...)` | `rememberDecoratedDestinations(...)` | Same fold-right decorator chain. | +| `Scene` | `NavigationScene` | Same surface: `key`, `entries`, `previousEntries`, `content`. Enro `NavigationScene` is non-generic because all entries are `NavigationDestination`. | +| `OverlayScene` | `NavigationScene.Overlay` | Same `overlaidEntries` concept. | +| `SceneStrategy` | `NavigationSceneStrategy` | Same `calculateScene(entries)` shape. | +| `SinglePaneSceneStrategy` | `dev.enro.ui.scenes.SinglePaneSceneStrategy` | Same fallback semantics. | +| `DialogSceneStrategy` | `dev.enro.ui.scenes.DialogSceneStrategy` | Same metadata-driven activation. | +| `NavDisplay(...)` | `NavigationDisplay(state, ...)` | Same `AnimatedContent`-driven rendering. | +| `AnimatedSceneKey` | `SceneIdentity` (private) | `(class, key, containerKey)`. Used as the `AnimatedContent.contentKey`. | +| `SceneState` | `NavigationSceneState` | Hoistable scene-hierarchy snapshot. Same fields: `entries`, `overlayScenes`, `currentScene`, `previousScenes`. | +| `rememberSceneState` | `rememberNavigationSceneState` | Computes the state; consumed internally by `NavigationDisplay` if not hoisted. | +| `SceneStrategyScope` | `SceneStrategyScope` | Carries the `onBack` callback for scene-internal back affordances. Receiver of `NavigationSceneStrategy.calculateScene`. | +| `SceneDecoratorStrategy` | `SceneDecoratorStrategy` | Wraps a non-overlay scene in another scene to add chrome (drawer, app bar, nav rail). Passed via `NavigationDisplay(sceneDecoratorStrategies = ...)`. | +| `SceneDecoratorStrategyScope` | `SceneDecoratorStrategyScope` | Receiver of `SceneDecoratorStrategy.decorateScene`. Extends `SceneStrategyScope`. | +| `Scene.metadata` | `NavigationScene.metadata` | Defaults to the last entry's metadata. Used by `NavigationDisplay` to look up per-scene transition overrides. | +| `NavMetadataKey` (for scene metadata) | `NavigationScene.MetadataKey` | Parallel to `NavigationDestination.MetadataKey`; same shape, scoped to scene-level metadata. | +| `NavDisplay.TransitionKey` etc. | `NavigationDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-scene transition overrides. Looked up before falling back to `NavigationAnimations` defaults. | +| `SceneInfo` | `NavigationSceneInfo` | Wraps the current scene as a `NavigationEventInfo` for predictive back handlers. | +| `OverlayScene.onRemove()` (suspend) | `NavigationScene.Overlay.onRemove()` | Runs after the overlay pops from the backstack, before it leaves composition. The display awaits it. | +| `predictivePopTransitionSpec(swipeEdge)` | `predictivePopTransitionSpec` in `NavigationAnimations` (and the `PredictivePopTransitionKey` value) now take a `swipeEdge: Int`. | Mirrors Nav3's swipe-edge plumbing. | +| `LocalNavAnimatedContentScope` | `LocalNavigationAnimatedVisibilityScope` | Same `AnimatedVisibilityScope` provided. | +| `SharedEntryInSceneNavEntryDecorator` | `sharedElementDecorator()` | Auto-wraps entries in `Modifier.sharedElement` keyed by instance id. | +| `SceneSetupNavEntryDecorator` | `movableContentDecorator()` | Wraps entries in `movableContentOf` and gates rendering by exclusion. | +| `BackStackAwareLifecycleNavEntryDecorator` | `navigationLifecycleDecorator(...)` | Provides per-destination `LifecycleOwner` capped at the destination's state. | +| `SaveableStateHolderNavEntryDecorator` | `savedStateDecorator(...)` | Saveable + saved state registries. Enro's is more capable but the factory shape mirrors Nav3. | +| `NavMetadataKey` | `NavigationDestination.MetadataKey` | Typed metadata keys, aligned with the existing `NavigationKey.MetadataKey`. `metadata.get(key)` and `metadata.contains(key)` operators are extensions on `Map` in `dev.enro.ui` — import them via `import dev.enro.ui.get` / `import dev.enro.ui.contains` outside that package. | + +## Deliberate divergences (and why) + +This list is the source of truth for "Enro doesn't match Nav3 here, on purpose." + +- **`NavigationScene` is non-generic.** Nav3 carries `T : Any` through + `Scene` so the scene knows the key type. Enro's key system is closed + (`NavigationKey` hierarchy) and the typed information lives in the + `NavigationKey.Instance` carried by each `NavigationDestination`. + Adding a type parameter to `NavigationScene` doesn't buy anything. + +- **`SceneTransitionData` carries `containerKey` / `visible` / + `previouslyVisible`.** Nav3 passes the raw `Scene` through the + `transition.AnimatedContent` so transition specs see the whole scene + (and have access to `entries`). Enro deliberately keeps the + transition-spec signature on `SceneTransitionData` (which exposes only + `NavigationKey.Instance`s, not the full `NavigationDestination`s) — the + transition spec should reason about visible **keys**, not destinations. + +- **`NavigationDestinationDecorator` is `open class`, not `Immutable`.** + Nav3 marks decorators `@Immutable`; Enro doesn't, to allow subclassing + for decorator-author ergonomics. The decorator's stored callbacks are + still effectively immutable. + +- **`savedStateDecorator` uses a custom `NavigationSavedStateHolder`.** + Nav3's `rememberSaveableStateHolderNavEntryDecorator` wraps each entry + with `SaveableStateProvider`. Enro maintains both a `SavedStateRegistry` + and a `SaveableStateRegistry` per destination so that AndroidX libraries + expecting a `SavedStateRegistryOwner` (Fragments, libraries persisting + through `SavedStateRegistry`) work without extra wiring. + +- **`navigationContextDecorator` is Enro-only.** Provides each destination + with a `NavigationHandle`, a `NavigationContext`, and the result/flow + routing. This is the core of Enro's API surface and has no Nav3 analog. + +- **Composition tracking + `onPop` dispatch lives in an innermost + `compositionTrackingDecorator`, not in `decorateNavigationDestination`'s + outer wrap.** Nav3's `decorateEntry` registers the equivalent + `DisposableEffect` at the *outermost* wrap — the wrapped entry's + `Content()` runs `DisposableEffect(...) { … }` *before* invoking the + decorator chain. Enro can't mirror that shape because some Enro + decorators' `onPop` callbacks tear down state that other decorators + read at composition time: + + - `viewModelStoreDecorator.onPop` clears the destination's child + `ViewModelStore`, which `LocalNavigationContext.current` consumers + read via `viewModel(viewModelStoreOwner = …)` to retrieve the + `NavigationHandleHolder`. + - `savedStateDecorator.onPop` transitions the destination's + `LifecycleRegistry` to `DESTROYED`, which + `DestinationDisposedEffect.onDispose` would otherwise try to push + back to `CREATED`. + + When the tracking `DisposableEffect` is in the outer wrap (outside + the `movableContent` that `movableContentDecorator` sets up), its + `onDispose` fires when the outer call-site disposes — earlier than + Compose's `disposeUnusedMovableContent` actually tears down the + inner slot table. A recomposition that lands between "outer `onPop` + fired" and "movable content discarded" sees the half-torn-down state + and crashes with either `IllegalStateException: No NavigationHandle + found` (the holder lookup races the `ViewModelStore` clear) or + `IllegalStateException: State is 'DESTROYED' and cannot be moved to + 'CREATED'` (the lifecycle transition race). + + Putting the tracking `DisposableEffect` in an innermost decorator + places it **inside** the movable content. Its `onDispose` then runs + in the same synchronous slot-table-teardown pass as the inner + `CompositionLocalProvider`s (saved state, view-model store, + navigation context). There is no window in which `onPop` has fired + but a downstream recompose can still consult the (now-gone) state. + + Nav3 doesn't hit this because its bundled decorators' `onPop` + callbacks are lightweight map removals (`SaveableStateHolder.removeState`, + `movableContentMap.remove`) — nothing else in the chain reads what + they tear down. Enro stacks more responsibilities on `onPop`, and + the safe disposal-order invariant requires keeping the tracking + inside the chain. + + Concrete shape: + - `decorateNavigationDestination` is a pure `foldRight`, the same as + Nav3's `decorateEntry` minus the outer `DisposableEffect`. + - `rememberDecoratedDestinations` appends a + `compositionTrackingDecorator` to the decorator list as the **last** + element. `foldRight` makes the last element the innermost wrap, so + the tracker's `DisposableEffect` is composed inside the movable + content. The tracker fires `onPop` on every other decorator in + reverse decoration order when the destination has left both the + backstack and composition. + - `PrepareBackStack` is the same as Nav3 — it catches the inverse + case (destination left the backstack while not in composition). + +- **`EmptyBehavior`, `NavigationContainer`, `NavigationOperation`, + `NavigationInterceptor`, `NavigationPlugin`, `EnroController`, + `NavigationKey.WithResult`, `registerForNavigationResult`, + `NavigationFlow`** — Enro features Nav3 doesn't have. They sit on + top of the rendering/scene model rather than inside it, so + cross-framework alignment doesn't apply. + +## Out-of-scope (Nav3 features Enro hasn't ported) + +- Deep-link request / matcher (Nav3 has `DeepLinkRequest`/`DeepLinkMatcher`). + Enro has its own path/deeplink system in `enro-runtime/.../path/`. Not + comparable line-for-line. +- `EntryProvider` DSL. Enro uses `@NavigationDestination` annotations + + KSP-generated bindings, registered through `EnroController`. + +## Known gaps + +Differences from Nav3 that aren't yet aligned but aren't permanent either — +either they're on the roadmap or they need a deliberate API change. + +- **Factory-style scene strategies.** Nav3's `SceneStrategy` is a plain + (non-`@Composable`) interface; strategies that need Compose state expose + a `rememberXxxStrategy()` factory that captures it. + `NavigationSceneStrategy.calculateScene` is still `@Composable` and + reads Compose locals directly. Migrating means dropping the + `@Composable` annotation and reshaping the built-in plus recipe + strategies (`ListDetailSceneStrategy`, `TwoPaneSceneStrategy`, + `DoublePaneScene`, ...) to capture their Compose-side inputs through + a `remember…()` factory. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 04ffc91aa..000000000 --- a/docs/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Enro Documentation -[Architecture](./architecture.md) - -[Troubleshooting](./troubleshooting.md) \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 90b7381c7..000000000 --- a/docs/architecture.md +++ /dev/null @@ -1,66 +0,0 @@ -# Architecture -The purpose of this documentation is to provide information on how Enro works internally. - -## Goals -The goal of Enro is to allow navigation between the screens of an application without requiring the a screen to have knowledge about the implementation of the screens that it wants to navigate to. Fundamentally, this means that the contract for each screen must be able to be created before the screen has been implemented, and must be able to live in a separate compilation unit to the screen's implementation. A screen implementation should depend on the contract, but the contract should not know about the screen. - -In addition to this key goal of Enro, there are several additional additional goals: -- Allow many different types of screens to be used in Enro (Activity, Fragment, Composable) and allow these screens to interoperate as seamlessly as possible. -- Allow the contracts for screens to be strongly typed -- Allow the contracts for screens to define an output type and return results of that output type -- Allow navigation to (optionally) be managed at the ViewModel layer of an application -- Generate as much of the navigation infrastructure code as possible - - -## Core Concepts -Enro has several named concepts which need to be understood. - -### NavigationDestination -A NavigationDestination is a "screen" within an application. Generally these are implemented as an Activity, Fragment, or Composable function. A NavigationDestination is bound to a particular NavigationKey. A NavigationDestination is a concept, not an actual object, and is represented by an annotation in Enro. This annotation takes a NavigationKey class as an argument, and represents that the Activity/Fragment/Composable *is a* NavigationDestination for a particular NavigationKey. - -#### SyntheticDestination -A "SyntheticDestination" is a special type of NavigationDestination. A SyntheticDestination is an interface, which can be declared as a NavigationDestination for a particular NavigationKey and will be invoked whenever navigation to that NavigationKey is requested. This can be used as a bridge between Enro and external libraries or to perform some custom action. - -Example: A SyntheticDestination which handles a NavigationKey called "ExternalLink(val url: String)", which uses the androidx.browser library to launch a browser session to the provided url. - -Example: A SyntheticDestination which handles a NavigationKey called "NotImplementedYet", which does nothing on the screen, but prints a message in the console saying that the destination has not been implemented. - -Example: A NavigationDestination in your application has been re-written, and you want to A/B test the new implementation against the old implementation. A SyntheticDestination could be defined which would launch either the old or the new NavigationDestination based on some criteria. - -### NavigationKey -A NavigationKey represents the contract for a particular NavigationDestination and are the objects used to perform navigation. A NavigationKey defines the inputs/arguments for a NavigationDestination. - -A NavigationKey must be parcelable, as it will be stored in bundles for Activity Intents or Fragment Arguments, and will be written to saved instance state bundles. - -### NavigationHandle -A NavigationHandle is the object that controls navigation within a particular NavigationDestination. A NavigationHandle has a reference to the NavigationKey that was used to open the NavigationDestination they are associated with. A NavigationHandle is what is used to perform navigation by executing NavigationInstructions. - -### NavigationContext -A NavigationContext represents a reference to a Fragment, Activity or Composable in which navigation can occur. - -### NavigationInstruction -A NavigationInstruction represents some action that a particular NavigationHandle should perform. Currently, there are three top level types of NavigationInstruction: Open, Close, and RequestClose. - -#### Open -A NavigationInstruction.Open opens the NavigationDestination associated with a particular NavigationKey. - -#### Close -A NavigationInstruction.Close closes the NavigationDestination that is associated with the NavigationHandle the instruction is executed on. - -#### RequestClose -A NavigationInstruction.RequestClose requests that the NavigationHandle it is executed on performs a NavigationInstruction.Close action. This is a "softer" version of the close request, and is executed by things such as a user pressing the "back" key. NavigationHandles can be configured to perform a custom action when a RequestClose instruction is executed. For example, this might be used to confirm that unsaved changes will be discarded before the NavigationDestination is actually closed. - -### Navigator -A Navigator is the object that is used to directly represent binding between a NavigationKey type and a NavigationDestination type. - -### NavigationExecutor -A NavigationExecutor is the object that executes NavigationInstructions. - -When a NavigationInstruction.Open is executed, the NavigationController finds the appropriate NavigationExecutor and provides it with the NavigationInstruction.Open that is being executed, the NavigationContext in which the instruction is being executed, and the Navigator that contains the NavigationDestination type. It is then the responsibility of the NavigationExecutor to open that NavigationDestination. - -When a NavigationInstruction.Close is executed, the NavigationController finds the appropriate NavigationExecutor and provides it with the NavigationContext in which the instruction is being executed. It is then the responsibility of the NavigationExecutor to close that NavigationContext appropriately. - -### NavigationController -The NavigationController is a Singleton object which is bound to the Application's lifecycle. The NavigationController stores all the Navigators and NavigationExecutors for the application. - - diff --git a/example/src/main/res/font/cutive_mono.ttf b/docs/ghpages/assets/fonts/cutive_mono.ttf similarity index 100% rename from example/src/main/res/font/cutive_mono.ttf rename to docs/ghpages/assets/fonts/cutive_mono.ttf diff --git a/docs/ghpages/assets/images/beyond-budget-icon.png b/docs/ghpages/assets/images/beyond-budget-icon.png new file mode 100644 index 000000000..bdd8532eb Binary files /dev/null and b/docs/ghpages/assets/images/beyond-budget-icon.png differ diff --git a/docs/ghpages/assets/images/git-icon.png b/docs/ghpages/assets/images/git-icon.png new file mode 100644 index 000000000..1b822efb4 Binary files /dev/null and b/docs/ghpages/assets/images/git-icon.png differ diff --git a/docs/ghpages/assets/images/splitwise-icon.png b/docs/ghpages/assets/images/splitwise-icon.png new file mode 100644 index 000000000..0a7d7cde3 Binary files /dev/null and b/docs/ghpages/assets/images/splitwise-icon.png differ diff --git a/docs/ghpages/docs/advanced/animations.md b/docs/ghpages/docs/advanced/animations.md new file mode 100644 index 000000000..7e4e20e72 --- /dev/null +++ b/docs/ghpages/docs/advanced/animations.md @@ -0,0 +1,326 @@ +--- +title: Animations +parent: Advanced Topics +nav_order: 3 +--- + +# Animations + +Enro animates navigation transitions through `NavigationDisplay`, using a +`NavigationAnimations` data class to describe the enter/exit transitions +between scenes. Inside a destination, per-element animation primitives +(`Modifier.animateNavigationEnterExit`, `NavigationAnimatedVisibility`) let +you animate parts of a screen on their own timing, alongside the +destination-level transition. + +## Backgrounds and transitions + +> A destination that participates in animations should have an **opaque +> background**. Compose destinations are transparent by default — whatever +> is composed behind them shows through. + +During a transition, two destinations are composed at once (the outgoing +one fading or sliding out, the incoming one fading or sliding in), and +any shared elements move over the top. If either destination is +transparent, the two overlap visibly and the result looks unpolished — +text on one screen reading "through" content on the other. + +The fix is to wrap each non-overlay destination in a `Surface` (or any +opaque container) coloured with your theme's background. Two ways to do +it: + +**Per destination** — apply the background at the destination's root: + +```kotlin +@Composable +@NavigationDestination(MyScreen::class) +fun MyScreenDestination() { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + // ... + } +} +``` + +**Globally** — add a `decorator` on your `NavigationComponent` so every +destination is wrapped automatically. This is what the recipes app does +(see [`RecipesComponent`][recipes-component]): + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + decorator { + navigationDestinationDecorator { destination -> + if (destination.isDirectOverlay() /* … and not a dialog */) { + destination.content() + } else { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + destination.content() + } + } + } + } + } +) +``` + +Skip dialogs and direct overlays — they're intentionally drawn over the +top of another destination and need to stay transparent. + +## NavigationAnimations + +A `NavigationAnimations` is a pair of transition specs — one for forward +navigation, one for pop: + +```kotlin +NavigationDisplay( + state = container, + animations = NavigationAnimations( + transitionSpec = { + ContentTransform( + targetContentEnter = slideInHorizontally { it } + fadeIn(tween(200)), + initialContentExit = slideOutHorizontally { -it / 3 } + fadeOut(tween(150)), + ) + }, + popTransitionSpec = { + ContentTransform( + targetContentEnter = slideInHorizontally { -it / 3 } + fadeIn(tween(200)), + initialContentExit = slideOutHorizontally { it } + fadeOut(tween(150)), + ) + }, + ), +) +``` + +The transition specs run in an `AnimatedContentTransitionScope`, so you have +access to everything Compose's `AnimatedContent` API offers. + +`NavigationAnimations.Default` is the default, and is what you'll get if +you don't pass an `animations` argument. The [animated navigation +recipe][animations-recipe] has a toggle that switches between several +common styles (horizontal slide, vertical slide, scale + fade, none). + +## Per-element animations inside a destination + +The destination-level transition animates the whole destination as a single +unit. To animate parts of a destination on their own timing — for example, +to fade a dialog scrim while sliding the card on a different curve — use +the two helpers in `dev.enro.ui.animation`. + +### `Modifier.animateNavigationEnterExit` + +The simplest hook. Attach it to any element that should have its own enter +and exit transitions, tied to the destination's enter/exit. + +```kotlin +Box( + Modifier + .matchParentSize() + .animateNavigationEnterExit( + enter = fadeIn(tween(durationMillis = 320)), + exit = fadeOut(tween(durationMillis = 220)), + ) + .background(Color.Black.copy(alpha = 0.5f)), +) +``` + +The element fades in when the destination enters and out when the +destination leaves, on whatever curve you specify. + +### `NavigationAnimatedVisibility` + +When you want a child block to actually appear or disappear in step with +the destination's enter/exit — with its own delay, its own curve, or both — +use `NavigationAnimatedVisibility`: + +```kotlin +NavigationAnimatedVisibility( + enter = fadeIn(tween(durationMillis = 220, delayMillis = 140)), + exit = fadeOut(tween(durationMillis = 100)), +) { + Row { /* action buttons that come in slightly later than the rest */ } +} +``` + +This is the right tool when you want a staggered reveal of inner content +that respects the destination's overall lifecycle. + +## When to use which + +- **Whole-screen transition** → `NavigationDisplay(animations = ...)`. +- **One element on its own timing, no delay tricks** → `Modifier.animateNavigationEnterExit`. +- **Delayed or staggered reveal of inner content** → `NavigationAnimatedVisibility`. + +A worked example combining all three (animated container transition for the +screen, then per-element animations for the dialog scrim, card, and +buttons) lives in the [staggered animations recipe][staggered-recipe]. + +## Predictive back + +`NavigationDisplay` participates in Android's predictive back gesture +automatically — when the user starts a back gesture, the popTransitionSpec +plays in reverse, tracking the gesture progress. You don't need to +configure anything for the default behaviour; the predictive-back +animations the user sees are the same `popTransitionSpec` you defined +running on a different driver. + +## Shared element animations + +Compose's shared-element transitions animate the bounds and contents of a +matched pair of elements as the user navigates between destinations — the +list-item thumbnail "grows" into the detail screen's hero image, for +example. Enro wires the necessary scopes for you: `NavigationDisplay` +wraps its rendered destinations in a `SharedTransitionLayout` and exposes +the two CompositionLocals every shared element needs. + +The two CompositionLocals live in `dev.enro.ui`: + +| Local | What it is | What it's for | +|---|---|---| +| `LocalNavigationSharedTransitionScope` | A `SharedTransitionScope` | Hosts `Modifier.sharedElement`, `Modifier.sharedBounds`, `rememberSharedContentState`, etc. Read it once per destination. | +| `LocalNavigationAnimatedVisibilityScope` | The `AnimatedVisibilityScope` for the destination's enter/exit | Passed to `sharedElement` so it knows when this side of the transition is appearing or disappearing. | + +The pattern is always the same: + +1. Pick a stable key for each element you want to share. +2. On the **source** destination, tag the element with that key. +3. On the **target** destination, tag the matching element with the same key. + +```kotlin +@Composable +@NavigationDestination(ItemList::class) +fun ItemListDestination() { + val sharedTransitionScope = LocalNavigationSharedTransitionScope.current + val animatedVisibilityScope = LocalNavigationAnimatedVisibilityScope.current + + LazyColumn { items(myItems) { item -> + with(sharedTransitionScope) { + Image( + modifier = Modifier + .sharedElement( + rememberSharedContentState(key = "thumb-${item.id}"), + animatedVisibilityScope = animatedVisibilityScope, + ) + .size(48.dp), + // ... + ) + } + }} +} + +@Composable +@NavigationDestination(ItemDetail::class) +fun ItemDetailDestination() { + val navigation = navigationHandle() + val sharedTransitionScope = LocalNavigationSharedTransitionScope.current + val animatedVisibilityScope = LocalNavigationAnimatedVisibilityScope.current + + with(sharedTransitionScope) { + Image( + modifier = Modifier + .sharedElement( + rememberSharedContentState(key = "thumb-${navigation.key.id}"), + animatedVisibilityScope = animatedVisibilityScope, + ) + .size(160.dp), + // ... + ) + } +} +``` + +When `ItemDetail` opens, Compose finds the element on `ItemList` tagged +`"thumb-${item.id}"` and animates the bounds and contents into the matched +element on the detail screen. When the user navigates back, the same +animation plays in reverse. + +### Picking keys + +Keys must match between the source and the target. They can be anything +serializable to a stable string — the item's id is the usual choice. +Mixing in a content type avoids accidental collisions when several +sharable elements per item participate (a thumbnail and a title, say): + +```kotlin +private fun thumbKey(id: Int) = "thumb-$id" +private fun titleKey(id: Int) = "title-$id" +``` + +Stable keys also means stable across recompositions — don't compute them +from a `remember`-ed value that could change. + +### Inside the `navigationDestination { }` provider form + +If you're using the provider-val style (`val foo = +navigationDestination { ... }`), the content lambda's receiver +`NavigationDestinationScope` already implements both +`SharedTransitionScope` and `AnimatedVisibilityScope`. You can call +`sharedElement`, `sharedBounds`, and `rememberSharedContentState` directly +on the receiver and pass `this` (or `this@navigationDestination`) as the +`animatedVisibilityScope`: + +```kotlin +@NavigationDestination(ItemDetail::class) +val itemDetailDestination = navigationDestination { + Image( + modifier = Modifier.sharedElement( + rememberSharedContentState(key = thumbKey(key.id)), + animatedVisibilityScope = this@navigationDestination, + ), + // ... + ) +} +``` + +Useful when the destination is short and you want to avoid the two +`Local*` reads at the top. + +### Beyond `sharedElement` + +Anything Compose's `SharedTransitionScope` supports works inside an Enro +destination — `Modifier.sharedBounds` (for elements whose contents differ +but whose bounds should animate), the `SharedContentState` overload that +takes a `BoundsTransform`, manual `OverlayClip` configuration, etc. You +won't need any Enro-specific plumbing past reading the two CompositionLocals. + +### When shared elements don't fire + +A few common gotchas, in order of likelihood: + +- **Key mismatch.** Double-check the key strings produce the same value on + both sides — a stray space or a type mismatch (`"$id"` vs + `"${id.toString()}"` is fine; `id.hashCode()` vs `id` is not). +- **Both destinations didn't render together.** Shared transitions need + the source destination to still be on the backstack while the target + is composing. If you replace the source instead of pushing on top of it, + there's nothing to animate from. +- **One side is rendered outside `NavigationDisplay`.** Only destinations + rendered by `NavigationDisplay` get the surrounding + `SharedTransitionLayout`. Embedded subscreens or popups that bypass it + won't participate. + +A full runnable example lives in the [shared-elements +recipe][sharedelements-recipe], with a list-to-detail flow over a few +sample items. + +## See also + +- [Animated navigation recipe][animations-recipe] — switching between + several transition styles. +- [Staggered animations recipe][staggered-recipe] — + `Modifier.animateNavigationEnterExit` + `NavigationAnimatedVisibility` + combined inside both an overlay and a regular pushed destination. +- [Shared elements recipe][sharedelements-recipe] — list-to-detail with + shared icon and title. +- [Navigation Destinations → Custom enter/exit on overlays](../core-concepts/navigation-destinations.md#custom-enterexit-on-overlays). + +[animations-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/animations/AnimatedNavigation.kt +[staggered-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/animations/StaggeredAnimations.kt +[sharedelements-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/sharedelements/SharedElementAnimations.kt +[recipes-component]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/RecipesComponent.kt diff --git a/docs/ghpages/docs/advanced/index.md b/docs/ghpages/docs/advanced/index.md new file mode 100644 index 000000000..5213804a4 --- /dev/null +++ b/docs/ghpages/docs/advanced/index.md @@ -0,0 +1,32 @@ +--- +title: Advanced Topics +nav_order: 4 +has_children: true +--- + +# Advanced Topics + +Patterns that build on the core concepts. + +- [Results](results.md) — `NavigationKey.WithResult`, + `complete(value)`, and `registerForNavigationResult`. With two + sub-pages on chaining multi-step flows: [embedded + flows](results/embedded-result-flows.md) for short callback chains, + [managed flows](results/managed-result-flows.md) for longer or + branching sequences. +- [View Models](view-models.md) — `by navigationHandle()` inside + a ViewModel, `createEnroViewModel { }` from a Composable, and shared + state across destinations. +- [Animations](animations.md) — `NavigationAnimations`, + per-element animation primitives, predictive back, and shared + elements. +- [Testing](testing.md) — `runEnroTest`, `EnroTestRule`, + `TestNavigationHandle`, and the assertion surface. +- [Plugins](plugins.md) — observe every destination's lifecycle (open, + active, close) for cross-cutting concerns like analytics, telemetry, + and instance-metadata tagging. +- [Synthetic Destinations](synthetic-destinations.md) — `NavigationKey`s + whose "destination" is a block of code instead of a screen. Bridges + to non-Enro side effects, conditional redirects, and runtime "decider" + patterns that pick between several implementations of a single + result-bearing contract. diff --git a/docs/ghpages/docs/advanced/path-bindings.md b/docs/ghpages/docs/advanced/path-bindings.md new file mode 100644 index 000000000..820bc0fa9 --- /dev/null +++ b/docs/ghpages/docs/advanced/path-bindings.md @@ -0,0 +1,239 @@ +--- +title: Path Bindings +parent: Advanced Topics +nav_order: 7 +--- + +# Path Bindings + +A path binding maps a URL path pattern to a `NavigationKey` type, and +back again. Once a key has a binding, the runtime can: + +- **Resolve a path to a key** — turn `/products/abc?source=email` into + `ProductDetail(productId = "abc", source = "email")` for deep-link + handling. +- **Serialise a key to a path** — turn that same key back into the URL + the web history plugin shows in the address bar. + +The same `@NavigationPath` annotation works on every platform. On Web +it drives the URL bar; on Android / iOS / Desktop it backs deep-link +parsing without you having to wire up Intent filters or custom URL +schemes yourself. (Inbound deep links still arrive through the +platform's own mechanism — `intent.data`, `application(_:openURL:)`, +custom URI handlers — but once you have the string, `getNavigationKeyFromPath` +turns it into a key you can `open()`.) + +`@NavigationPath` and its companions are currently marked +`@ExperimentalEnroApi` while the API surface stabilises through the +3.x cycle. The shape is unlikely to shift meaningfully, but expect +small refinements before the API graduates. + +## Simple usage + +Annotate the key with `@NavigationPath("/pattern/with/{placeholders}")`: + +```kotlin +@Serializable +@NavigationPath("/products/{productId}") +data class ProductDetail( + val productId: String, +) : NavigationKey +``` + +The processor reads the pattern, matches each `{placeholder}` to a +property on the key (by name), and generates the binding. At runtime +the controller will accept `/products/shoe-1` and produce +`ProductDetail("shoe-1")`; calling `controller.getPathFromNavigationKey(ProductDetail("shoe-1"))` +returns `/products/shoe-1`. + +Properties that aren't part of the path produce a compile-time error +unless they have default values — there has to be something for the +deserialiser to plug into them. + +## Query parameters and optionals + +Append `?key={value}` (or `&key={value}`) for query parameters. Mark +optional parameters with a trailing `?` inside the braces: + +```kotlin +@Serializable +@NavigationPath("/products/{productId}?source={source?}&campaign={campaign?}") +data class ProductDetail( + val productId: String, + val source: String? = null, + val campaign: String? = null, +) : NavigationKey +``` + +Now `/products/shoe-1`, `/products/shoe-1?source=email`, and +`/products/shoe-1?source=email&campaign=spring-sale` all resolve; +absent params land as `null` on the key. + +## Value classes + +Inline value classes serialise through their backing field, so they +work as path properties as long as the underlying type is supported +(`String`, primitive numerics, `Boolean`): + +```kotlin +@JvmInline +@Serializable +value class ProductId(val value: String) + +@Serializable +@NavigationPath("/products/{productId}") +data class ProductDetail( + val productId: ProductId, +) : NavigationKey +``` + +The URL is still `/products/shoe-1` — the value class is transparent +at the path layer. + +## Custom bindings with `@NavigationPath.FromBinding` + +Property-by-name matching covers most cases. When it doesn't — you +want to default missing fields to non-trivial values, derive one +property from another, or hand-write the serialiser — declare a +`NavigationKey.PathBinding` and point at it with +`@NavigationPath.FromBinding`: + +```kotlin +@Serializable +@NavigationPath("/items/{id}?name={name}&title={title?}") +@NavigationPath.FromBinding(MyKey.Default::class) +data class MyKey( + val id: String, + val name: String, + val title: String? = null, +) : NavigationKey { + + object Default : NavigationKey.PathBinding { + // Fallback shape — used when the URL is just /items with a title. + override val pattern: String = "/items?title={title?}" + + override fun deserialize(data: PathData): MyKey { + return MyKey( + id = "default-id", + name = "default-name", + title = data.optional("title"), + ) + } + + override fun serialize(builder: PathData.Builder, key: MyKey) { + key.title?.let { builder.set("title", it) } + } + } +} +``` + +A key can carry **one primary pattern** (the `@NavigationPath` value) +plus **any number of `FromBinding` patterns**. Resolution tries each +pattern in turn; the first match wins. This is what lets you support +short / long forms of the same URL ("`/items/abc`" and "`/items`" both +producing some `MyKey`). + +The `PathData` / `PathData.Builder` API exposes typed accessors for +the parsed path segments and query parameters. See `dev.enro.path.PathData` +for the surface. + +## Programmatic bindings (no annotation) + +When you can't (or don't want to) annotate the key — typically because +the key is defined in a module you don't control — register the binding +directly through `NavigationPathBinding.createPathBinding(…)`: + +```kotlin +val productDetailBinding: NavigationPathBinding = + NavigationPathBinding.createPathBinding( + pattern = "/products/{productId}?source={source?}", + propertyOne = ProductDetail::productId, + propertyTwo = ProductDetail::source, + constructor = ::ProductDetail, + ) + +val pathsModule = createNavigationModule { + path(productDetailBinding) +} +``` + +The annotation form is just sugar over this API — the processor +generates equivalent `createPathBinding(…)` calls and registers them +on the component. + +## Resolving paths at runtime + +Two functions, mirror images of each other: + +```kotlin +// String → Key +@ExperimentalEnroApi +fun EnroController.getNavigationKeyFromPath(path: String): NavigationKey? + +// Key → String +@ExperimentalEnroApi +fun EnroController.getPathFromNavigationKey(key: NavigationKey): String? +``` + +`NavigationHandle` and `NavigationContext` have receiver versions of +both for convenience inside destinations — see the +`NavigationHandle.getNavigationKeyFromPath` / `getPathFromNavigationKey` +KDoc. Both return `null` when no binding matches: an unrecognised path +or a key whose type has no binding registered. + +Typical use: + +```kotlin +// Handling an inbound Android deep link. +override fun onNewIntent(intent: Intent) { + val key = controller.getNavigationKeyFromPath(intent.dataString.orEmpty()) ?: return + rootHandle.open(key) +} +``` + +## The path-vs-state model + +A common question: "Why doesn't my modal / tab / inner-container +destination show up in the URL?" + +Enro's web URL routing is **root-container-only** — only the active +destination of the root navigation container is reflected in the URL +bar. Modals, sheets, inner tabs, list/detail panes hosted inside +other destinations are *state*, not *URL state*, and don't write to +`location.pathname`. + +This is deliberate, and matches how most modern web apps behave: + +- Going to a different profile on Bluesky writes the URL + (`bsky.app/profile/some.handle`). +- Switching tabs within that profile (Posts / Replies / Media / + Videos / Likes / Feeds) doesn't — the URL stays on the profile. + +Path bindings declare what's URL-shaped; the runtime treats unbound +destinations as session-local. If you have a destination that you'd +like to be deep-linkable but it lives inside a nested container, the +two options are: + +1. Promote it to root for the URL-bound case (and host the same + destination inside a parent for the nested case — same key, two + contexts). +2. Use the synthetic-backstack pattern from the *Advanced Deep Link* + recipe: parse the URL yourself, derive the parent context, seed the + nested container's backstack manually. + +The web platform docs cover the URL/history wiring in detail — see +[Web Platform Guide](../platform/web.md) for `EnroBrowserContent`, +`InstallWebHistoryPlugin`, and `rememberInitialBackstackFromUrl`. + +## See also + +- [Web Platform Guide](../platform/web.md) — how `@NavigationPath` + drives browser URL routing. +- [Synthetic Destinations](synthetic-destinations.md) — pairs well + with path bindings for "URL hits → decide which screen to show". +- Recipes: [Basic Deep Link][basic-deep-link], + [Advanced Deep Link][advanced-deep-link] — full working examples of + annotated and programmatic bindings respectively. + +[basic-deep-link]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/deeplink/BasicDeepLink.kt +[advanced-deep-link]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/deeplink/AdvancedDeepLink.kt diff --git a/docs/ghpages/docs/advanced/plugins.md b/docs/ghpages/docs/advanced/plugins.md new file mode 100644 index 000000000..ef69a5494 --- /dev/null +++ b/docs/ghpages/docs/advanced/plugins.md @@ -0,0 +1,137 @@ +--- +title: Plugins +parent: Advanced Topics +nav_order: 5 +--- + +# Plugins + +A `NavigationPlugin` is a way to attach behaviour to *every* destination in +your app, without touching the destinations themselves. Plugins observe +navigation events as they happen — instances being opened, becoming active, +being closed — and can react by tagging metadata, dispatching analytics, +running diagnostics, or modifying the way destinations are rendered. + +Plugins are the right tool when: + +- You want to do something for **every** destination (or every destination + that matches a type), uniformly. +- The behaviour is cross-cutting — it doesn't belong inside a specific + screen. +- You want the destinations themselves to stay unaware that the behaviour + exists. + +Examples include analytics ("log a screen-view for every opened +destination"), origin tagging ("stamp every instance with the timestamp it +was opened at"), or diagnostics ("warn if a screen stays composed for more +than N seconds"). + +## The NavigationPlugin interface + +Subclass `NavigationPlugin` and override whichever hooks you care about: + +```kotlin +abstract class NavigationPlugin { + open fun onAttached(controller: EnroController) {} + open fun onDetached(controller: EnroController) {} + + open fun onOpened(navigationHandle: NavigationHandle<*>) {} + open fun onActive(navigationHandle: NavigationHandle<*>) {} + open fun onClosed(navigationHandle: NavigationHandle<*>) {} + + @AdvancedEnroApi + open fun onDestinationCreated( + destination: NavigationDestination<*>, + additionalMetadata: MutableMap, + ) {} +} +``` + +| Hook | Fires when | +|---|---| +| `onAttached(controller)` | The plugin is installed on a controller. Typical setup point. | +| `onDetached(controller)` | The plugin is removed from a controller. Typical teardown point. | +| `onOpened(handle)` | A new destination instance has just been opened. The handle's lifecycle is at `CREATED` or later. | +| `onActive(handle)` | A destination instance has become the active (visible/top) destination. | +| `onClosed(handle)` | A destination instance is closed and about to be removed from the backstack. | +| `onDestinationCreated(destination, additionalMetadata)` | (Advanced) A destination is being assembled for rendering. You can add or override rendering metadata here — for example, to force a key to render as an overlay without modifying its destination definition. | + +`onOpened`, `onActive`, and `onClosed` are the common hooks. `onDestinationCreated` +is marked `@AdvancedEnroApi` because changing rendering metadata can break +how destinations are presented. + +## Installing a plugin + +Plugins are installed in the `NavigationComponent`'s module DSL: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + plugin(MyAnalyticsPlugin()) + plugin(OpenedTimestampPlugin()) + } +) +``` + +A plugin is installed once per controller. Multiple plugins can be +installed; their hooks fire in installation order. + +## A worked example + +Below is `OpenedTimestampPlugin` — a small plugin that stamps every +instance with the epoch-millisecond time it was first opened, via the +instance's `Metadata`. + +```kotlin +object OpenedAt : NavigationKey.MetadataKey(default = null) + +class OpenedTimestampPlugin : NavigationPlugin() { + override fun onOpened(navigationHandle: NavigationHandle<*>) { + val instance = navigationHandle.instance + if (instance.metadata.get(OpenedAt) == null) { + instance.metadata.set(OpenedAt, Clock.System.now().toEpochMilliseconds()) + } + } +} +``` + +Any destination can then read its own opened-at timestamp: + +```kotlin +val openedAt = navigation.instance.metadata.get(OpenedAt) +``` + +Installed in `RecipesComponent`, the plugin stamps every destination in the +recipes app, regardless of where the key lives or who opens it. The runnable +version is the [OpenedTimestampPlugin recipe][timestamp-recipe]. + +This is the canonical example of using **[instance +metadata](../core-concepts/navigation-keys.md#instance-metadata)** to carry +data that any destination might want but that isn't part of any key's +contract. + +## A note on what plugins shouldn't do + +- **Plugins shouldn't perform navigation.** If you find yourself wanting to + call `open` or `close` from a plugin hook, you probably want an + [interceptor](../core-concepts/navigation-containers.md#interceptors) + instead — those are designed to observe and rewrite operations as they + flow. +- **Don't put per-destination behaviour in a plugin.** If the behaviour + applies to one specific destination, put it on the destination itself + (or in its ViewModel). Plugins are for cross-cutting concerns. +- **Don't rely on hook ordering across plugins.** If two plugins both + modify the same instance metadata, decide on a clear ownership rule — + installation order is not a stable contract. + +## See also + +- [Navigation Keys → Instance metadata](../core-concepts/navigation-keys.md#instance-metadata) + for what `Metadata` is and isn't. +- [Navigation Containers → Interceptors](../core-concepts/navigation-containers.md#interceptors) + for the close cousin of plugins, used for rewriting operations. +- [OpenedTimestampPlugin recipe][timestamp-recipe] — the worked example + above. + +[timestamp-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/plugins/OpenedTimestampPlugin.kt diff --git a/docs/ghpages/docs/advanced/results.md b/docs/ghpages/docs/advanced/results.md new file mode 100644 index 000000000..1bec1cea9 --- /dev/null +++ b/docs/ghpages/docs/advanced/results.md @@ -0,0 +1,202 @@ +--- +title: Results +parent: Advanced Topics +nav_order: 1 +has_children: true +--- + +# Results + +Enro's result system is what turns a screen into a function with a return +value. A screen whose key implements `NavigationKey.WithResult` returns a +typed `R` to its caller. The caller registers interest in the result with +`registerForNavigationResult`, opens the destination through the resulting +channel, and is notified asynchronously when the destination completes (or +when the user backs out). + +## The shape of a result + +A key signals "this screen returns a value" by implementing +`NavigationKey.WithResult`: + +```kotlin +@Serializable +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.WithResult +``` + +The destination returns its value with `navigation.complete(value)`: + +```kotlin +@Composable +@NavigationDestination(SelectDate::class) +fun SelectDateScreen() { + val navigation = navigationHandle() + // ... + Button(onClick = { navigation.complete(LocalDate.now()) }) { Text("Use today") } +} +``` + +A caller receives the value through a `NavigationResultChannel`: + +```kotlin +val getDate = registerForNavigationResult( + onCompleted = { date -> /* use it */ }, +) + +Button(onClick = { getDate.open(SelectDate()) }) { Text("Pick a date") } +``` + +That's the whole pattern. The rest of this page covers the variations. + +## In a Composable + +`registerForNavigationResult` is a Composable function. It returns a +`NavigationResultChannel` you call `.open(key)` on: + +```kotlin +@Composable +@NavigationDestination(Home::class) +fun HomeScreen() { + val navigation = navigationHandle() + + val pickDate = registerForNavigationResult( + onClosed = { /* dismissed without producing a value */ }, + onCompleted = { date -> + // do something with the date — note: this is a callback, + // not a suspension. You can update Compose state from here. + }, + ) + + Button(onClick = { pickDate.open(SelectDate(maxDate = LocalDate.now())) }) { + Text("Pick a date") + } +} +``` + +The channel is `remember`-ed by composition key, so it survives recomposition +and process death. + +### Closed vs completed + +Two callbacks, two cases: + +- `onCompleted(value)` fires when the destination calls + `navigation.complete(value)`. +- `onClosed()` fires when the destination calls `navigation.close()` (or is + closed by any other means — back press, container collapse, etc.). + +You can register only `onCompleted` if you don't care about cancellation — +it's the required argument; `onClosed` defaults to a no-op. + +### Without a result type + +For destinations whose key does **not** implement `WithResult`, there's a +`Unit`-typed overload that lets you still distinguish "completed" from +"closed": + +```kotlin +@Serializable +data class ConfirmDelete(val itemName: String) : NavigationKey + +val confirmDelete = registerForNavigationResult( + onCompleted = { /* user confirmed */ }, + onClosed = { /* user cancelled */ }, +) + +Button(onClick = { confirmDelete.open(ConfirmDelete("Tax return.pdf")) }) { + Text("Delete") +} +``` + +Even though `ConfirmDelete` has no result type, the destination can still +call `complete()` to signal "user confirmed" vs `close()` for "user +cancelled". See [close vs complete](../core-concepts/navigation-handles.md#closing-vs-completing). + +## In a ViewModel + +`registerForNavigationResult` is also available as an extension on +`ViewModel`. The form is a property delegate: + +```kotlin +class HomeViewModel : ViewModel() { + val navigation by navigationHandle() + + val pickDate by registerForNavigationResult( + onCompleted = { date -> + // update your StateFlow / call a use case / etc. + }, + ) + + fun onPickDateClicked() { + pickDate.open(SelectDate(maxDate = LocalDate.now())) + } +} +``` + +Inside the ViewModel, the channel survives configuration change and process +death along with the ViewModel. The `viewModelScope` is used to observe +incoming results, so callbacks stop when the ViewModel is cleared. + +## Sub-pages + +There are two patterns worth their own pages: + +- **[Embedded result flows](results/embedded-result-flows.md)** — chaining a + small number of results together inside one screen, using `onCompleted` to + open the next result-producing key. +- **[Managed result flows](results/managed-result-flows.md)** — defining a + multi-step flow as sequential, imperative code with + `managedFlowDestination`. + +## How results are routed + +Under the hood, calling `channel.open(key)` attaches a metadata tag to the +opened instance identifying *which channel* this instance is feeding. When +the instance completes or closes, the runtime looks up the channel by tag +and dispatches the result. You can ignore this mechanism unless you're +building deep tooling — the point is that a single screen can have multiple +distinct result channels open without them being confused for each other. + +A consequence worth noting: a `NavigationResultChannel` is bound to *one* +result type. If you want a screen that picks "either a contact or a phone +number," model that as a sealed class for the result type, not as two +channels racing each other. + +## Forwarding from a "decider" destination + +When several concrete screens implement the same result contract — a +legacy and a redesigned edit-profile flow, an A/B test variant, a +platform-specific picker — a synthetic destination can sit in front of +them and pick at runtime, while callers see only the stable contract: + +```kotlin +@Serializable object EditProfile : NavigationKey.WithResult +@Serializable object EditProfileLegacy : NavigationKey.WithResult +@Serializable object EditProfileV2 : NavigationKey.WithResult + +@NavigationDestination(EditProfile::class) +val editProfile = syntheticDestination { + if (featureFlags.useNewProfileUi) completeFrom(EditProfileV2) + else completeFrom(EditProfileLegacy) +} +``` + +`completeFrom` opens the chosen key with the synthetic's result-channel +metadata copied across, so the chosen screen's `complete(value)` routes +back to whoever opened `EditProfile`. See +[synthetic destinations](synthetic-destinations.md) for the full pattern. + +## See also + +- [Synthetic Destinations](synthetic-destinations.md) — `completeFrom` + and the result-decider pattern. +- [Navigation Handles — closing vs completing](../core-concepts/navigation-handles.md#closing-vs-completing). +- [Returning Results recipe][results-recipe] — small worked example with + both Composable and ViewModel forms. +- [Embedded result flows](results/embedded-result-flows.md) and + [managed result flows](results/managed-result-flows.md). + +[results-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/results/ReturningResults.kt diff --git a/docs/ghpages/docs/advanced/results/embedded-result-flows.md b/docs/ghpages/docs/advanced/results/embedded-result-flows.md new file mode 100644 index 000000000..7c844c752 --- /dev/null +++ b/docs/ghpages/docs/advanced/results/embedded-result-flows.md @@ -0,0 +1,81 @@ +--- +title: Embedded Result Flows +parent: Results +grand_parent: Advanced Topics +nav_order: 1 +--- + +# Embedded Result Flows + +An *embedded result flow* is when a screen chains several result-producing +destinations together, using each result to decide what to ask for next, all +inside that screen's own logic. It's the simplest way to compose a short +sequence of result-producing steps. + +The pattern is just `registerForNavigationResult` callbacks calling +`open` on each other. + +```kotlin +@Composable +@NavigationDestination(CreateAccount::class) +fun CreateAccountScreen() { + val navigation = navigationHandle() + var name by remember { mutableStateOf(null) } + var email by remember { mutableStateOf(null) } + + val askForEmail = registerForNavigationResult( + onCompleted = { result -> + email = result + navigation.complete(/* CreateAccountResult(name!!, email!!) */) + }, + ) + + val askForName = registerForNavigationResult( + onCompleted = { result -> + name = result + askForEmail.open(EnterEmail) + }, + ) + + Button(onClick = { askForName.open(EnterName) }) { + Text("Start") + } +} +``` + +This is the right shape for: + +- Two or three steps where the screen logic is simple. +- Cases where the next step depends on the previous result, but the branching is small. +- A screen that already has its own UI and is only "augmenting" itself with a few sub-questions. + +## When this pattern hurts + +The callback chain grows quickly. Once you have more than three or four +steps, or once the branching is non-trivial ("if the user picked X, ask Y; +otherwise ask Z and then W"), the chain becomes hard to read and harder to +test. The state at any point is spread across `remember`-ed variables. + +The pattern also doesn't survive *partial* progress well. If the user +completes step two of four and then the process is killed, you've lost steps +one and two unless you stash them somewhere persistent yourself. + +For anything beyond a small handful of steps, prefer a +[managed result flow](managed-result-flows.md) — same destinations, but the +flow control is written as straight-line code and the intermediate state is +managed for you. + +## Mixing the patterns + +You can mix the two. A managed flow can launch an embedded sub-flow inside +a step, and an embedded flow can hand off to a managed flow at any point. +The right question is: *for the next stretch, is the logic +straight-line-able?* If yes, use a managed flow; if no, use callbacks. + +## See also + +- [Managed result flows](managed-result-flows.md) — for longer or more + conditional sequences. +- [Returning Results recipe][results-recipe] — the canonical small example. + +[results-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/results/ReturningResults.kt diff --git a/docs/ghpages/docs/advanced/results/managed-result-flows.md b/docs/ghpages/docs/advanced/results/managed-result-flows.md new file mode 100644 index 000000000..bf201d3cd --- /dev/null +++ b/docs/ghpages/docs/advanced/results/managed-result-flows.md @@ -0,0 +1,109 @@ +--- +title: Managed Result Flows +parent: Results +grand_parent: Advanced Topics +nav_order: 2 +--- + +# Managed Result Flows + +A managed flow lets you define a multi-step navigation sequence as +**straight-line code**. Each step is a result-producing destination; the +flow scope's `open(key)` looks like a suspending function call that returns +the destination's result. Under the hood, Enro runs the steps, persists +intermediate state, and re-evaluates the flow on every result so the code +reads top-to-bottom. + +This is the pattern to reach for when an [embedded result +flow](embedded-result-flows.md) would have too many nested callbacks or too +much manual intermediate state. + +```kotlin +@OptIn(ExperimentalEnroApi::class) +@NavigationDestination(BookingFlow::class) +val bookingFlowDestination: NavigationDestinationProvider = + managedFlowDestination( + flow = { + val destination = open(SelectFlightDestination) + val date = open(SelectDate) + val passengers = open(SelectPassengers(maxPassengers = 5)) + + val seats = when { + passengers > 3 -> listOf("Group Seating") + else -> listOf("12A", "12B", "14C") + } + val seat = open(SelectSeat(availableSeats = seats)) + + BookingDetails(destination, date, passengers, seat) + }, + onCompleted = { details -> /* ... */ }, + ) +``` + +The complete worked version is the [managed flow recipe][managedflow-recipe]. + +## The mental model + +Inside the `flow = { }` block: + +- `open(key)` returns the value of `key`'s `WithResult` type. The block + is re-evaluated each time a result comes in, so by the time `open` "returns," + Enro has the value. +- The block's *return value* is the flow's overall result, delivered to + whoever opened the flow. +- The keys you `open` are registered as steps of the flow. Enro renders each + one in the flow's own container as the user reaches it. +- If the user backs up the flow, Enro re-evaluates from the top with the + preserved results for the earlier steps still in scope. +- `open(NavigationKey)` (without a `WithResult`) is also supported for + non-result destinations you want to step through. + +The flow is the destination — `managedFlowDestination` +produces a `NavigationDestinationProvider` that you bind to a +`NavigationKey.WithResult`. Callers open it like any other +result-producing destination through `registerForNavigationResult`. + +## Branching + +Because the flow body is plain Kotlin, branches and loops are exactly what +they look like: + +```kotlin +flow = { + val name = open(EnterName) + if (name == "admin") { + val token = open(RequestAdminToken) + AdminAccount(name, token) + } else { + StandardAccount(name) + } +} +``` + +When a branch is taken, only the keys actually opened in that branch become +steps of the flow. If the user backs up and re-evaluates into the other +branch, the steps for the abandoned branch are discarded. + +## When `open` returns + +`open(KeyWithResult)` participates in the flow's state machine — it doesn't +"suspend" in the coroutine sense. When the flow scope encounters an `open` +for a step that hasn't produced a result yet, it throws a sentinel that the +flow infrastructure catches; this is how the flow knows where to pause and +wait. **Don't** put `open` calls inside `try / catch` blocks that catch +`Throwable` — you'll break the flow's control flow. Catch specific +exceptions only. + +## Persistence + +Each managed-flow step's result is persisted with the flow's saved state. +If the process is killed and restored, the flow resumes at the same step +with all previous results intact. + +## See also + +- [Embedded result flows](embedded-result-flows.md) — when callbacks are + enough. +- [Managed flow recipe][managedflow-recipe] — full runnable example. + +[managedflow-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/managedflow/ManagedFlow.kt diff --git a/docs/ghpages/docs/advanced/synthetic-destinations.md b/docs/ghpages/docs/advanced/synthetic-destinations.md new file mode 100644 index 000000000..59cb5ae0b --- /dev/null +++ b/docs/ghpages/docs/advanced/synthetic-destinations.md @@ -0,0 +1,364 @@ +--- +title: Synthetic Destinations +parent: Advanced Topics +nav_order: 6 +--- + +# Synthetic Destinations + +A synthetic destination is a `NavigationKey` whose "destination" is a block +of code rather than a piece of UI. When the key is opened, the block runs; +the synthetic instance itself never lands on any backstack. Synthetics are +the bridge between Enro's navigation API and behaviours that don't fit a +"render a screen" model — things like launching an external browser, +deciding which of several implementations should fulfil a contract, or +composing a multi-step action behind a single call site. + +A synthetic looks like any other destination from the caller's side: + +```kotlin +navigation.open(Logout) // synthetic +navigation.open(EditProfile) // synthetic — could be either of two screens +navigation.open(SettingsScreen) // ordinary destination +``` + +The caller doesn't need to know — or care — that some keys resolve to a +block of code instead of a Composable. + +## When to reach for a synthetic + +There are three patterns the recipes (see [recipes][synthetic-recipes]) +cover end-to-end, and they're a fair guide for when a synthetic is the +right tool: + +- **Side-effect bridge** — wrap a non-Enro action behind a navigation + call. E.g. `OpenExternalUrl(url)` that launches Chrome Custom Tabs on + Android, `Desktop.browse(...)` on JVM, `window.open(...)` on web. +- **Conditional redirect** — gate a destination behind a runtime check. + `RequireLogin` checks the auth state and forwards to either the + requested screen or a login screen, so callers don't have to know + about the gate. +- **Result decider** — a result-bearing synthetic picks between several + concrete implementations (legacy / V2, feature flag A / feature flag + B, mobile-only / wide-screen-only) and forwards the chosen one's + result back to the original caller via `completeFrom`. + +If the behaviour really is "render this screen with some twist," prefer a +normal destination with the twist expressed in metadata. Reach for a +synthetic when there's no screen to render, or when the choice of which +screen depends on runtime state. + +## Declaring a synthetic + +`syntheticDestination` builds a `NavigationDestinationProvider` that +the KSP processor binds the same way as any other destination: + +```kotlin +@Serializable +object Logout : NavigationKey + +@NavigationDestination(Logout::class) +val logout = syntheticDestination { + sessionRepository.clearSession() + open(LoginScreen()) +} +``` + +Inside the block, you have a `SyntheticDestinationScope` receiver with +the key and instance available, plus the outcome methods covered below. + +## The outcome methods + +Each method ends the synthetic by deciding what should happen next. They +all return `Nothing` — calling one short-circuits the block. They split +into two kinds: **pure outcomes** that are rewritten in place as the +synthetic's surrounding `processOperations` pass runs, and a +**side-effect outcome** that runs deferred after the pass settles. + +### Pure outcomes — rewritten in place + +| Method | What it does | +|---|---| +| `open(key)` | Opens `key` on the same container the synthetic was opened on. The dispatcher rewrites the original `Open(synthetic)` into `Open(key)` inside `processOperations`, so the new operation flows through the interceptor chain in the same pass. Ordering is preserved when synthetics appear in an initial backstack alongside normal destinations. | +| `close()` | Ends the synthetic and registers a `Closed` result against whoever opened the synthetic. The caller's `onClosed` callback fires. | +| `closeSilently()` | Same as `close()` but the result-channel callback does **not** fire. Use when the caller doesn't need to be notified. | +| `complete()` | Ends the synthetic and registers a `Completed` result with no payload. Only available for non-result keys. | +| `complete(result)` | Same as `complete()` but with a typed payload. Only available when the synthetic's key implements `NavigationKey.WithResult`. | +| `completeFrom(key)` | Opens `key` and routes *its* eventual completion back to whoever opened the synthetic. The synthetic doesn't produce the result; the forwarded key does. | + +### Side-effect outcome — runs deferred + +| Method | What it does | +|---|---| +| `sideEffect { ... }` | The block runs in `afterExecution`, with a [`SyntheticSideEffectScope`](#side-effect-scope) receiver carrying `context`, `container`, `instance`, and `key`. The synthetic is treated as silently closed once the side effect dispatches. Use this when you need platform handles, the container reference, or any imperative work that doesn't fit a single navigation operation. | + +The methods throw a sentinel exception that the synthetic dispatcher +catches and converts into a `NavigationOperation`. Don't catch +`Throwable` inside a synthetic block — you'll swallow the outcome. + +## Pure outcomes vs side effects + +The split matters because the two kinds run at different points in the +operation lifecycle. + +A **pure outcome** is a deterministic rewrite of the synthetic's `Open`. +The dispatcher catches the outcome and returns the equivalent operation +from inside its interceptor — so the rewrite is part of the same +`processOperations` pass that handled the original `Open(synthetic)`. +For example, if an initial backstack is `[ASynthetic, B]` and the +synthetic opens `Target`, the resulting backstack is `[Target, B]` — +the synthetic's outcome takes the synthetic's slot in the queue, not the +end of the queue. + +A **side-effect outcome** runs deferred, in `afterExecution`. By the time +the block runs, every other operation in the same pass has already +settled. That's the point at which you can safely: + +- Touch platform handles (find an `Activity`, get a Compose + `WindowInfo`, etc.). +- Read or rewrite the container's backstack + (`container.execute(context, NavigationOperation.SetBackstack(...))`). +- Make any other imperative call that doesn't fit a single + `NavigationOperation`. + +The two recipes that ship in `recipes/synthetic` illustrate the split: +the auth gate and profile decider both use pure outcomes; the external +URL launcher uses `sideEffect { ... }` because it bridges to a non-Enro +API. + +## Falling through + +A synthetic block that returns without calling any outcome method is +treated as a **silent close**. No result-channel callback fires; no +navigation operation is dispatched against the synthetic's instance. The +block ran, did its thing, and Enro doesn't need to do anything further. + +This is the default for synthetics that only do pure side effects — but +note that in the current model you should prefer `sideEffect { ... }` +for that work, so you get explicit access to `container` and `context` +deferred to the right point. Pure synthetics that genuinely do *no* +imperative work and only return a fixed outcome (like a decider that +always picks one of two destinations) tend to be one-line bodies that +short-circuit through `open()` or `completeFrom(...)`. + +## Forwarding results with `completeFrom` + +The result-decider pattern is the most distinctive synthetic use case. +Your public-facing key has a `WithResult` contract; the synthetic +picks one of several concrete implementations at runtime and lets *that* +implementation produce the result: + +```kotlin +@Serializable +object EditProfile : NavigationKey.WithResult + +@Serializable +object EditProfileLegacy : NavigationKey.WithResult + +@Serializable +object EditProfileV2 : NavigationKey.WithResult + +@NavigationDestination(EditProfile::class) +val editProfile = syntheticDestination { + // Read a feature flag, A/B bucket, remote config, etc. + if (featureFlags.useNewProfileUi) completeFrom(EditProfileV2) + else completeFrom(EditProfileLegacy) +} +``` + +The caller subscribes to `EditProfile` and never needs to know which +underlying screen actually ran: + +```kotlin +val editProfile = registerForNavigationResult( + onCompleted = { update -> /* one branch, regardless of which UI produced it */ }, +) +Button(onClick = { editProfile.open(EditProfile) }) { Text("Edit") } +``` + +Under the hood, `completeFrom` copies the synthetic's result-channel +metadata onto the forwarded instance, so when the forwarded key calls +`navigation.complete(value)`, the value routes back through to the +original caller's channel. + +## The "already finished" error + +Synthetic blocks should be **synchronous**. The dispatcher catches the +outcome thrown from the block and acts on it; any code that runs *after* +the block returns (e.g. a coroutine the block launched on +`lifecycleScope`) operates against a scope that has already concluded. + +If async code calls an outcome method on the scope after the block has +returned, the scope throws: + +``` +SyntheticDestination for X has already finished with Close. +A second outcome cannot be set — this usually means an async coroutine +outlived the synthetic block and tried to complete/close it after the +dispatcher had already moved on. Do any async work before opening the +synthetic, or forward to a destination that owns the work itself. +``` + +The fix is one of: + +- **Do the async work *before* the synthetic** and pass the result in as + a parameter of the synthetic's key. +- **Forward to a real destination** that owns the async work as part of + its lifecycle (`completeFrom(WorkInProgressScreen)`). +- **Restructure as a regular destination with a loading state** if the + synthetic was really trying to be a "do some work then close" UI. + +There is a deliberate design choice here: synthetics are intentionally +small, synchronous decision points. Letting them survive across coroutine +boundaries would require giving them their own lifecycle, view model +store, and serialised state — i.e. making them ordinary destinations. +That direction is being considered as a future "lifecycle-bearing +synthetic" mode but isn't on the current roadmap. + +## Accessing the originating context + +### From the pure scope — reads only + +`SyntheticDestinationScope` exposes `context: NavigationContext` and +the derived `destinationContext`. These are intended for **reads**: +inspecting `controller`, walking parent contexts, checking which +destination opened the synthetic. They're available so a pure outcome +can be informed by the surrounding navigation state. + +You can technically reach `context.controller` and other writable handles +from inside a pure block, but doing so runs while the container's +execute mutex is held — direct `controller.execute(...)` or +`container.execute(...)` calls deadlock. That's the framework signalling +that you should be using `sideEffect { ... }` instead. + +### From the side-effect scope — full access + +`SyntheticSideEffectScope` (the receiver of the `sideEffect` block) +exposes `context`, `container`, `instance`, and `key`. By the time the +side-effect block runs, the mutex is released and the backstack has +settled, so imperative work is safe: + +```kotlin +@NavigationDestination(OpenExternalUrl::class) +val openExternalUrl = syntheticDestination { + sideEffect { + val activity = context.findActivity() // Android extension + activity.startActivity(Intent(ACTION_VIEW, Uri.parse(key.url))) + } +} + +@NavigationDestination(DeepLinkResolver::class) +val deepLinkResolver = syntheticDestination { + sideEffect { + val resolved = context.controller.getNavigationKeyFromPath(key.url) + ?: return@sideEffect + container.execute( + context = context, + operation = NavigationOperation.SetBackstack( + currentBackstack = container.backstack, + targetBackstack = backstackOf(Home.asInstance(), resolved.asInstance()), + ), + ) + } +} +``` + +## Testing synthetic destinations + +enro-test provides `testSyntheticDestination(...)` for unit-testing +synthetic logic without going through a real container or interceptor +pipeline. It runs the synthetic's block in a sandbox scope and returns +a [`SyntheticOutcome`][synthetic-outcome] describing what the block +decided. + +Two entry points cover the two common test shapes: + +### Registered path — via the installed controller + +Use this when the synthetic is registered through a `NavigationModule` on +your component, as it would be in production: + +```kotlin +@Test +fun `auth gate forwards to protected screen when logged in`() = runEnroTest { + MyComponent.installNavigationController(this) + isLoggedIn = true // app-state setup + + val outcome = testSyntheticDestination(RequireProtectedFeature) + + outcome.assertOpens() +} +``` + +### Direct path — provider passed in + +Use this for pure unit tests that don't need a controller installed. Pass +the `NavigationDestinationProvider` value directly: + +```kotlin +val authGate = syntheticDestination { + if (sessionRepository.isLoggedIn) open(AuthGateProtectedFeature) + else open(AuthGateLogin) +} + +@Test +fun `auth gate redirects to login when logged out`() { + sessionRepository.signOut() + + val outcome = testSyntheticDestination(RequireProtectedFeature, authGate) + + outcome.assertOpens() +} +``` + +### Assertion helpers + +| Helper | Asserts | +|---|---| +| `assertOpens(predicate?)` | Outcome is `Open` of a key of type `T`, optionally matching `predicate`. Returns the typed key. | +| `assertCompletesFrom(predicate?)` | Outcome is `CompleteFrom` of a key of type `T`. Returns the typed key. | +| `assertCloses(silent?)` | Outcome is `Close`, optionally matching the `silent` flag. | +| `assertCompletes(expectedResult)` | Outcome is `Complete` with the given payload. Pass `null` for non-result synthetics. | +| `assertSideEffect()` | Outcome is `SideEffect`. Returns the side-effect so you can run it (see below). | + +### Executing side-effect outcomes + +A `SideEffect` outcome carries the block but doesn't auto-invoke it — +the test gets to decide whether to run it, and with what scope: + +```kotlin +@Test +fun `external URL synthetic runs the launch side effect`() { + val outcome = testSyntheticDestination(OpenExternalUrl("https://enro.dev"), openExternalUrl) + outcome.assertSideEffect().runWith() + // …assert on whatever the side effect produced (mock state, captured intents, etc.) +} +``` + +`runWith()` with no arguments uses default fixture context and container +— enough for most tests. Pass explicit `context: NavigationContext` and +`container: NavigationContainer` arguments when the side effect's +behaviour depends on either. + +### What you DON'T get from the test helper + +`testSyntheticDestination` runs the block in isolation — it doesn't +dispatch operations against any container's backstack. So if your +synthetic calls `open(Other)`, the test sees a `SyntheticOutcome.Open(Other)` +but `Other` doesn't actually land in any backstack. For end-to-end +backstack assertions, fall back to the container fixtures and execute +the operation through `container.execute(...)` as the +`SyntheticDestinationTests` in enro-runtime do. + +[synthetic-outcome]: https://github.com/isaac-udy/Enro/blob/main/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticOutcome.kt + +## See also + +- [Returning results](results.md) — the result contract that `complete` + and `completeFrom` plug into. +- [Recipes][synthetic-recipes] — three worked examples corresponding to + the three patterns above. +- [Migrating from v2](../migrating-from-v2.md) — `sendResult` / + `forwardResult` from Enro 2 are now `complete` / `completeFrom`. + +[synthetic-recipes]: https://github.com/isaac-udy/Enro/tree/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/synthetic diff --git a/docs/ghpages/docs/advanced/testing.md b/docs/ghpages/docs/advanced/testing.md new file mode 100644 index 000000000..db3f0987c --- /dev/null +++ b/docs/ghpages/docs/advanced/testing.md @@ -0,0 +1,178 @@ +--- +title: Testing +parent: Advanced Topics +nav_order: 4 +--- + +# Testing + +Enro's test utilities live in the `enro-test` module and let you write +tests that: + +- Install a controller for the duration of a test (so destinations and + handles can be created without a full app instance). +- Construct a `TestNavigationHandle` that records every navigation + operation a handle executes, instead of dispatching to a real container. +- Assert against those recorded operations with typed helpers. +- Inject a navigation handle into a `ViewModel` so the ViewModel can be + unit-tested in isolation. + +Add the dependency: + +```kotlin +dependencies { + testImplementation("dev.enro:enro-test:3.0.0-beta01") +} +``` + +## Two ways to install a test controller + +### `runEnroTest { }` (KMP-friendly) + +`runEnroTest` is a plain function that installs a navigation controller +for the duration of the block. Use this in any Kotlin test: + +```kotlin +@Test +fun open_profile_navigates_to_edit_screen() = runEnroTest { + val handle = createTestNavigationHandle(ShowProfile("user-123")) + + handle.open(EditProfile("user-123")) + + handle.assertOpened() +} +``` + +This is the recommended form. It works in common test source sets, +instrumented tests, and JVM unit tests alike. + +### `EnroTestRule` (JUnit) + +If your test class uses JUnit-style `@Rule`s, `EnroTestRule` does the same +thing as `runEnroTest` but as a `TestRule`: + +```kotlin +@get:Rule(order = 0) +val enroRule = EnroTestRule() + +@get:Rule(order = 1) +val composeRule = createComposeRule() +``` + +If you have other `@Rule`s that launch activities or fragments, put the +`EnroTestRule` first by ordering — the controller has to be installed +before any destination is created. + +## TestNavigationHandle + +Build a `TestNavigationHandle` for a key (or instance) and call the +ordinary `open` / `close` / `complete` / `requestClose` extensions on it. +The handle records every operation rather than dispatching to a container: + +```kotlin +val handle = createTestNavigationHandle(ShowProfile("user-123")) + +handle.open(SelectDate()) +handle.complete() + +// every operation is recorded on `handle.operations` +``` + +The recorded operations are `NavigationOperation.RootOperation`s — `Open`, +`Close`, `Complete`, `CompleteFrom`, `SetBackstack`. Aggregated +operations (`AggregateOperation`) are flattened to their constituent root +operations when recorded. + +Once a handle has been closed or completed, it rejects further operations. +Call `handle.clearOperationHistory()` to re-use the same handle for +multiple sequential scenarios. + +## Assertions + +The handle exposes typed assertions for each common shape. + +### `assertOpened()` + +```kotlin +// Any open of type T +val instance = handle.assertOpened() + +// A specific key value +handle.assertOpened(EditProfile("user-123")) + +// A predicate over the instance +handle.assertOpened { it.key.userId == "user-123" } + +// "Nothing was opened" +handle.assertNoneOpened() +``` + +### `assertClosed()` / `assertCompleted()` + +```kotlin +handle.assertClosed() +handle.assertNotClosed() + +handle.assertCompleted() +handle.assertCompleted(LocalDate.now()) // with a specific result +handle.assertCompleted { it.year > 2020 } +handle.assertNotCompleted() +``` + +`assertCompleted(predicate)` is the one to reach for when you want to +verify the *kind* of result without pinning down the exact value. + +### Container assertions + +For tests that build a real `NavigationContainerState` (using the test +fixtures), there's a matching set of container-level assertions: + +```kotlin +val state = /* container state */ +state.assertActive() +state.assertActive(ShowProfile("user-123")) +state.assertActive { it.key.userId == "user-123" } +``` + +`assertActive` checks the currently-rendered destination at the top of the +backstack. + +## Testing ViewModels + +To unit-test a ViewModel that uses `by navigationHandle()` or +`by registerForNavigationResult { }`, use +`putNavigationHandleForViewModel(key)` to inject a recording +handle: + +```kotlin +@Test +fun saving_changes_completes_the_destination() = runEnroTest { + val handle = putNavigationHandleForViewModel( + EditProfile(initial = "Hello"), + ) + + val viewModel = EditProfileViewModel() + viewModel.onSaveClicked() + + handle.assertCompleted() +} +``` + +The injected handle survives until you put a new one for the same ViewModel +type. Pair it with `runEnroTest` (or `EnroTestRule`) so the underlying +controller is set up. + +## Strict mode + +When `runEnroTest { }` or `EnroTestRule` is active, attempting to perform +"real" navigation outside a `TestNavigationHandle` will be blocked. This is +deliberate — the test utilities are designed for **isolated** testing of a +single destination or ViewModel. If you want full app navigation in an +instrumented test, don't install the test controller; let the app's +real controller drive it. + +## See also + +- [Navigation Handles](../core-concepts/navigation-handles.md) for the + operations the assertions match against. +- [Results](results.md) for the channel/operation interaction. diff --git a/docs/ghpages/docs/advanced/view-models.md b/docs/ghpages/docs/advanced/view-models.md new file mode 100644 index 000000000..b1938c99a --- /dev/null +++ b/docs/ghpages/docs/advanced/view-models.md @@ -0,0 +1,151 @@ +--- +title: View Models +parent: Advanced Topics +nav_order: 2 +--- + +# View Models + +Enro integrates with standard Android `ViewModel`s (and their KMP-friendly +counterparts via Compose Multiplatform). A ViewModel can read its own +destination's `NavigationHandle`, drive navigation from business logic, and +register for results — all without depending on its destination's +Composable. + +## Getting a NavigationHandle in a ViewModel + +Inside a `ViewModel`, use the `by navigationHandle()` property +delegate: + +```kotlin +class ProfileViewModel : ViewModel() { + val navigation by navigationHandle() + + fun onEditClicked() { + navigation.open(EditProfile(navigation.key.userId)) + } + + fun onBackClicked() { + navigation.requestClose() + } +} +``` + +The handle is the same type the destination's Composable would get from +`navigationHandle()`. It's available immediately at +construction time — usable from `init { }` and any method. + +The delegate accepts a `config` block, same as the Composable +`configure { }`: + +```kotlin +class EditProfileViewModel : ViewModel() { + private val draft = MutableStateFlow("") + + val navigation by navigationHandle( + config = { + onCloseRequested { + if (draft.value == key.initial) close() else openConfirmationDialog() + } + }, + ) + + private fun openConfirmationDialog() { /* ... */ } +} +``` + +See [Navigation Handles → Overriding the close-requested callback](../core-concepts/navigation-handles.md#overriding-the-close-requested-callback). + +## Constructing a ViewModel inside a destination + +In Compose, use the standard `viewModel { }` builder with the +`createEnroViewModel { }` helper: + +```kotlin +@Composable +@NavigationDestination(ShowProfile::class) +fun ProfileScreen() { + val viewModel = viewModel { createEnroViewModel { ProfileViewModel() } } + val state by viewModel.uiState.collectAsState() + // ... +} +``` + +`createEnroViewModel { }` (in `dev.enro.viewmodel`) is what makes +`by navigationHandle()` work inside the ViewModel — it supplies the +current `NavigationHandle` to the ViewModel as it's being constructed. + +You don't need any special factory. `createEnroViewModel { }` returns the +same instance the lambda produces, so you can also use it with constructor +parameters, dependency-injection containers, or whatever your project +already uses: + +```kotlin +val viewModel = viewModel { + createEnroViewModel { + ProfileViewModel( + repository = MyContainer.userRepository, + analytics = MyContainer.analytics, + ) + } +} +``` + +If you're using Hilt, Koin, or another DI library, build the ViewModel the +way your container does and wrap the construction call in +`createEnroViewModel { }`. + +## Lifetime + +A ViewModel constructed for a destination shares the destination's +backstack lifetime. It's created the first time the destination is +composed, retained across configuration changes, and cleared when the +destination is permanently removed from the backstack (i.e. after a close +or a backstack reset). + +The handle held in the ViewModel is *the same handle* used inside the +destination's Composable. You can drive `open` / `close` / `complete` from +either place; results dispatched to a `registerForNavigationResult` channel +go to whichever instance registered them. + +## ViewModels and results + +`registerForNavigationResult` is available as a `ViewModel` extension and +uses a property delegate: + +```kotlin +class HomeViewModel : ViewModel() { + val navigation by navigationHandle() + + val pickDate by registerForNavigationResult( + onCompleted = { date -> /* update StateFlow, call use case, etc. */ }, + ) + + fun onPickDateClicked() { + pickDate.open(SelectDate(maxDate = LocalDate.now())) + } +} +``` + +See [Results](results.md) for details. + +## Shared state + +Sharing state between destinations is normal ViewModel territory — Enro +doesn't add anything special. A common pattern is to lift the state into a +container that survives both screens (e.g. a hoisted `StateFlow` in a +parent destination, or a process-wide singleton) and have each destination's +ViewModel observe it. + +For a small inline example, see the [shared ViewModel recipe][shared-recipe]. + +## See also + +- [Navigation Handles](../core-concepts/navigation-handles.md) — the handle + API both ViewModels and Composables use. +- [Results](results.md) — `registerForNavigationResult` in both forms. +- [Basic ViewModel recipe][basic-recipe] and + [Shared ViewModel recipe][shared-recipe]. + +[basic-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/viewmodel/BasicViewModel.kt +[shared-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/viewmodel/SharedViewModel.kt diff --git a/docs/ghpages/docs/core-concepts/index.md b/docs/ghpages/docs/core-concepts/index.md new file mode 100644 index 000000000..5652d1cc8 --- /dev/null +++ b/docs/ghpages/docs/core-concepts/index.md @@ -0,0 +1,22 @@ +--- +title: Core Concepts +nav_order: 3 +has_children: true +--- + +# Core Concepts + +The four building blocks of Enro. Read the [basic concepts +tour](../getting-started/basic-concepts.md) first if you haven't — these +pages assume the vocabulary. + +- [Navigation Keys](navigation-keys.md) — the contract for a screen: its + inputs and (optionally) its typed result. +- [Navigation Destinations](navigation-destinations.md) — the + implementation: a Composable function or provider, bound to a key, with + optional metadata (dialog, overlay, scene strategy). +- [Navigation Containers](navigation-containers.md) — where a backstack + lives, including nesting, empty behaviour, filters, and interceptors. +- [Navigation Handles](navigation-handles.md) — the control surface + inside a screen for `open`, `close`, `complete`, `requestClose`, and + the result API. diff --git a/docs/ghpages/docs/core-concepts/navigation-containers.md b/docs/ghpages/docs/core-concepts/navigation-containers.md new file mode 100644 index 000000000..6f73664d0 --- /dev/null +++ b/docs/ghpages/docs/core-concepts/navigation-containers.md @@ -0,0 +1,194 @@ +--- +title: Navigation Containers +parent: Core Concepts +nav_order: 3 +--- + +# Navigation Containers + +A `NavigationContainer` is a region of your UI that hosts a backstack of +destinations. Most apps have one root container; advanced layouts (tabs, +list-detail, multiple back stacks) use several, often nested. + +The container is created with `rememberNavigationContainer` and rendered +with `NavigationDisplay`. Together they're the smallest amount of navigation +infrastructure your app needs. + +```kotlin +val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), +) +NavigationDisplay(state = container) +``` + +## rememberNavigationContainer + +```kotlin +@Composable +public fun rememberNavigationContainer( + key: NavigationContainer.Key = /* auto-generated, saveable */, + backstack: NavigationBackstack, + emptyBehavior: EmptyBehavior = EmptyBehavior.preventEmpty(), + interceptor: NavigationInterceptor = NoOpNavigationInterceptor, + filter: NavigationContainerFilter = acceptAll(), +): NavigationContainerState +``` + +- **`key`** — identifies this container within its parent. The default is a + stable saveable UUID and is fine for most cases. Provide an explicit key + when you need to address the container from elsewhere (for example, when + programmatically pushing a destination into a specific container). +- **`backstack`** — the initial contents. Most apps pass + `backstackOf(MyRootKey.asInstance())`. +- **`emptyBehavior`** — what happens when the backstack becomes empty. +- **`interceptor`** — observe or veto operations before they apply. +- **`filter`** — restrict which keys this container will accept. + +The function returns a `NavigationContainerState` you pass to +`NavigationDisplay`. + +## Building a backstack + +`backstackOf` is the canonical way to build one: + +```kotlin +val backstack = backstackOf( + Home.asInstance(), + ShowProfile("user-123").asInstance(), +) +``` + +If you already have a `List>`, call `.asBackstack()`. +There's also `emptyBackstack()` if you want to start empty (and combine that +with an `emptyBehavior` other than the default — see below). + +`MyKey.asInstance()` wraps a key into an `Instance` with a fresh id; see +[Navigation Keys](navigation-keys.md#navigationkeyinstance). + +## NavigationDisplay + +`NavigationDisplay` is the Composable that renders a container. + +```kotlin +NavigationDisplay( + state = container, + modifier = Modifier.fillMaxSize(), + sceneStrategy = /* defaults to dialog + directOverlay + singlePane */, + contentAlignment = Alignment.TopStart, + sizeTransform = null, + animations = NavigationAnimations.Default, +) +``` + +It watches the container's backstack and animates between destinations as it +changes. `sceneStrategy` controls how the backstack is laid out +(see [Navigation Destinations](navigation-destinations.md#scene-strategies)); +`animations` controls how destinations animate in and out +(see [Animations](../advanced/animations.md)). + +## Empty behaviour + +`EmptyBehavior` decides what happens when the user closes the last +destination in the container. + +| Helper | Effect | +|---|---| +| `EmptyBehavior.preventEmpty()` | Refuses the close — the bottom-most destination stays put. | +| `EmptyBehavior.allowEmpty(onEmpty = { })` | Allows the container to become empty when its stack drains. The container stays mounted but renders nothing. The optional `onEmpty` lambda fires when this happens. | +| `EmptyBehavior.closeParent()` | When the last destination is closed, closes the *parent* destination too. This is what a typical detail-stack inside a list-detail layout wants. | +| `EmptyBehavior.default()` | An alias for the current default behaviour. Today that's `preventEmpty()`; future versions of Enro may change the default, so use this if you want to track whatever Enro considers most appropriate. | + +For custom behaviour, build an `EmptyBehavior` from a lambda (see +`EmptyBehavior.kt` in the source). + +## Reading and changing the backstack + +`NavigationContainerState` exposes a few useful members: + +```kotlin +val backstack: NavigationBackstack // current contents, observable +fun updateBackstack(block: (NavigationBackstack) -> NavigationBackstack) +fun execute(operation: NavigationOperation) // for advanced cases +``` + +Reading `state.backstack` from a Composable is a normal Compose state read — +the surrounding scope recomposes when the backstack changes. You can use this +to drive a bottom bar, breadcrumbs, or any UI element that mirrors the stack. + +`updateBackstack { it.drop(1) }` programmatically reduces the stack; +`updateBackstack { backstackOf(NewRoot.asInstance()) }` resets it entirely. +Most navigation goes through `NavigationHandle` operations, not direct +backstack edits — see [Navigation Handles](navigation-handles.md). + +## Filters + +A `NavigationContainerFilter` decides whether a given key is accepted by this +container. By default a container accepts everything (`acceptAll()`). + +Filters are how you steer navigation to the *right* container in an app with +several of them. A common pattern: a "details" container in a list-detail +layout accepts only detail-type keys; navigation to detail keys gets routed +there automatically. + +See the [list-detail recipe][listdetail-recipe] for a working example. + +## Interceptors + +A `NavigationInterceptor` observes (and optionally vetos or rewrites) every +operation before it applies to the container's backstack. Use cases: + +- Confirming "unsaved changes will be lost" before allowing a close. +- Logging or analytics on navigation events. +- Redirecting one key to another based on app state. + +Interceptors are built either from a `NavigationInterceptor` implementation +or from the `navigationInterceptor { }` builder. They can also be registered +globally on the `NavigationComponent` (see +[Installation](../getting-started/installation.md)). + +## Nested containers + +A container can be created inside a destination — the resulting container +is a *child* of that destination's context. This is how features like tabs, +list-detail layouts, and per-flow back stacks are built. + +```kotlin +@Composable +@NavigationDestination(MainTabs::class) +fun MainTabsDestination() { + var tab by remember { mutableStateOf(Tab.Home) } + + val homeContainer = rememberNavigationContainer( + key = NavigationContainer.Key("home"), + backstack = backstackOf(Home.asInstance()), + ) + val profileContainer = rememberNavigationContainer( + key = NavigationContainer.Key("profile"), + backstack = backstackOf(Profile.asInstance()), + ) + + Column { + when (tab) { + Tab.Home -> NavigationDisplay(state = homeContainer) + Tab.Profile -> NavigationDisplay(state = profileContainer) + } + TabBar(current = tab, onSelect = { tab = it }) + } +} +``` + +Two recipes go deeper on this pattern: + +- [Tab navigation][tabs-recipe] — a simple tabbed layout. +- [Multiple back stacks][multistack-recipe] — independent saveable backstacks per tab, à la Material's `BottomNavigationView`. + +## See also + +- [Navigation Destinations](navigation-destinations.md) — what fills a container. +- [Navigation Handles](navigation-handles.md) — how operations on a handle reach the container. +- [Animations](../advanced/animations.md) — `NavigationAnimations` and per-element animation. +- Recipes: [list-detail][listdetail-recipe], [tabs][tabs-recipe], [multiple back stacks][multistack-recipe]. + +[listdetail-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/listdetail/ListDetailNavigation.kt +[tabs-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/tabs/TabNavigation.kt +[multistack-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/multiplestacks/MultipleBackStacks.kt diff --git a/docs/ghpages/docs/core-concepts/navigation-destinations.md b/docs/ghpages/docs/core-concepts/navigation-destinations.md new file mode 100644 index 000000000..55e95c7c2 --- /dev/null +++ b/docs/ghpages/docs/core-concepts/navigation-destinations.md @@ -0,0 +1,240 @@ +--- +title: Navigation Destinations +parent: Core Concepts +nav_order: 2 +--- + +# Navigation Destinations + +A `NavigationDestination` is the *implementation* of a key — the screen +itself. Where a key is a contract ("a screen with these inputs and this +result"), a destination fulfils that contract. The two are bound together +with the `@NavigationDestination(KeyClass::class)` annotation. + +Enro discovers destinations through KSP. Annotate either a `@Composable` +function or a `NavigationDestinationProvider` `val`, and Enro generates +the binding code at build time. + +## Two ways to declare a destination + +### As a Composable function + +The simplest form. Use this whenever the screen doesn't need special metadata. + +```kotlin +@Composable +@NavigationDestination(ShowProfile::class) +fun ProfileScreen() { + val navigation = navigationHandle() + Text("Profile for ${navigation.key.userId}") +} +``` + +### As a provider val + +Use this when the destination needs metadata — to be rendered as a dialog, as +an overlay, or with any other behaviour controlled by a scene strategy. + +```kotlin +@NavigationDestination(ConfirmDialog::class) +val confirmDialogDestination: NavigationDestinationProvider = navigationDestination( + metadata = { + dialog( + dialogProperties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + ) + ) + }, +) { + AlertDialog( + onDismissRequest = { navigation.close() }, + title = { Text("Confirm") }, + text = { Text("Are you sure?") }, + confirmButton = { TextButton(onClick = { navigation.close() }) { Text("Yes") } }, + dismissButton = { TextButton(onClick = { navigation.close() }) { Text("No") } }, + ) +} +``` + +Inside the `navigationDestination { }` content block you have a +`NavigationDestinationScope` receiver — `navigation` and `key` are +properties on the scope. You also get `AnimatedVisibilityScope` and +`SharedTransitionScope` members for free, so you can use +`Modifier.animateEnterExit(...)`, shared elements, etc., directly. + +## Destination metadata and scene strategies + +A destination's metadata is a small typed bag attached when the destination +is *rendered*. It tells the active `NavigationSceneStrategy` how to treat +that destination — should it be rendered as a normal screen, a dialog, an +overlay above other content, or something else entirely. + +The built-in metadata helpers are: + +| Helper | Effect | +|---|---| +| *(none)* | The destination is rendered normally by `SinglePaneSceneStrategy` (or whichever pane-style strategy is active). | +| `dialog()` | The destination is rendered inside a Compose `Dialog`. Defaults to standard `DialogProperties`. | +| `dialog(dialogProperties = ...)` | As above with custom `DialogProperties`. | +| `directOverlay()` | The destination is rendered directly on top of the underlying scene with no window or shell. Snaps in/out by default. | +| `directOverlay(enter, exit)` | Same as `directOverlay()` but with explicit `EnterTransition` / `ExitTransition`. | +| `directOverlayWithFade(durationMillis = 128)` | Shortcut for symmetric fade in/out. | + +```kotlin +@NavigationDestination(InfoSheet::class) +val infoSheetDestination = navigationDestination( + metadata = { directOverlay() } +) { + AlertDialog(/* ... */) +} +``` + +### Conditional metadata + +The `metadata = { }` block runs per *instance*, so it can read the key: + +```kotlin +@Serializable +data class ItemDetail(val itemId: String, val showAsDialog: Boolean = false) : NavigationKey + +@NavigationDestination(ItemDetail::class) +val itemDetailDestination = navigationDestination( + metadata = { + if (key.showAsDialog) dialog() + // otherwise: rendered normally + } +) { + Column { Text("Item: ${navigation.key.itemId}") } +} +``` + +See the [destination registration recipe][entryprovider-recipe] for a worked +example covering both styles and conditional metadata side-by-side. + +## Synthetic destinations + +A third declaration form, `syntheticDestination`, binds a key to a block +of code instead of a UI. The key still exposes the same `open(...)` / +`complete(...)` API to callers, but when opened the synthetic runs its +block and never lands on a backstack: + +```kotlin +@Serializable +object Logout : NavigationKey + +@NavigationDestination(Logout::class) +val logout = syntheticDestination { + sessionRepository.clearSession() + open(LoginScreen()) +} +``` + +Synthetics are the right tool for side-effect bridges (launching an +external browser, sharing a system intent), conditional redirects (auth +gates), and "decider" patterns that pick one of several real destinations +to fulfil a single result contract. + +See [Synthetic Destinations](../advanced/synthetic-destinations.md) for the +full surface — outcome methods, the result-decider pattern via +`completeFrom`, and the rules of thumb for when to reach for one. + +## Scene strategies + +A `NavigationSceneStrategy` decides *how* the current backstack is laid out +visually. Enro composes a chain of strategies; the first one that wants to +handle the current backstack wins. The default chain in `NavigationDisplay` +is: + +```kotlin +NavigationSceneStrategy.from( + DialogSceneStrategy(), + DirectOverlaySceneStrategy(), + SinglePaneSceneStrategy(), +) +``` + +In order: if the top entry has `dialog()` metadata, render it inside a +`Dialog`; else if it has `directOverlay()` metadata, render it as a +no-shell overlay; else fall through to the single-pane strategy and render +it like a normal screen. + +You can replace the chain (or add to it) when you create a +`NavigationDisplay`: + +```kotlin +NavigationDisplay( + state = container, + sceneStrategy = NavigationSceneStrategy.from( + MyAdaptiveSceneStrategy(), + DialogSceneStrategy(), + DirectOverlaySceneStrategy(), + SinglePaneSceneStrategy(), + ), +) +``` + +The list-detail recipe shows a real adaptive scene strategy: +[`recipes/listdetail/ListDetailNavigation.kt`][listdetail-recipe]. + +## Custom enter/exit on overlays + +For overlay destinations, the simplest way to animate them in and out is the +`directOverlay(enter, exit)` overload: + +```kotlin +@NavigationDestination(Toast::class) +val toastDestination = navigationDestination( + metadata = { + directOverlay( + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + ) + }, +) { + Surface { Text(navigation.key.message) } +} +``` + +For animating *parts* of a destination — for example, the scrim and the card +of a dialog at different rates — see the +[Animations](../advanced/animations.md) page. + +## Registering a destination without the annotation + +If you'd rather not use KSP, you can register a `NavigationDestinationProvider` +manually in the module DSL: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + destination(confirmDialogDestination) + destination(itemDetailDestination) + } +) +``` + +In a multi-module setup, every module that contains `@NavigationDestination` +annotations must apply the `enro-processor` KSP dependency for those +destinations to be picked up. The app module simply depends transitively on +those modules; you do not need to register the destinations again. + +## Fragment and Activity destinations + +Enro 3 is Compose-first, but Android Fragments and Activities are supported +through the `enro-compat` module. A Fragment or Activity is registered the +same way — `@NavigationDestination(KeyClass::class)` on the class — and the +runtime takes care of bridging it. See the +[Android platform guide](../platform/android.md). + +## See also + +- [Navigation Keys](navigation-keys.md) — the contract side of the equation. +- [Navigation Containers](navigation-containers.md) — where destinations live. +- [Animations](../advanced/animations.md) — animating destinations and their content. +- [Recipes][recipes] — every metadata style and several custom scene strategies. + +[entryprovider-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/entryprovider/DestinationRegistration.kt +[listdetail-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/listdetail/ListDetailNavigation.kt +[recipes]: https://github.com/isaac-udy/Enro/tree/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes diff --git a/docs/ghpages/docs/core-concepts/navigation-handles.md b/docs/ghpages/docs/core-concepts/navigation-handles.md new file mode 100644 index 000000000..094424d4b --- /dev/null +++ b/docs/ghpages/docs/core-concepts/navigation-handles.md @@ -0,0 +1,325 @@ +--- +title: Navigation Handles +parent: Core Concepts +nav_order: 4 +--- + +# Navigation Handles + +A `NavigationHandle` is the control surface inside a screen — the variable +you call `open`, `close`, and `complete` on to drive navigation. Every +destination has one. You read the current key off it; you execute operations +through it. + +```kotlin +val navigation = navigationHandle() + +navigation.open(SelectDate(maxDate = LocalDate.now())) // open another screen +navigation.close() // close this screen +navigation.complete(result) // close with a result (WithResult keys only) +``` + +## Getting a handle + +### In a Composable + +```kotlin +@Composable +@NavigationDestination(ShowProfile::class) +fun ProfileScreen() { + val navigation = navigationHandle() + Text("Profile for ${navigation.key.userId}") +} +``` + +`navigationHandle()` is a Composable function. The type parameter +makes `navigation.key` typed. + +If you don't care about the key's type (because you're writing reusable +logic that doesn't depend on it), use the untyped form: + +```kotlin +val navigation = navigationHandle() +``` + +### In a ViewModel + +Use the property delegate: + +```kotlin +class ProfileViewModel : ViewModel() { + val navigation by navigationHandle() +} +``` + +The handle is resolved when the ViewModel is constructed by Enro's helper +factory — see [View Models](../advanced/view-models.md). + +### Inside a `navigationDestination { }` provider + +The provider's content lambda has a `NavigationDestinationScope` receiver, +which exposes `navigation` and `key` directly: + +```kotlin +@NavigationDestination(ConfirmDialog::class) +val confirmDialogDestination = navigationDestination( + metadata = { dialog() } +) { + AlertDialog( + onDismissRequest = { navigation.close() }, + confirmButton = { Button(onClick = { navigation.close() }) { Text("OK") } }, + title = { Text("Confirm") }, + text = { Text("Item: ${key /* same as navigation.key */}") }, + ) +} +``` + +## What's on a handle + +```kotlin +abstract class NavigationHandle : LifecycleOwner { + val key: T // shorthand for instance.key + abstract val instance: NavigationKey.Instance + abstract val savedStateHandle: SavedStateHandle + abstract fun execute(operation: NavigationOperation) +} +``` + +- **`key`** — the key the screen was opened with, typed. +- **`instance`** — the wrapping `NavigationKey.Instance` (id + metadata). +- **`savedStateHandle`** — a per-destination `SavedStateHandle`, scoped to + this navigation entry. Persists across configuration changes and process + death. +- **`execute(operation)`** — the underlying primitive every operation below + funnels through. You rarely call this directly. + +`NavigationHandle` also implements `LifecycleOwner`, so you can launch +coroutines tied to the destination's lifecycle. + +## Operations + +All operations live in the `dev.enro` package as extension functions on +`NavigationHandle`. Import them individually. + +### Opening another destination + +```kotlin +navigation.open(SelectDate(maxDate = LocalDate.now())) +navigation.open(ShowProfile("user-123").withMetadata(IsExpanded, true)) +``` + +`open` accepts either a `NavigationKey` or a `NavigationKey.WithMetadata<*>` +(produced by `key.withMetadata(MetadataKey, value)`). + +For result-producing keys, prefer a result channel +(`registerForNavigationResult`) over `open` — see +[Results](../advanced/results.md). + +### Closing the current destination + +```kotlin +navigation.close() // close this destination +navigation.requestClose() // ask this destination to close +``` + +`close()` and `requestClose()` are both ways of dismissing a destination, +but they're not interchangeable. + +- `close()` closes the destination directly. +- `requestClose()` invokes the destination's *onCloseRequested* callback. + By default that callback just calls `close()`, so unless you've overridden + it, `requestClose()` and `close()` do the same thing. Overriding the + callback lets you put logic between the request and the actual close. + +The Android back button calls `requestClose()`, not `close()`. Custom +"dismiss" UI in your destination (a back arrow, an X button, a backdrop tap +on a dialog) should call `requestClose()` too. As a rule of thumb, +**prefer `requestClose()` for anything user-initiated** — you'll lose +nothing today and gain the ability to add behaviour later without hunting +down every call site. + +### Overriding the close-requested callback + +In a Composable destination, configure the callback with +`navigation.configure { onCloseRequested { ... } }`. The `configure` block +is a `DisposableEffect`, so the callback is automatically cleaned up when +the destination leaves the composition. + +```kotlin +@Composable +@NavigationDestination(EditProfile::class) +fun EditProfileScreen() { + val navigation = navigationHandle() + var draft by remember { mutableStateOf(navigation.key.initial) } + + val confirmDiscard = registerForNavigationResult( + onCompleted = { discard -> if (discard) navigation.close() }, + ) + + navigation.configure { + onCloseRequested { + if (draft == navigation.key.initial) { + close() // nothing changed — close directly + } else { + confirmDiscard.open(ConfirmDiscardDialog) + } + } + } + + // ... +} +``` + +In a `ViewModel`, pass a `config` block to the `by navigationHandle` +delegate: + +```kotlin +class EditProfileViewModel : ViewModel() { + val draft = MutableStateFlow("") + private val originalValue: String get() = navigation.key.initial + + val navigation by navigationHandle { + onCloseRequested { + if (draft.value == originalValue) { + close() + } else { + // delegate to a UI-side handler, emit an event, etc. + viewModelScope.launch { closeRequests.emit(Unit) } + } + } + } + + val closeRequests = MutableSharedFlow() +} +``` + +The ViewModel form is configured once at construction time and cleaned up +when the ViewModel is cleared. + +Two things to notice in the Composable example above: + +1. The "no changes" branch calls `close()`, **not** `requestClose()` — + calling `requestClose()` from inside the `onCloseRequested` callback + would loop forever. +2. The confirmation dialog is opened through a `registerForNavigationResult` + channel so the screen can react to the user's confirmation. See + [Results](../advanced/results.md). + +Other common reasons to override `onCloseRequested`: + +- Block the back button while a save is in progress. +- Run a cleanup side-effect before closing. +- Redirect the close to a different operation + (e.g. `completeFrom(otherKey)`). + +A worked end-to-end example lives in the +[request-close confirmation recipe][requestclose-recipe]. + +### One callback per handle + +A navigation handle can have at most one active `onCloseRequested` callback. +Registering more than one for the same handle — whether that's two +`configure { }` blocks in the same composable, two `by navigationHandle` +properties on the same ViewModel, or one of each running concurrently — is +an error and will throw when `requestClose()` is invoked. Decide where the +callback belongs (typically the ViewModel if you have one, otherwise the +top-level Composable for the destination) and register it once. + +### Closing vs completing + +Every destination can be closed *or* completed — those aren't determined by +the key's type, they're determined by what the destination is trying to +communicate to its caller. + +- `close()` — the screen is going away **without** finishing the task it + was opened for. The user backed out, cancelled, dismissed the dialog, etc. +- `complete()` / `complete(result)` — the screen is going away **because** + it finished the task it was opened for. + +The distinction is delivered to whoever opened the destination through a +`registerForNavigationResult` channel: `onCompleted` fires for `complete`, +`onClosed` fires for `close`. See [Results](../advanced/results.md). + +#### With a result + +For destinations whose key implements `NavigationKey.WithResult`, +`complete` requires the result value: + +```kotlin +navigation.complete(LocalDate.now()) +``` + +Calling `complete()` with no argument on a `WithResult` handle is a +compile error — a result-producing key has no meaningful completion without +the result. + +#### Without a result + +For destinations whose key has no result, both `close()` and `complete()` +are valid. The choice is semantic: + +```kotlin +@Composable +@NavigationDestination(ConfirmDelete::class) +fun ConfirmDeleteDialog() { + val navigation = navigationHandle() + AlertDialog( + onDismissRequest = { navigation.requestClose() }, // user backed out + confirmButton = { + Button(onClick = { navigation.complete() }) { Text("Delete") } // confirmed + }, + dismissButton = { + Button(onClick = { navigation.close() }) { Text("Cancel") } // cancelled + }, + ) +} +``` + +Even though `ConfirmDelete` has no result type, the *caller* still gets to +distinguish "the user confirmed" from "the user cancelled" by registering +both `onCompleted` and `onClosed` on its result channel. + +### Composite operations + +A few one-call shortcuts for common patterns: + +| Operation | Effect | +|---|---| +| `navigation.closeAndReplaceWith(otherKey)` | Closes this destination and opens `otherKey` in a single atomic operation. | +| `navigation.completeFrom(anotherKey)` | Completes this destination by *delegating* to another key — opens `anotherKey`, and when *that* destination completes, this one also completes with the same result. Useful for screen "redirection". | +| `navigation.closeAndCompleteFrom(otherKey)` | Combines the above — close this destination, then route completion through `otherKey`. | + +Type constraints apply: a `NavigationKey.WithResult` handle can only +`completeFrom` another `NavigationKey.WithResult` with the matching +result type. The compiler enforces it. + +## Reading and writing per-destination state + +The `savedStateHandle` on a `NavigationHandle` is scoped to that destination +and survives process death. Use it the same way you'd use a +`SavedStateHandle` on a regular Android ViewModel. + +For Composable state that should survive process death, prefer +`rememberSaveable` — the navigation container's `NavigationSavedStateHolder` +takes care of scoping it correctly per destination. + +## A note on lifetime + +A handle is bound to one *instance* on the backstack. If your screen appears +twice in the backstack (different `Instance`s of the same key), there are +two handles in flight — one for each instance, each with its own +`savedStateHandle`. + +If you've pushed your destination and then pushed something on top of it, +your handle is still valid; the destination is composed but not visible. +When the user navigates back, the same handle resumes. + +## See also + +- [Navigation Keys](navigation-keys.md) — what a handle is parameterised by. +- [Navigation Destinations](navigation-destinations.md) — where handles come from. +- [Results](../advanced/results.md) — `complete` and `registerForNavigationResult` in depth. +- [View Models](../advanced/view-models.md) — `by navigationHandle()` inside a ViewModel. +- [Request-close confirmation recipe][requestclose-recipe] — full unsaved-changes example. + +[requestclose-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/requestclose/RequestCloseConfirmation.kt diff --git a/docs/ghpages/docs/core-concepts/navigation-keys.md b/docs/ghpages/docs/core-concepts/navigation-keys.md new file mode 100644 index 000000000..0a170ec8c --- /dev/null +++ b/docs/ghpages/docs/core-concepts/navigation-keys.md @@ -0,0 +1,210 @@ +--- +title: Navigation Keys +parent: Core Concepts +nav_order: 1 +--- + +# Navigation Keys + +A `NavigationKey` is the contract for a screen — its function signature. The +properties of the key are the inputs to the screen. If the screen produces a +typed return value, the key implements `NavigationKey.WithResult`. + +Keys are values: declarative, serializable, and decoupled from the screen +they identify. Callers refer to screens only through their keys, never +through their implementations. + +```kotlin +@Serializable +data class ShowProfile(val userId: String) : NavigationKey +``` + +Read that as `fun showProfile(userId: String): Unit`. + +```kotlin +@Serializable +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.WithResult +``` + +Read that as `fun selectDate(minDate: LocalDate? = null, maxDate: LocalDate? = null): LocalDate`. + +## Serialization + +Keys must be `@Serializable` (kotlinx.serialization). Enro uses the serializer +to persist backstacks across process death, to bridge instances between +platforms, and to support deep links. + +Every property on the key needs a serializer too. Built-in Kotlin types +(`String`, `Int`, `Boolean`, `List`, `Map`, nullable types, +`kotlin.uuid.Uuid`, etc.) work without configuration. For domain types you +own, annotate them with `@Serializable`: + +```kotlin +@Serializable +data class UserId(val value: String) + +@Serializable +data class ShowProfile(val userId: UserId) : NavigationKey +``` + +For types you don't own (or for polymorphic types in a sealed hierarchy), +register a custom `SerializersModule` on your `NavigationComponent`: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + serializersModule(kotlinx.serialization.modules.SerializersModule { + // contextual / polymorphic registrations + }) + } +) +``` + +## Keys with results + +A key that implements `NavigationKey.WithResult` declares that the screen +produces a value of type `T` to its caller. The screen returns the value by +calling `navigation.complete(value)`. The caller receives it through a +`registerForNavigationResult` channel — see [Results](../advanced/results.md). + +```kotlin +@Serializable +data class ConfirmDelete(val itemName: String) : NavigationKey.WithResult +``` + +Whether a key implements `WithResult` only determines whether `complete` +*requires* a value — every screen can still use both `close` and `complete` +regardless. See [close vs complete](navigation-handles.md#closing-vs-completing) +on the Navigation Handles page for the semantic distinction. + +## NavigationKey.Instance + +A key on its own is a value — the same key may appear in a backstack more +than once (push the same profile, push another copy of the same profile). To +distinguish individual appearances, Enro wraps every key in a +`NavigationKey.Instance` when it enters a backstack. Each instance has a +unique `id` and its own `metadata`. + +You'll usually only see `Instance` when building the initial backstack: + +```kotlin +val initial = backstackOf( + Home.asInstance(), + ShowProfile("user-123").asInstance(), +) +``` + +`asInstance()` is the standard way to wrap a key. Two instances of the same +key are distinct values (different `id`s) — `Set>` +will not collapse them. + +## Instance metadata + +`NavigationKey.Metadata` is a typed, serializable key-value bag attached to +each `Instance` on the backstack. It's a place to carry data **about a +particular appearance of a key** that isn't part of the key's contract. + +A few real-world uses for instance metadata: + +- The screen-space coordinates of the click that opened this destination, + so an animation can play from that point. +- An analytics correlation id assigned at the moment the instance was + created, so events emitted while the destination is open can be linked + back to its origin. +- A timestamp of when this instance was opened. +- A note indicating which feature flow opened this destination. + +These are all things that *might be present, or might not*, and that don't +change what the destination is or how it renders. They're closer to "extra +parameters that any key might carry" than to anything in the key itself. + +> **Don't confuse this with destination metadata.** A +> `navigationDestination(metadata = { dialog() }) { ... }` block configures +> *the destination* — it drives rendering decisions like "show this as a +> dialog" or "render as an overlay." That's a separate system. See +> [Navigation Destinations](navigation-destinations.md#destination-metadata-and-scene-strategies). + +Define a `MetadataKey` to read and write a metadata value: + +```kotlin +object OpenedAt : NavigationKey.MetadataKey(default = null) +``` + +Attach a value to a key for a single open via `withMetadata`: + +```kotlin +navigation.open( + ShowProfile("user-123").withMetadata(OpenedAt, Clock.System.now().toEpochMilliseconds()) +) +``` + +Or set it from a [plugin](../advanced/plugins.md) on every instance globally +— useful when you want metadata that applies uniformly across the app (an +analytics correlation id, an origin point captured from the last user input, +an opened-at timestamp, etc.). The +[OpenedTimestampPlugin recipe][timestamp-recipe] is a small worked example +of this. + +A `MetadataKey`'s `name` is its `qualifiedName` — make it an `object` or a +top-level singleton. + +For metadata that should not be persisted across process death, use +`TransientMetadataKey`. This is marked `@AdvancedEnroApi` because skipping +persistence has consequences — your code has to tolerate the value being +absent after the app is restored from saved state. + +## Naming conventions + +A `NavigationKey` should read like a verb-phrase or noun-phrase for a screen: + +- `ShowProfile`, `EditProfile`, `SelectDate`, `ConfirmDelete` ✅ +- `ProfileKey`, `ProfileScreenKey`, `ProfileNavigationKey` ❌ (the `Key` + suffix is redundant — the type already tells you what it is) + +For keys that produce results, the name often matches the result: + +- `SelectDate : NavigationKey.WithResult` +- `PickContact : NavigationKey.WithResult` +- `ConfirmDelete : NavigationKey.WithResult` + +A `data object` is the right choice for a key with no inputs: + +```kotlin +@Serializable +data object Home : NavigationKey + +@Serializable +data object PickName : NavigationKey.WithResult +``` + +## Where keys live + +In a multi-module project, a `NavigationKey` should live in the *contract* +module for the feature it represents — not in the module that *implements* +the screen. That's the whole point of the contract: callers depend on the +key without depending on the implementation. + +A common arrangement: + +``` +:feature-profile-api ← contains ShowProfile, EditProfile, etc. +:feature-profile-impl ← contains the Composables annotated with @NavigationDestination +:app ← depends on both, plus :feature-other-impl, etc. +``` + +See the [modular navigation recipe][modular-recipe] for a worked example. + +## See also + +- [Navigation Destinations](navigation-destinations.md) — how a key is bound to a screen implementation. +- [Navigation Handles](navigation-handles.md) — how a screen reads its own key. +- [Results](../advanced/results.md) — how `WithResult` keys produce and consume values. +- [Basic recipe][basic-recipe] — minimal end-to-end example. + +[basic-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/basic/BasicNavigation.kt +[modular-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/modular/ModularNavigation.kt +[timestamp-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/plugins/OpenedTimestampPlugin.kt diff --git a/docs/ghpages/docs/getting-started/basic-concepts.md b/docs/ghpages/docs/getting-started/basic-concepts.md new file mode 100644 index 000000000..72a895613 --- /dev/null +++ b/docs/ghpages/docs/getting-started/basic-concepts.md @@ -0,0 +1,172 @@ +--- +title: Basic Concepts +parent: Getting Started +nav_order: 2 +--- + +# Basic Concepts + +This page is the short vocabulary tour. Each concept here gets its own page +under *Core Concepts* — read this one first so the rest of the docs make +sense. + +The central idea is that **screens behave like functions**. A screen has a +contract (its inputs and an optional return value); calling code invokes the +contract without knowing how the screen is implemented. + +## Navigation Keys + +A `NavigationKey` is the contract for a screen — its function signature. The +properties of the key are the inputs to the screen. If the screen produces a +value, the key implements `NavigationKey.WithResult`. + +```kotlin +@Serializable +data class ShowProfile(val userId: String) : NavigationKey + +@Serializable +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.WithResult +``` + +Keys are `@Serializable` (kotlinx.serialization) so Enro can persist the +backstack across process death and across platforms. + +A key on its own doesn't do anything — it's a value. The system that *invokes* +the contract is the navigation handle (below). + +## Navigation Destinations + +A `NavigationDestination` is the *implementation* of a contract — the screen +itself. It's bound to a key with the `@NavigationDestination(KeyClass::class)` +annotation. Two styles are supported: + +A regular Composable function: + +```kotlin +@Composable +@NavigationDestination(ShowProfile::class) +fun ProfileScreen() { + val navigation = navigationHandle() + Text("Profile for ${navigation.key.userId}") +} +``` + +Or a destination provider, when the destination needs metadata (for example, +to behave like a dialog or an overlay): + +```kotlin +@NavigationDestination(ShowProfile::class) +val profileDestination = navigationDestination { + Text("Profile for ${navigation.key.userId}") +} +``` + +The provider form is also what you use to declare a dialog, bottom sheet, or +custom overlay — through the `metadata = { dialog() }` or +`metadata = { directOverlay() }` builders. See +[Navigation Destinations](../core-concepts/navigation-destinations.md). + +## Navigation Handle + +A `NavigationHandle` is the control surface inside a screen — the variable +you call `open`, `close`, or `complete` on. + +```kotlin +val navigation = navigationHandle() + +navigation.open(SelectDate(maxDate = LocalDate.now())) // open another screen +navigation.close() // close this screen +navigation.complete(result) // close with a result +``` + +The typed parameter (`` above) gives you access to the key the +screen was opened with via `navigation.key`. + +## Navigation Container + +A `NavigationContainer` is a location in your UI that hosts a backstack of +destinations. You create one inside a Composable with +`rememberNavigationContainer`, give it an initial backstack, and render it with +`NavigationDisplay`. + +```kotlin +val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), +) +NavigationDisplay(state = container) +``` + +A typical app has one root container; nested containers are supported and are +how features like tabs, list-detail layouts, and multiple back stacks are built. + +## NavigationKey.Instance + +When a key is added to a backstack, Enro wraps it in a +`NavigationKey.Instance` — the same key may appear in the backstack more than +once, so each appearance gets a unique `id`. You'll mostly see `Instance` when +building a backstack: + +```kotlin +val initial = backstackOf( + Home.asInstance(), + ShowProfile("user-123").asInstance(), +) +``` + +You can also attach `Metadata` to an instance to influence how that particular +appearance behaves (animations, scene treatment, etc.). + +## Results + +A `NavigationKey.WithResult` screen returns a value to its caller. Callers +register a result channel and call `open` on it. + +```kotlin +val getDate = registerForNavigationResult( + onCompleted = { date -> /* use date */ }, +) + +Button(onClick = { getDate.open(SelectDate(maxDate = LocalDate.now())) }) { + Text("Pick a date") +} +``` + +The screen returns its value with `navigation.complete(date)`. See +[Results](../advanced/results.md). + +## NavigationComponent + +A `NavigationComponent` is the configuration object for Enro in your +application. It's declared once, annotated with `@NavigationComponent`, and +installed at app startup. + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { /* optional config */ } +) +``` + +See [Installation](installation.md). + +## Where everything fits + +``` +Application starts + └── MyComponent.installNavigationController(this) ← happens once + +Compose tree + └── rememberNavigationContainer(backstack = ...) ← NavigationContainer + └── NavigationDisplay(state = container) + └── Destination for the current key on the stack + └── navigationHandle() ← NavigationHandle + └── .open(...) / .close() / .complete(...) +``` + +## Next steps + +- Walk through [Your First Screen](your-first-screen.md) for a complete end-to-end example. +- Then read the [Core Concepts](../../index.md#core-concepts) pages in order — they go into each of the above in depth. diff --git a/docs/ghpages/docs/getting-started/index.md b/docs/ghpages/docs/getting-started/index.md new file mode 100644 index 000000000..f53b66049 --- /dev/null +++ b/docs/ghpages/docs/getting-started/index.md @@ -0,0 +1,20 @@ +--- +title: Getting Started +nav_order: 2 +has_children: true +--- + +# Getting Started + +These three pages take you from an empty project to a working Enro screen, +in order. + +- [Installation](installation.md) — add Enro to your project and install + it on each platform. +- [Basic Concepts](basic-concepts.md) — the small vocabulary you need to + read the rest of the docs. +- [Your First Screen](your-first-screen.md) — a complete worked example, + from defining a key to receiving a result. + +If you're upgrading an existing Enro 2 app, jump to the +[Migrating from Enro 2](../migrating-from-v2.md) guide first. diff --git a/docs/ghpages/docs/getting-started/installation.md b/docs/ghpages/docs/getting-started/installation.md new file mode 100644 index 000000000..c44330576 --- /dev/null +++ b/docs/ghpages/docs/getting-started/installation.md @@ -0,0 +1,177 @@ +--- +title: Installation +parent: Getting Started +nav_order: 1 +--- + +# Installation + +Enro is published to [Maven Central](https://search.maven.org/). Make sure your +project includes the `mavenCentral()` repository, then add the dependencies for +your platform below. + +## Dependencies + +```kotlin +plugins { + id("com.google.devtools.ksp") version "" +} + +dependencies { + // Core library + implementation("dev.enro:enro:3.0.0-beta01") + + // KSP processor — generates the install function for your NavigationComponent + // and discovers @NavigationDestination annotations + ksp("dev.enro:enro-processor:3.0.0-beta01") + + // Optional: test utilities + testImplementation("dev.enro:enro-test:3.0.0-beta01") +} +``` + +In a Kotlin Multiplatform project, add `enro` to your `commonMain` source set, +and add the `enro-processor` KSP dependency for each target you build. + +If you have an existing Android app that uses Fragments or Activities and want +to adopt Enro incrementally, also add the compatibility module: + +```kotlin +dependencies { + implementation("dev.enro:enro-compat:3.0.0-beta01") +} +``` + +## Declare a NavigationComponent + +A `NavigationComponent` is the entry point for Enro into your application. +Declare one as an `object` extending `NavigationComponentConfiguration`, +annotated with `@NavigationComponent`. KSP will generate an +`installNavigationController` extension on that object for each platform you +target. + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + // Optional configuration: + // plugins, interceptors, decorators, custom serializers, + // additional modules from other libraries, etc. + } +) +``` + +The `module` block is where you compose extra configuration. You rarely need +anything in it to start — destinations are discovered automatically through +the `@NavigationDestination` annotations on your screens. + +In a multi-platform project, you can declare one `NavigationComponent` in the +common source set and use it from every target. + +## Install Enro on each platform + +### Android + +Call `installNavigationController` from your `Application.onCreate`: + +```kotlin +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + MyComponent.installNavigationController(this) + } +} +``` + +Then host a backstack from any `Activity` or Composable. The typical pattern is +to call `rememberNavigationContainer` and `NavigationDisplay` from your root +Composable: + +```kotlin +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + NavigationDisplay(state = container) + } + } +} +``` + +If you're migrating from Fragments or Activities, see the [Android platform +guide](../platform/android.md) and add `enro-compat` to keep your existing +screens working. + +### iOS + +On iOS, install the controller during app startup and expose a Composable view +controller for Swift to display. + +```kotlin +fun MainViewController(): UIViewController = EnroUIViewController { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + NavigationDisplay(state = container) +} +``` + +The `installNavigationController` call for iOS is typically made once at app +startup; see the [iOS platform guide](../platform/ios.md) for the Swift +boilerplate to bridge the generated view controller into your app. + +### Desktop + +Call `installNavigationController(Unit)` to get an `EnroController`, then drive +your windows through it: + +```kotlin +fun main() { + val controller = MyComponent.installNavigationController(Unit) + controller.openWindow(/* a root window descriptor */) + + application { + EnroApplicationContent(controller) + } +} +``` + +See the [Desktop platform guide](../platform/desktop.md) for the full window +configuration story and the [recipes desktop main] +[recipes-desktop] for a complete working example. + +### Web (WasmJS) + +Call `installNavigationController(document)` from your `main`, then render the +backstack inside an `EnroBrowserContent`: + +```kotlin +fun main() { + MyComponent.installNavigationController(document) + + ComposeViewport { + EnroBrowserContent { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + InstallWebHistoryPlugin(container) + NavigationDisplay(state = container) + } + } +} +``` + +`InstallWebHistoryPlugin` ties your container to browser history so the back +button and URL bar behave as users expect. See the +[Web platform guide](../platform/web.md) for more. + +## Next steps + +- Read [Basic Concepts](basic-concepts.md) for the short vocabulary tour. +- Walk through [Your First Screen](your-first-screen.md) for an end-to-end example. +- If you're upgrading an existing app, read the [migration guide](../migrating-from-v2.md). + +[recipes-desktop]: https://github.com/isaac-udy/Enro/blob/main/recipes/app/desktop/src/desktopMain/kotlin/main.kt diff --git a/docs/ghpages/docs/getting-started/your-first-screen.md b/docs/ghpages/docs/getting-started/your-first-screen.md new file mode 100644 index 000000000..d8e79df69 --- /dev/null +++ b/docs/ghpages/docs/getting-started/your-first-screen.md @@ -0,0 +1,177 @@ +--- +title: Your First Screen +parent: Getting Started +nav_order: 3 +--- + +# Your First Screen + +This page walks through a complete, working example: two screens that navigate +to each other, plus a third screen that returns a result. By the end you'll +have touched every concept covered in [Basic Concepts](basic-concepts.md). + +If you'd rather read the finished code, the equivalent runnable example lives in +[`recipes/basic/BasicNavigation.kt`][basic-recipe] and +[`recipes/results/ReturningResults.kt`][results-recipe]. + +## 1. Define the keys + +A key is a contract — what does this screen need, and what does it produce? + +```kotlin +@Serializable +data object Home : NavigationKey + +@Serializable +data class Profile(val userId: String) : NavigationKey + +@Serializable +data object PickName : NavigationKey.WithResult +``` + +`Home` takes no inputs. `Profile` requires a `userId`. `PickName` produces a +`String` back to whoever opened it. + +## 2. Implement the destinations + +Each destination is annotated with `@NavigationDestination(KeyClass::class)`. +The simplest form is a `@Composable` function. + +```kotlin +@Composable +@NavigationDestination(Home::class) +fun HomeScreen() { + val navigation = navigationHandle() + + val askForName = registerForNavigationResult( + onCompleted = { name -> /* we'll use it in a moment */ }, + ) + + Column { + Text("Welcome home") + + Button(onClick = { navigation.open(Profile("user-123")) }) { + Text("View profile") + } + + Button(onClick = { askForName.open(PickName) }) { + Text("Pick a name") + } + } +} + +@Composable +@NavigationDestination(Profile::class) +fun ProfileScreen() { + val navigation = navigationHandle() + + Column { + Text("Profile for ${navigation.key.userId}") + Button(onClick = { navigation.close() }) { Text("Back") } + } +} + +@Composable +@NavigationDestination(PickName::class) +fun PickNameScreen() { + val navigation = navigationHandle() + var input by remember { mutableStateOf("") } + + Column { + TextField(value = input, onValueChange = { input = it }) + + Button(onClick = { navigation.complete(input) }) { + Text("Done") + } + Button(onClick = { navigation.close() }) { + Text("Cancel") + } + } +} +``` + +A few things worth noticing: + +- `navigationHandle()` returns a `NavigationHandle` that knows the type of the + current key — `navigation.key` is typed `Profile` inside `ProfileScreen`. +- `navigation.open(otherKey)` adds another destination on top of this one. +- `navigation.close()` removes the current destination. +- `navigation.complete(value)` is the result-returning form of `close()` — only + callable on a destination whose key implements `NavigationKey.WithResult`. +- `registerForNavigationResult` returns a channel; `open` it with a key and + `onCompleted` is called with the result. There's also `onClosed` for when the + user dismisses without producing a result. + +## 3. Wire up the NavigationComponent + +Declare a `NavigationComponent` once for your application: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { } +) +``` + +KSP generates an `installNavigationController` extension on this object for +each platform you target. On Android, install it in your `Application`: + +```kotlin +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + MyComponent.installNavigationController(this) + } +} +``` + +See [Installation](installation.md) for the equivalent on iOS, Desktop and +Web. + +## 4. Host the backstack + +Decide where your navigation lives in the UI. The minimal pattern is a single +container at the root of your Compose tree: + +```kotlin +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + NavigationDisplay(state = container) + } + } +} +``` + +`backstackOf(Home.asInstance())` says "start with `Home` on the stack". +`NavigationDisplay` watches the container's backstack and renders the current +destination, animating transitions for you. + +## 5. Run it + +Open the app. You're on `Home`. Tap **View profile** — the backstack pushes +`Profile`, and `NavigationDisplay` animates the transition. Tap **Back** — +`Profile` closes and you're back on `Home`. + +Tap **Pick a name** — `PickName` opens. Type something and tap **Done**. The +`onCompleted` callback fires on `Home` with the string you typed. + +## What's next + +That's a working app. From here: + +- For different presentation styles (dialogs, bottom sheets, custom overlays), + see [Navigation Destinations](../core-concepts/navigation-destinations.md). +- For multiple containers, tabs, or nested back stacks, see + [Navigation Containers](../core-concepts/navigation-containers.md). +- For richer result handling, see [Results](../advanced/results.md). +- For custom transitions and per-element animations, see + [Animations](../advanced/animations.md). +- For testing, see [Testing](../advanced/testing.md). + +[basic-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/basic/BasicNavigation.kt +[results-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/results/ReturningResults.kt diff --git a/docs/ghpages/docs/migrating-from-v2.md b/docs/ghpages/docs/migrating-from-v2.md new file mode 100644 index 000000000..f002558ea --- /dev/null +++ b/docs/ghpages/docs/migrating-from-v2.md @@ -0,0 +1,383 @@ +--- +title: Migrating from Enro 2 +nav_order: 90 +--- + +# Migrating from Enro 2 + +Enro 3 is a substantial rewrite. The conceptual model is the same — screens +behave like functions, with typed contracts and decoupled implementations — +but most APIs have changed names, shapes, or both, and the library is now +Kotlin Multiplatform and Compose-first. + +This guide covers the API delta with concrete before/after pairs, and ends +with a walkthrough of a small screen converted from v2 to v3. + +If you have a working Enro 2 app with Fragments or Activities, you can adopt +Enro 3 incrementally by depending on `enro-compat`, which keeps the +Fragment/Activity story working alongside new Compose destinations. See +[Android platform guide](platform/android.md). + +## Big picture changes + +- **Kotlin Multiplatform.** Enro 3 targets Android, iOS, Desktop and Web + through Compose Multiplatform. Enro 2's Android-only assumptions + (Parcelize, Fragments, Activities) have been replaced with KMP-friendly + primitives. +- **Compose-first.** A Composable function annotated with + `@NavigationDestination` is the canonical destination. Fragments and + Activities live in `enro-compat`. +- **`@Parcelize` → `@Serializable`.** Navigation keys are serialized with + kotlinx.serialization so they work on every platform. +- **Push vs Present is gone.** A `NavigationKey` no longer carries + presentation semantics. Behaviour like *"open as a dialog"* now lives in + destination metadata and is dispatched by scene strategies. +- **`NavigationApplication` is gone.** You install the controller from + `Application.onCreate` (or the equivalent on other platforms) with a + one-liner. +- **`NavigationInstruction` is now `NavigationOperation`.** Same idea, more + cases (`Open`, `Close`, `Complete`, `CompleteFrom`, `SetBackstack`, + `SideEffect`, `AggregateOperation`). +- **KSP, not kapt.** The processor is `enro-processor` and is applied via the + `com.google.devtools.ksp` Gradle plugin. + +## API delta at a glance + +| Enro 2 | Enro 3 | +|---|---| +| `NavigationKey.SupportsPush`, `SupportsPresent`, `SupportsPresent.WithResult` | `NavigationKey`, `NavigationKey.WithResult` | +| `@Parcelize class Key(...) : NavigationKey.SupportsPush` | `@Serializable data class Key(...) : NavigationKey` | +| `navigation.push(key)` / `navigation.present(key)` | `navigation.open(key)` | +| `navigation.closeWithResult(result)` | `navigation.complete(result)` | +| `class MyApp : Application(), NavigationApplication { override val navigationController = ... }` | `MyComponent.installNavigationController(this)` inside `Application.onCreate` | +| `NavigationController` (singleton on `Application`) | `EnroController` (per-component) | +| `NavigationInstruction.Open` / `.Close` / `.RequestClose` | `NavigationOperation.Open` / `.Close` / `.Complete` / `.SetBackstack` / etc. | +| `NavigationExecutor` | Gone — scene strategies handle dispatch. | +| `NavigationBinding` (registered manually or via codegen) | `NavigationBinding` still exists but is generated by KSP from `@NavigationDestination` annotations. Rarely written by hand. | +| `enroViewModels()` property delegate | `viewModel { createEnroViewModel { MyVm() } }` from Compose, or `by navigationHandle()` inside a regular ViewModel. | +| `kapt("dev.enro:enro-processor:...")` | `ksp("dev.enro:enro-processor:...")` | +| `composableDestination { MyScreen() }` (in DSL) | Either `@NavigationDestination(Key::class)` on a Composable, or `val foo = navigationDestination { ... }` with `destination(foo)` in `createNavigationModule`. | +| `composeEnvironment { content -> ... }` in module | Deprecated. Use `decorator { navigationDestinationDecorator { ... } }`. | +| `syntheticDestination { sendResult(value) }` | `syntheticDestination { complete(value) }` — see [Synthetic Destinations](advanced/synthetic-destinations.md). The v2 `sendResult` and `forwardResult` extensions still exist in `enro-compat` as `@Deprecated` shims pointing at the new methods. | +| `syntheticDestination { forwardResult(otherKey) }` | `syntheticDestination { completeFrom(otherKey) }` | +| `EnroTestRule` (JUnit) | Still available on JVM; the KMP form is `runEnroTest { ... }`. | +| `expectInstruction { ... }` test helpers | `TestNavigationHandle` with `assertOpened`, `assertClosed`, `assertCompleted`, `assertOperationExecuted`. The old helpers remain in `enro-test`'s compat layer. | + +## Detailed changes + +### Navigation keys + +Enro 2 keys carried their presentation intent on the type: + +```kotlin +@Parcelize data class ShowProfile(val userId: String) : NavigationKey.SupportsPush +@Parcelize data class SelectDate(val min: LocalDate?) : NavigationKey.SupportsPresent.WithResult +``` + +Enro 3 keys are flat — the type only describes inputs (and optionally a +return value): + +```kotlin +@Serializable data class ShowProfile(val userId: String) : NavigationKey +@Serializable data class SelectDate(val min: LocalDate?) : NavigationKey.WithResult +``` + +If you previously distinguished "push" and "present" at the call site +(`navigation.push(...)` vs `navigation.present(...)`), use destination +*metadata* instead. A destination is rendered as a dialog or overlay when its +metadata says so: + +```kotlin +@NavigationDestination(SelectDate::class) +val selectDateDestination = navigationDestination( + metadata = { dialog() } +) { + // ... +} +``` + +See [Navigation Destinations](core-concepts/navigation-destinations.md) for the +full metadata story. + +### NavigationHandle operations + +| Enro 2 | Enro 3 | +|---|---| +| `navigation.push(key)` | `navigation.open(key)` | +| `navigation.present(key)` | `navigation.open(key)` (with `dialog()` / `directOverlay()` metadata on the destination) | +| `navigation.close()` | `navigation.close()` (same) | +| `navigation.requestClose()` | `navigation.requestClose()` (same) | +| `navigation.closeWithResult(value)` | `navigation.complete(value)` | +| `navigation.execute(NavigationInstruction.Open(...))` | `navigation.execute(NavigationOperation.Open(...))` | + +The extension functions live in package `dev.enro` and are imported as +`dev.enro.open`, `dev.enro.close`, `dev.enro.complete`, `dev.enro.requestClose`. + +### Installing the controller + +Enro 2: + +```kotlin +@NavigationComponent +class MyApp : Application(), NavigationApplication { + override val navigationController = installNavigationController { + // configuration + } +} +``` + +Enro 3: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + // optional configuration: plugins, interceptors, decorators, etc. + } +) + +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + MyComponent.installNavigationController(this) + } +} +``` + +The `NavigationApplication` interface is gone, and the controller no longer +needs to be exposed as a property. The same component object is used to +install on other platforms — `installNavigationController(document)` for Web, +`installNavigationController(Unit)` for Desktop, etc. + +### Module DSL + +The module DSL used by Enro 2 (`override fun NavigationModuleScope.configure()`) +is replaced by `createNavigationModule { }`, passed into the +`NavigationComponentConfiguration` constructor. Common entries: + +```kotlin +createNavigationModule { + plugin(MyPlugin()) + interceptor { /* NavigationInterceptorBuilder */ } + decorator { navigationDestinationDecorator { /* wrap every destination */ } } + destination(myProviderVal) // for navigationDestination val { } providers + path(MyDeepLinkPathBinding) + serializersModule(myKotlinxSerializersModule) + module(otherFeatureModule) // compose modules from other libraries +} +``` + +`composeEnvironment { content -> ... }` is deprecated; use `decorator { }` +instead. + +### Rendering a backstack + +Enro 2 created containers like: + +```kotlin +val container = rememberNavigationContainer( + root = HomeKey, + emptyBehavior = EmptyBehavior.CloseParent, +) +``` + +Enro 3: + +```kotlin +val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), +) +NavigationDisplay(state = container) +``` + +`asInstance()` wraps a key into a `NavigationKey.Instance` (every appearance +of a key in a backstack has its own id and metadata). + +`NavigationDisplay` is the new top-level Composable for rendering a container. +It replaces the v2 `Container { }` Composable. + +### Results + +Enro 2: + +```kotlin +class HomeScreen : Fragment() { + val getDate by registerForNavigationResult { date -> /* use it */ } + + fun onPickDate() = getDate.present(SelectDate()) +} + +@NavigationDestination(SelectDate::class) +class SelectDateScreen : Fragment() { + val navigation by navigationHandle() + fun onPicked(date: LocalDate) = navigation.closeWithResult(date) +} +``` + +Enro 3: + +```kotlin +@Composable +@NavigationDestination(Home::class) +fun HomeScreen() { + val getDate = registerForNavigationResult( + onCompleted = { date -> /* use it */ }, + onClosed = { /* dismissed without a result */ }, + ) + + Button(onClick = { getDate.open(SelectDate()) }) { Text("Pick a date") } +} + +@Composable +@NavigationDestination(SelectDate::class) +fun SelectDateScreen() { + val navigation = navigationHandle() + // ... + Button(onClick = { navigation.complete(LocalDate.now()) }) { Text("Use today") } +} +``` + +The Compose form of `registerForNavigationResult` returns a +`NavigationResultChannel` immediately (no `by` delegate). Inside a +`ViewModel`, the delegate form is still available. + +### ViewModels + +Enro 2 used a custom `enroViewModels` delegate: + +```kotlin +class ProfileViewModel : ViewModel() { + val navigation by navigationHandle() +} + +@NavigationDestination(ShowProfile::class) +class ProfileFragment : Fragment() { + val viewModel by enroViewModels() +} +``` + +Enro 3 uses the standard `viewModel { }` builder with a small helper that +wires the `NavigationHandle` into the factory: + +```kotlin +class ProfileViewModel : ViewModel() { + val navigation by navigationHandle() +} + +@Composable +@NavigationDestination(ShowProfile::class) +fun ProfileScreen() { + val viewModel = viewModel { createEnroViewModel { ProfileViewModel() } } + // ... +} +``` + +The `by navigationHandle()` delegate inside the ViewModel itself is +unchanged. + +### Testing + +Enro 2's JVM-only `EnroTestRule` still works for JUnit-based tests: + +```kotlin +class ProfileTest { + @get:Rule(order = 0) val enroRule = EnroTestRule() + @get:Rule(order = 1) val composeRule = createComposeRule() + + @Test fun example() { /* ... */ } +} +``` + +For KMP tests (and as a more flexible alternative in any test), use the +`runEnroTest { }` block: + +```kotlin +@Test fun example() = runEnroTest { + val handle = createTestNavigationHandle(ShowProfile("user-123")) + handle.open(SelectDate()) + handle.assertOpened() +} +``` + +`TestNavigationHandle` records every `NavigationOperation` and exposes +matching assertions (`assertOpened`, `assertClosed`, `assertCompleted`, +`assertOperationExecuted`). + +For ViewModel tests, `putNavigationHandleForViewModel(key)` +remains. + +See [Testing](advanced/testing.md). + +## A small worked migration + +Take this small Enro 2 screen: + +```kotlin +@Parcelize +data class GreetUser(val name: String) : NavigationKey.SupportsPresent.WithResult + +@NavigationDestination(GreetUser::class) +class GreetUserFragment : Fragment() { + val navigation by navigationHandle() + + override fun onCreateView(...): View { + return ComposeView(requireContext()).apply { + setContent { + Column { + Text("Hello, ${navigation.key.name}!") + Button(onClick = { navigation.closeWithResult("greeted") }) { + Text("Done") + } + } + } + } + } +} +``` + +In Enro 3 this becomes a Composable destination, with the dialog presentation +moved to metadata: + +```kotlin +@Serializable +data class GreetUser(val name: String) : NavigationKey.WithResult + +@NavigationDestination(GreetUser::class) +val greetUserDestination = navigationDestination( + metadata = { dialog() } // <-- presentation moves here +) { + Column { + Text("Hello, ${navigation.key.name}!") + Button(onClick = { navigation.complete("greeted") }) { + Text("Done") + } + } +} +``` + +Callers change from `push`/`present` to `open`, and from `closeWithResult` to +`complete`, but the conceptual shape — a screen with inputs and a typed result +— is identical. + +## What didn't change + +- `@NavigationDestination(KeyClass::class)` is still the way to bind a screen + to its key. +- Inside a `ViewModel`, you still get the `NavigationHandle` via + `by navigationHandle()`. +- Result channels are still typed; `onCompleted` still fires once with the + produced value. +- Backstacks are still serializable and survive process death. +- Multi-module apps still work the same way: every module that declares + `@NavigationDestination` annotations needs the `enro-processor` KSP + dependency, and the app module depends transitively on all of them. + +## Need help? + +- Browse the [recipes module][recipes] for working examples of every feature + in Enro 3. +- Check the [troubleshooting page](troubleshooting.md) for common errors. +- File an issue at [github.com/isaac-udy/Enro/issues](https://github.com/isaac-udy/Enro/issues) + if you hit something not covered here. + +[recipes]: https://github.com/isaac-udy/Enro/tree/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes diff --git a/docs/ghpages/docs/platform/android.md b/docs/ghpages/docs/platform/android.md new file mode 100644 index 000000000..963e18fa3 --- /dev/null +++ b/docs/ghpages/docs/platform/android.md @@ -0,0 +1,159 @@ +--- +title: Android +parent: Platform-Specific Guides +nav_order: 1 +--- + +# Android + +Enro on Android is the same Compose-first runtime you'd use on iOS, Desktop +or Web, plus a compatibility module — `enro-compat` — that lets you keep +existing Fragments and Activities working while you adopt the new API. + +## Installation + +Install in your `Application.onCreate`: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { /* optional config */ } +) + +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + MyComponent.installNavigationController(this) + } +} +``` + +The `installNavigationController(this)` call is what attaches the +controller to your application instance — no `NavigationApplication` +interface to implement, no property to expose. + +## Hosting the backstack + +The typical pattern is one root container at the top of your Compose tree: + +```kotlin +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + NavigationDisplay(state = container) + } + } +} +``` + +That's it. From here, every destination annotated with +`@NavigationDestination(...)` is reachable through the navigation handle on +any composable in the tree. + +## Migrating an existing Android app — `enro-compat` + +If you have an existing app with Fragments or Activities, the +`enro-compat` module lets you adopt Enro 3 incrementally. Existing +Fragment- and Activity-backed destinations keep working alongside new +Composable destinations; you can migrate screens one at a time. + +```kotlin +dependencies { + implementation("dev.enro:enro:3.0.0-beta01") + implementation("dev.enro:enro-compat:3.0.0-beta01") + ksp("dev.enro:enro-processor:3.0.0-beta01") +} +``` + +When `enro-compat` is on the classpath, Enro automatically registers a +`compatModule` that provides: + +- Serialization support for legacy `NavigationDirection.Push` / + `NavigationDirection.Present` directions, so backstacks built from old + call sites still serialize correctly. +- `LegacyNavigationDirectionPlugin`, which translates legacy direction + metadata at runtime into the new metadata model. +- Extension helpers for Fragment and Activity (`registerForNavigationResult`, + `getNavigationHandle`, `addOpenInstruction`, etc.) so existing Fragment- + and Activity-style code compiles unchanged. + +You don't need to install the compat module explicitly — its presence on +the classpath is detected during controller initialisation. + +### Annotating a Fragment + +```kotlin +@NavigationDestination(ShowProfile::class) +class ProfileFragment : Fragment(R.layout.fragment_profile) { + private val navigation by navigationHandle() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.title).text = + "Profile for ${navigation.key.userId}" + } +} +``` + +The handle delegate is the same one Composables and ViewModels use; the +key, operations (`open`, `close`, `complete`), and result channels all +behave the same way. + +### Annotating an Activity + +```kotlin +@NavigationDestination(EditProfile::class) +class EditProfileActivity : AppCompatActivity() { + private val navigation by navigationHandle() + // ... +} +``` + +Activities and Fragments live in the same backstack as Composable +destinations, so a Composable can `navigation.open(EditProfile(...))` and +have an Activity launched — and vice versa. + +### Bridging individual destinations + +A common migration pattern is to convert one screen at a time, starting +with leaves of the navigation graph: + +1. Identify a screen and copy its `NavigationKey` to the new + `@Serializable` form (drop `@Parcelize`, drop the `SupportsPush` / + `SupportsPresent` marker — see the [migration guide](../migrating-from-v2.md)). +2. Re-implement that screen as a `@Composable` destination annotated with + `@NavigationDestination(KeyClass::class)`. +3. Existing callers continue to use the same key; the call sites don't + need to change. +4. Repeat for the next screen. + +You can ship a half-migrated app. Just leave the not-yet-converted +Fragments and Activities annotated as they are; `enro-compat` keeps them +addressable. + +## Notes + +- **Activity context** is available wherever Compose's + `LocalContext.current` works — Enro doesn't replace or wrap it. +- **Predictive back** is on by default for Compose destinations rendered + by `NavigationDisplay`. See + [Animations → Predictive back](../advanced/animations.md#predictive-back). +- **Process death** is handled automatically. Your container's backstack + is saved to `SavedStateHandle` and restored when the process is + recreated. Test it with "Don't keep activities" in Developer Options. +- **Multi-Activity layouts** are supported via `enro-compat` — each + Activity hosts its own root container. + +## See also + +- [Installation](../getting-started/installation.md) — covers the multi-platform install side. +- [Migrating from Enro 2](../migrating-from-v2.md) — what changes when you bring a v2 app forward. +- [Recipes][recipes] — every recipe runs on Android out of the box; the + [interop recipe][interop-recipe] demonstrates a native `AndroidView` + inside an Enro destination. + +[recipes]: https://github.com/isaac-udy/Enro/tree/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes +[interop-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/interop/NativeInterop.kt diff --git a/docs/ghpages/docs/platform/desktop.md b/docs/ghpages/docs/platform/desktop.md new file mode 100644 index 000000000..0cde28451 --- /dev/null +++ b/docs/ghpages/docs/platform/desktop.md @@ -0,0 +1,86 @@ +--- +title: Desktop +parent: Platform-Specific Guides +nav_order: 3 +--- + +# Desktop + +Enro runs on Compose Multiplatform for Desktop. Installation is a single +call from `main`, after which you open one or more windows through the +controller. + +## Minimal install + +```kotlin +fun main() { + val controller = MyComponent.installNavigationController(Unit) + controller.openWindow( + GenericRootWindow( + windowConfiguration = { + RootWindow.WindowConfiguration( + title = "My App", + onCloseRequest = { navigation.close() }, + ) + }, + ) { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + NavigationDisplay(state = container) + }, + ) + + application { + EnroApplicationContent(controller) + } +} +``` + +What's going on here: + +- `installNavigationController(Unit)` installs the controller for the + current process and hands you an `EnroController`. +- `controller.openWindow(...)` registers a window with the controller. + The `windowConfiguration` lambda describes how the window appears + (title, size, close behaviour, key handling); the trailing content + lambda is the window's Compose content. +- `application { EnroApplicationContent(controller) }` is Compose + Multiplatform's standard entry point — it renders every window the + controller knows about. + +The window's `WindowConfiguration` block runs inside a scope that has the +window's own `NavigationHandle` available as `navigation`, so +`onCloseRequest = { navigation.close() }` closes the window through the +navigation system rather than killing the process. + +## Multi-window apps + +Desktop apps often want more than one window — a main window plus +detached editors, palettes, debug panes, etc. Each window in Enro is its +own top-level navigation destination, opened through `controller.openWindow(...)` +or pushed from a destination via the regular `navigation.open(...)` API. + +The runnable example for the recipes app — including a `MenuBar`, key +shortcuts, and the full window-configuration block — is the [desktop main +file][desktop-main]. The patterns there generalise to any multi-window +desktop app. + +## What Enro provides on desktop + +- Per-window navigation containers — each window can host its own + backstack and predictive-back behaviour. +- Standard saved-state survival across in-process Compose recompositions. + (Process restart is not handled out of the box; persist state yourself + if you need it.) +- The full common API: `NavigationKey`, `NavigationKey.WithResult`, + `navigationHandle()`, `registerForNavigationResult`, + `NavigationDisplay`, scene strategies, plugins, decorators. + +## See also + +- [Installation](../getting-started/installation.md) for the multi-platform setup. +- [Recipes desktop main][desktop-main] — full working example with + multi-window, `MenuBar`, key shortcuts. + +[desktop-main]: https://github.com/isaac-udy/Enro/blob/main/recipes/app/desktop/src/desktopMain/kotlin/main.kt diff --git a/docs/ghpages/docs/platform/index.md b/docs/ghpages/docs/platform/index.md new file mode 100644 index 000000000..721d1afa3 --- /dev/null +++ b/docs/ghpages/docs/platform/index.md @@ -0,0 +1,24 @@ +--- +title: Platform-Specific Guides +nav_order: 5 +has_children: true +--- + +# Platform-Specific Guides + +How to install and use Enro on each supported platform. The core API is +the same everywhere; these pages cover the install bootstrap and the +platform-specific notes worth knowing. + +- [Android](android.md) — `Application.onCreate` install, hosting a + container from a `ComponentActivity`, and the `enro-compat` story for + migrating Fragment/Activity destinations incrementally. +- [iOS](ios.md) — `installNavigationController(application:)` from + Swift's `UIApplicationDelegate`, exposing a `UIViewController` via + `EnroUIViewController { }`, and embedding it in your app. +- [Desktop](desktop.md) — `controller.openWindow(...)` with + `GenericRootWindow`, `EnroApplicationContent`, and the multi-window + pattern. +- [Web](web.md) — `installNavigationController(document)`, the + `EnroBrowserContent` host, and `InstallWebHistoryPlugin` for browser + back/forward. diff --git a/docs/ghpages/docs/platform/ios.md b/docs/ghpages/docs/platform/ios.md new file mode 100644 index 000000000..c76082f58 --- /dev/null +++ b/docs/ghpages/docs/platform/ios.md @@ -0,0 +1,110 @@ +--- +title: iOS +parent: Platform-Specific Guides +nav_order: 2 +--- + +# iOS + +Enro runs on iOS through Compose Multiplatform. Installation has two +pieces: a Swift call into the generated `installNavigationController` +extension at app launch, and a Kotlin entry point that hands the Swift app +a `UIViewController` to display. + +## Installation + +### From Swift + +In your `UIApplicationDelegate`, call `installNavigationController` on +your `NavigationComponent` once at launch. KSP generates a `.shared` +accessor on the component object so it's reachable from Swift: + +```swift +import UIKit +import shared + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + MyComponent.shared.installNavigationController( + application: application, + ) + return true + } +} +``` + +The `shared` accessor (and the `installNavigationController` extension on +it) is generated by Enro's KSP processor whenever the iOS target is +present. + +### From Kotlin + +Expose a `UIViewController` for your Swift app to embed. The canonical +pattern uses `EnroUIViewController { }`, which wires up a Compose host with +the navigation context Enro needs: + +```kotlin +@Suppress("unused") // called from Swift +fun MainViewController(): UIViewController = EnroUIViewController { + val container = rememberNavigationContainer( + backstack = backstackOf(Home.asInstance()), + ) + NavigationDisplay(state = container) +} +``` + +In Swift, instantiate it and use it as your root view controller — +typically embedded in a `UIHostingController` for a SwiftUI app, or set +directly as the window's root for a UIKit app: + +```swift +import UIKit +import shared + +class ViewController: UIViewController { + override func loadView() { + let kotlinVc = MainViewControllerKt.MainViewController() + addChild(kotlinVc) + view = kotlinVc.view + kotlinVc.didMove(toParent: self) + } +} +``` + +(SwiftUI users can wrap the Kotlin view controller in +`UIViewControllerRepresentable` — same idea.) + +## What Enro provides on iOS + +- A real backstack tied to the destination tree. The system back gesture + on iOS calls `requestClose` on the active destination's + `NavigationHandle`. +- Saved state for the backstack across app suspension and process death, + via the platform's standard restoration hooks. +- The full common API: `NavigationKey`, `NavigationKey.WithResult`, + `navigationHandle()`, `registerForNavigationResult`, + `NavigationDisplay`, scene strategies, plugins, decorators. + +## Notes + +- Enro doesn't replace `UINavigationController` — it owns its own + backstack inside the `EnroUIViewController` it gives you. If you want a + native navigation bar, build it in Compose (the recipes app uses + Material 3's `TopAppBar` everywhere) or wrap the + `EnroUIViewController` inside a `UINavigationController` whose bar you + drive in Swift. +- The destination types you can use on iOS are the same Composable + destinations you use everywhere else. There is no Fragment/Activity + story to migrate — iOS has always been Compose Multiplatform-only. + +## See also + +- [Installation](../getting-started/installation.md) for the multi-platform setup. +- [Recipes iOS entrypoint][ios-recipe] — the working + `MainViewController()` Enro uses for its own runnable iOS recipes. + +[ios-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/app/ios/src/iosMain/kotlin/dev/enro/recipes/MainViewController.kt diff --git a/docs/ghpages/docs/platform/web.md b/docs/ghpages/docs/platform/web.md new file mode 100644 index 000000000..da2f5ac0f --- /dev/null +++ b/docs/ghpages/docs/platform/web.md @@ -0,0 +1,158 @@ +--- +title: Web Platform Guide +parent: Platform-Specific Guides +nav_order: 4 +--- + +# Web + +Enro runs in the browser through Compose for Web's WasmJS target. The +install pattern mirrors the other platforms: call +`installNavigationController` once at start-up, then host a container +inside an `EnroBrowserContent`. + +## Minimal install + +```kotlin +fun main() { + MyComponent.installNavigationController(document) + + ComposeViewport { + EnroBrowserContent { + val container = rememberNavigationContainer( + backstack = rememberInitialBackstackFromUrl { + backstackOf(Home.asInstance()) + }, + ) + InstallWebHistoryPlugin(container) + NavigationDisplay(state = container) + } + } +} +``` + +The pieces: + +- `installNavigationController(document)` ties the controller to the + browser's `document`. This is currently the only supported binding — + Enro runs in the browser via Compose for Web. +- `ComposeViewport` is the standard Compose Multiplatform entry point for + the browser; it mounts your Compose tree at the page's root. +- `EnroBrowserContent { }` provides the Compose locals Enro needs for + browser-specific behaviour. Treat it like Compose Multiplatform's + outermost theme wrapper. +- `rememberInitialBackstackFromUrl { ... }` reads `window.location` once + on first composition and tries to resolve it to a backstack via the + controller's registered path bindings. If the URL matches a + `@NavigationPath`, the app boots straight into that destination; if it + doesn't, the lambda's default is used. +- `InstallWebHistoryPlugin(container)` wires your container's backstack + into the browser history API. The URL bar reflects the current + root-container destination, and the browser's back/forward buttons + navigate that root backstack. + +## URL routing model + +Enro's web URL routing is **root-container-only** in beta: + +- The URL bar always reflects the active destination of the **root + navigation container** — the one you create with + `rememberNavigationContainer` directly inside `EnroBrowserContent`. +- Browser back/forward navigates that root container's backstack. +- Inner-container navigation (modals, tabs, list/detail panes, anything + hosted inside another destination) is **session-local** — it doesn't + change the URL and doesn't create history entries. + +This is the model most modern web apps use — going to a different page +on Twitter writes a URL, switching tabs within a profile doesn't. +Browser back goes between pages, not between page-internal tabs. + +### What gets a URL + +A `NavigationKey` annotated with `@NavigationPath` participates in URL +routing **when it is the active destination of the root container**: + +```kotlin +@Serializable +@NavigationPath("/products/{productId}?source={source?}") +data class ProductDetail( + val productId: String, + val source: String? = null, +) : NavigationKey +``` + +If `ProductDetail` is at the top of the root container, the URL bar +will show `/products/abc?source=email`. If it's the top of a *nested* +container hosted inside some other destination, the URL bar continues +to show the outer (root) destination's path. + +When a destination has no `@NavigationPath`, or the active destination +lives inside a nested container, the URL bar **doesn't change** — it +keeps whatever path was last set by an annotated destination (or the +URL the user originally landed on, if no annotated destination has been +active yet). `pushState` still fires, so browser back/forward continues +to work through `history.state`; the URL just doesn't pretend to +identify state that isn't bookmarkable. + +### Cold loading from a URL + +`rememberInitialBackstackFromUrl { default() }` reads the address bar +on first composition and resolves it through the controller's path +bindings. The resolved key becomes a single-entry backstack on the +root container. If you bookmark `/products/abc-123` and reopen it, +the app boots directly into the `ProductDetail("abc-123")` screen — +provided that destination is something you're willing to host at the +root. + +If you also want pretty URLs for state that lives inside a nested +container (e.g. a list/detail pane), the synthetic-backstack approach +from the *Advanced Deep Link* recipe is the recommended pattern: read +the URL yourself, derive the parent context, and seed the backstack +manually. + +## What the URL bar shows + +The plugin uses two slots in `window.history`: + +- **URL** (`location.pathname + location.search`) — derived from the + root container's active destination's `@NavigationPath`. This is the + part users see and share. +- **`history.state`** — the root container's backstack as JSON, used + for accurate back/forward restoration mid-session. + +Inner-container state is **not** serialised into either slot. If you +need it to survive page reloads, handle it via your own +`saveable`/`rememberSaveable` storage as you would on other platforms. + +## What Enro provides on Web + +- A real backstack for the root container that mirrors browser history. +- The full common API: `NavigationKey`, `NavigationKey.WithResult`, + `navigationHandle()`, `registerForNavigationResult`, + `NavigationDisplay`, scene strategies, plugins, decorators. +- Deep linking from a URL on cold load via + `rememberInitialBackstackFromUrl` + `@NavigationPath` bindings on + root destinations. +- Saved state across in-page navigation. Full-page reloads start fresh + except for what the URL itself encodes — if you need persistence + across reload, write to `localStorage` / `sessionStorage` yourself. + +## Known limitations + +- **Nested URL routing**: there's no built-in way today to encode the + state of inner containers in the URL. A URL like `/recipe/page-2` + that maps to `[Recipe, Page2-in-inner-container]` is something we'll + add in a future release. For now, leaf URLs inside nested containers + are session-local. +- **Manual address-bar edits**: if the user edits the URL by hand + without a full-page reload, the plugin no-ops on the resulting + `popstate`. Reloading the page applies the new URL via the cold-load + path. + +## See also + +- [Installation](../getting-started/installation.md) for the multi-platform setup. +- [Recipes web main][web-main] — full working bootstrap for the recipes app. + +[cmp-web]: https://github.com/JetBrains/compose-multiplatform/blob/master/web/README.md +[web-main]: https://github.com/isaac-udy/Enro/blob/main/recipes/app/web/src/wasmJsMain/kotlin/main.kt diff --git a/docs/ghpages/docs/troubleshooting.md b/docs/ghpages/docs/troubleshooting.md new file mode 100644 index 000000000..bd4a9a705 --- /dev/null +++ b/docs/ghpages/docs/troubleshooting.md @@ -0,0 +1,279 @@ +--- +title: Troubleshooting +nav_order: 80 +--- + +# Troubleshooting + +Most issues with Enro at runtime fall into one of a few categories — the +controller isn't installed, a destination isn't bound to its key, a handle +is accessed from the wrong scope, or a value can't be serialized. This +page walks through the common symptoms. + +If you don't find your issue here, please open an issue at +[github.com/isaac-udy/Enro/issues](https://github.com/isaac-udy/Enro/issues) +with a stack trace and a brief description of the call site. + +## Setup errors + +### `EnroController has not been installed` + +The controller wasn't installed before something tried to use it. Make +sure you call `installNavigationController(...)` on your `NavigationComponent` +before any composable that reads a navigation handle is composed. + +```kotlin +// Android +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + MyComponent.installNavigationController(this) // <- this must run first + } +} +``` + +See [Installation](getting-started/installation.md) for the equivalent on +each platform. + +### `No NavigationHandle found for [KeyClass]` + +You called `navigationHandle()` from a composable that isn't a +navigation destination, or whose key is a different type. + +A handle is only available inside a destination — that is, a composable +function annotated with `@NavigationDestination(...)` (or the body of a +`navigationDestination { }` provider), or any composable composed +**inside** one. The typed parameter must match the destination's actual +key type. + +```kotlin +@Composable +@NavigationDestination(ShowProfile::class) +fun ProfileScreen() { + val navigation = navigationHandle() // ✅ matches the annotation + // ... +} + +@Composable +fun SomeRandomComposable() { + val navigation = navigationHandle() // ❌ not inside a destination +} +``` + +If your composable is genuinely shared between destinations and doesn't +need to know its key type, use the untyped form +`navigationHandle()` and accept that `navigation.key` will +be the base interface. + +### `No LocalNavigationHandle` + +Same family as the previous error — composable reading the navigation +handle from outside any destination. Wrap it inside a destination, or +hoist the navigation calls up to a destination-level composable and pass +the operations down as lambdas. + +### Missing navigation binding for `[KeyClass]` + +Your key is being opened but no destination is registered for it. Three +common causes: + +1. **Forgot `@NavigationDestination(KeyClass::class)` on the destination.** + The annotation is what drives KSP code generation. +2. **The module that declares the destination doesn't apply the + `enro-processor` KSP plugin.** Every module that contains + `@NavigationDestination` annotations needs its own KSP dependency on + `dev.enro:enro-processor`. +3. **The app module doesn't depend on the destination module.** KSP-generated + bindings only travel through direct dependencies — make sure your app + module's classpath includes every feature module that contains + destinations. + +If all three look correct, try a clean build (`./gradlew clean +:app:assembleDebug`) — KSP incrementality can occasionally cache an old +result. See the [modular navigation +recipe][modular-recipe] for the canonical multi-module setup. + +## Navigation-handle errors + +### `${key} is a NavigationKey.WithResult and cannot be completed without a result` + +You called `navigation.complete()` (no arguments) on a destination whose +key implements `NavigationKey.WithResult`. A result-producing key +must produce a result. + +```kotlin +// ❌ won't compile if the key implements WithResult +navigation.complete() + +// ✅ pass the result +navigation.complete(LocalDate.now()) +``` + +If the destination genuinely has no result to deliver and you want it +gone, use `navigation.close()` — the caller's `onClosed` callback will +fire. + +### `Cannot completeFrom a NavigationKey.WithResult from a NavigationKey that does not also implement NavigationKey.WithResult` + +`completeFrom(otherKey)` says "delegate this destination's completion to +`otherKey`". For that to make sense, both keys must agree on the result +type: a `WithResult` destination can only `completeFrom` another +`WithResult` with the matching `R`. + +If the redirect target genuinely doesn't have a result, you probably +want `closeAndReplaceWith(otherKey)` instead. + +### Multiple `onCloseRequested` callbacks registered for the same NavigationHandle + +You registered the close-requested callback in more than one place for +the same destination — typically one in the ViewModel and one in the +Composable, or two `configure { }` blocks in the same composable. Only +one callback may be active at a time. + +Pick one home for the callback (the ViewModel if you have one, otherwise +the top-level Composable). See +[Navigation Handles → Overriding the close-requested callback](core-concepts/navigation-handles.md#overriding-the-close-requested-callback). + +### `Cannot execute NavigationOperation on TestNavigationHandle that is closed` + +The handle you're using in a test was closed (or completed) and then you +tried to drive more navigation through it. Either the test is exercising +a flow that goes past the close, or the assertion is at the wrong point. + +If you intentionally want to continue using the handle after a close, +call `handle.clearOperationHistory()` between scenarios. + +### `SyntheticDestination for [Key] has already finished with [Outcome]` + +An outcome method (`open`, `close`, `closeSilently`, `complete`, +`completeFrom`) was called on a synthetic's scope after the synthetic +had already concluded. The dispatcher catches the first outcome the +block emits and converts it to a navigation operation; subsequent calls +have nothing to do. + +The usual cause is launching a coroutine inside a synthetic block: + +```kotlin +@NavigationDestination(BadSynthetic::class) +val badSynthetic = syntheticDestination { + context.lifecycleOwner.lifecycleScope.launch { + someAsyncWork() + complete() // ← block has long since returned; throws "already finished" + } +} +``` + +Synthetics are intentionally synchronous decision points — they don't +have their own lifecycle, view-model store, or persisted state. The fix +is one of: + +- **Do the async work before opening the synthetic** and pass the + result into the synthetic's key as a parameter. +- **Forward to a real destination** that owns the work as part of its + own lifecycle: `syntheticDestination<...> { completeFrom(LoadingScreen) }`. +- **Restructure as a regular destination with a loading state** if the + synthetic was really trying to be a "do some work then close" UI. + +See [Synthetic Destinations](advanced/synthetic-destinations.md) for the +full design rationale. + +## Result errors + +### `Received result for id ${id}, but no active steps had that id` + +Specific to managed flows. The flow received a step result, but the +flow scope no longer contains a step matching that id — usually because +the flow's body changed shape between the time the step was opened and +the time the result came back (an upstream value caused a branch to no +longer include this step, for example). + +Make sure each `open(key)` inside the flow is reached deterministically +from the upstream results. Don't write conditions that produce a +**different ordering** of `open` calls on re-evaluation; conditions are +fine, but the same upstream results should always lead to the same flow +shape. + +## Serialization errors + +### `Object of type X could not be added to NavigationKey.Metadata` + +You're storing a value in `instance.metadata` whose serializer isn't +registered. Built-in Kotlin types are fine; custom types either need to +be `@Serializable` or have a serializer registered on the +`NavigationComponent`: + +```kotlin +@NavigationComponent +object MyComponent : NavigationComponentConfiguration( + module = createNavigationModule { + serializersModule(SerializersModule { + contextual(MyCustomType.serializer()) + }) + } +) +``` + +### Backstack restoration fails after process death + +Usually means one of your `NavigationKey`s has a non-serializable +property. Mark the key as `@Serializable` and make sure every property +on it is also serializable (either built-in, `@Serializable` itself, or +registered in your `SerializersModule`). Run a simple "kill from +Recents" test in development to flush these out early. + +## Testing errors + +### `No NavigationHandle found ...` in a test + +You're constructing a destination or ViewModel without setting up a +test controller. Wrap the test in `runEnroTest { }`, or add an +`EnroTestRule` to the test class. + +```kotlin +@Test +fun example() = runEnroTest { + val handle = createTestNavigationHandle(MyKey) + // ... +} +``` + +For ViewModels with `by navigationHandle()`, also call +`putNavigationHandleForViewModel(key)` so the ViewModel can +resolve its handle. + +See [Testing](advanced/testing.md). + +### `Multiple onCloseRequested callbacks ...` in a test + +Same root cause as in app code, but easier to hit accidentally if a +fixture installs the callback and the test's `before` block does too. +Make sure each scenario only sets up the callback once. + +## Migration errors (Enro 2 → 3) + +If you're getting compile errors after upgrading from Enro 2, see the +[migration guide](migrating-from-v2.md). The most common ones: + +- **`Unresolved reference: SupportsPush` / `SupportsPresent`** — the v2 + markers are gone. Use bare `NavigationKey` or + `NavigationKey.WithResult`. +- **`Unresolved reference: closeWithResult`** — renamed to `complete(value)`. +- **`Unresolved reference: push` / `present`** — both replaced by `open`. + Dialog/overlay behaviour moves into destination metadata. +- **`Unresolved reference: NavigationApplication`** — gone. Install the + controller directly from `Application.onCreate`. +- **`@Parcelize` flagged but not migrated** — keys are now `@Serializable` + (kotlinx.serialization). +- **`enroViewModels` no longer resolves** — use `viewModel { createEnroViewModel { ... } }`. + +## Reporting an issue + +If your problem isn't covered here, the most helpful issue report +includes: + +- The full stack trace. +- A minimal code snippet showing the call site. +- The Enro version you're on (e.g. `3.0.0-beta01`). +- The platform (Android API level, iOS version, JDK version, browser). + +[modular-recipe]: https://github.com/isaac-udy/Enro/blob/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes/modular/ModularNavigation.kt diff --git a/docs/ghpages/index.md b/docs/ghpages/index.md new file mode 100644 index 000000000..d66bea040 --- /dev/null +++ b/docs/ghpages/index.md @@ -0,0 +1,93 @@ +--- +title: Overview +nav_order: 1 +--- + +# Enro + +Enro is a powerful navigation library for Kotlin Multiplatform — Android, iOS, +Desktop and Web — built around a simple idea: **screens within an application +should behave like functions**. + +A `NavigationKey` is the signature of a screen. It declares the screen's inputs, +and optionally a typed result. Calling code never needs to know how the screen +is implemented; it just invokes the contract. + +```kotlin +@Serializable +data class ShowProfile(val userId: String) : NavigationKey + +@Serializable +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.WithResult +``` + +If you read those as function signatures: + +```kotlin +fun showProfile(userId: String): Unit +fun selectDate(minDate: LocalDate? = null, maxDate: LocalDate? = null): LocalDate +``` + +Enro is Compose-first and Kotlin Multiplatform. On Android, a compatibility +layer (`enro-compat`) keeps existing Fragments and Activities working alongside +Compose destinations, so you can adopt Enro incrementally. + +## Getting Started + +- [Installation](docs/getting-started/installation.md) — add Enro to your project and install it on each platform. +- [Basic Concepts](docs/getting-started/basic-concepts.md) — the small vocabulary you need to read everything else. +- [Your First Screen](docs/getting-started/your-first-screen.md) — a complete worked example from key to navigation to result. + +## Core Concepts + +- [Navigation Keys](docs/core-concepts/navigation-keys.md) +- [Navigation Destinations](docs/core-concepts/navigation-destinations.md) +- [Navigation Containers](docs/core-concepts/navigation-containers.md) +- [Navigation Handles](docs/core-concepts/navigation-handles.md) + +## Advanced Topics + +- [Results](docs/advanced/results.md) + - [Embedded Result Flows](docs/advanced/results/embedded-result-flows.md) + - [Managed Result Flows](docs/advanced/results/managed-result-flows.md) +- [View Models](docs/advanced/view-models.md) +- [Animations](docs/advanced/animations.md) +- [Testing](docs/advanced/testing.md) +- [Plugins](docs/advanced/plugins.md) + +## Platform-Specific Guides + +- [Android](docs/platform/android.md) +- [iOS](docs/platform/ios.md) +- [Desktop](docs/platform/desktop.md) +- [Web](docs/platform/web.md) + +## Migrating from Enro 2 + +Enro 3 is a substantial rewrite. See the [migration guide](docs/migrating-from-v2.md) +for the API delta and a step-by-step conversion. + +## Recipes + +The [recipes module](https://github.com/isaac-udy/Enro/tree/main/recipes/common/src/commonMain/kotlin/dev/enro/recipes) +in the source repo is a set of small, runnable examples — one per concept. The +documentation below links into them for every working snippet. + +## Applications using Enro + +

+ + + +    + + + +

+ +--- + +*"The novices' eyes followed the wriggling path up from the well as it swept a great meandering arc around the hillside. Its stones were green with moss and beset with weeds. Where the path disappeared through the gate they noticed that it joined a second track of bare earth, where the grass appeared to have been trampled so often that it ceased to grow. The dusty track ran straight from the gate to the well, marred only by a fresh set of sandal-prints that went down, and then up, and ended at the feet of the young monk who had fetched their water." — [The Garden Path](http://thecodelesscode.com/case/156)* diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index bd31488dd..000000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,178 +0,0 @@ -# Troubleshooting -## Enro Exceptions -### `NoAttachedNavigationHandle` -There are two primary ways to get a NavigationHandle in Enro. The first one is through the `by navigationHandle()` property delegate, which is used to create and configure a NavigationHandle that is aware of it's type. The second is through `getNavigationHandle()`, which returns an untyped NavigationHandle. This exception is thrown by `getNavigationHandle()`, as well as internally in Enro when a NavigationHandle is needed to perform some action, but the type of the NavigationHandle is unimportant (for example, when using `by registerForNavigationResult`). - -This exception is thrown when Enro attempts to read a NavigationHandle from a NavigationContext (an Activity, Fragment or Composable) which does not have a NavigationHandle already attached. In normal operation, the NavigationController installed in your application's Application class will be watching Activity/Fragment/Composable lifecycle events, and attaching a NavigationHandle to these as required. If you are seeing this exception, that means that this hasn't happened. - -The most likely cause of this exception is that you have not configured your Application to use Enro correctly. It is important to make sure that your Application class is correctly implements `NavigationApplication` (see [here](https://github.com/isaac-udy/Enro#3-annotate-your-application-as-a-navigationcomponent-and-implement-the-navigationapplication-interface)). Make sure that you *instantiate the Application's NavigationController when the Application is instantiated*. If you lazily initialise the Application's NavigationController, you can create a situation where the NavigationController hasn't been attached to the Application's lifecycle in time to watch the lifecycle events of your screens and create the NavigationHandles. - -#### This Exception is occurring in tests -This exception can also occur in tests, if you have not correctly set your test up to use Enro. This can occur if you are either missing an `EnroTestRule` in your class, or you are creating an Activity/Fragment/Composable before the `EnroTestRule` has been configured. - -For example, it is common to use an `ActivityTestRule` or `ActivityScenarioRule` in tests, which will create an Activity for your tests to run against. Adding an `EnroTestRule` to a test that uses either of these test rules requires setting an `order` on the `@Rule` annotation, to make sure that **the `EnroTestRule` is initialised before the Activity/Fragment/Composable is launched**. - -Examples: -1. (Incorrect) In this example, neither of the `@Rule`s have an order, so it is likely that the `EnroTestRule` will be initialised **after** the Activity has been launched, causing a `NoAttachedNavigationHandle` exception. -```kotlin -class ExampleTest { - @get:Rule - val activity = ActivityTestRule(ExampleActivity::class.java) - - @get:Rule - val enroRule = EnroTestRule() -} -``` - -2. (Correct) In this example, both of the `@Rule`s have an order, ensuring that the EnroTestRule will be active when the ActivityTestRule launches the Activity. -```kotlin -class ExampleTest { - @get:Rule(order = 0) - val enroRule = EnroTestRule() - - @get:Rule(order = 1) - val activity = ActivityTestRule(ExampleActivity::class.java) -} -``` - -3. (Correct) In this example, the `EnroTestRule` does not have an order, but this does not matter, as an Activity is only launched during the `exampleTest` test method, meaning that the `EnroTestRule` will be initialised before the Activity is created. -```kotlin -class ExampleTest { - @get:Rule - val enroRule = EnroTestRule() - - @Test - fun exampleTest() { - val scenario = ActivityScenario.launch(ExampleActivity::class.java) - } -} -``` - -### `CouldNotCreateEnroViewModel` -This exception is thrown by the `EnroViewModelFactory`, which is used when using `by enroViewModels()` to create a ViewModel that has access to a NavigationHandle. `by enroViewModels` does not actually create the ViewModel itself, but rather it sets up some state and then delegates the ViewModel creation to another `ViewModelProvider.Factory`. You can pass your own `ViewModelProvider.Factory` to `by enroViewModels` if you need to use a custom ViewModel factory, but this will default to the `defaultViewModelProviderFactory` of a Fragment or Activity if you do not provide your own `ViewModelProvider.Factory`. - -If you are getting this exception, check that the `defaultViewModelProviderFactory` can actually create the ViewModel type you are requesting. If you use a custom `ViewModelProvider.Factory`, make sure that you are providing this to `by enroViewModels`, or calling `withNavigationHandle()` if you are using `by viewModels()`. - -#### This occurs in a project that uses Hilt and `@HiltViewModel` -This exception can occur in projects that use Hilt, and you are attempting to create an `@HiltViewModel` annotated ViewModel, but are not requesting the ViewModel from inside an `@AndroidEntryPoint` annotated Activity/Fragment. If a Fragment or Activity is not marked as `@AndroidEntryPoint`, Hilt will not set the `defaultViewModelProviderFactory` to the Hilt ViewModel factory, and the `defaultViewModelProviderFactory` will be a `SavedStateViewModelFactory`, which won't be able to construct your `@HiltViewModel` factory. - -#### Examples -```kotlin -@Parcelize class ExampleKey : NavigationKey - -class ExampleViewModel(val customArgument: Int) : ViewModel() { - val navigation by navigationHandle() -} -class ExampleViewModelFactory : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return ExampleViewModel(customArgument = 1337) as T - } -} - -@NavigationDestination(ExampleKey::class) -class ExampleFragment : Fragment { - - // This statement will fail, because the Fragment's defaultViewModelProviderFactory will be a - // SavedStateViewModelFactory(), which doesn't know how to construct an ExampleViewModel, because - // ExampleViewModel requires an Int parameter called "customArgument". - val failingViewModel by enroViewModels() - - // This statement will succeed, because we are passing through the special "ExampleViewModelFactory" - // factory which does know how to create an ExampleViewModel - val successfulViewModel by enroViewModels { ExampleViewModelFactory() } -} - -``` - -### `ViewModelCouldNotGetNavigationHandle` -This exception will occur if you attempt to create a ViewModel that uses `by navigationHandle()`, but the ViewModel does not have access to a NavigationHandle during it's initialisation. This can be solved in a few different ways: - -1. Activities and Fragments: -```kotlin -@NavigationDestination(MyNavigationKey::class) -class ActivityOrFragment : Activity /*or Fragment*/ { - /** - * viewModelFromDefault and viewModelFromCustom below show how to use `ViewModelProvider.Factory.withNavigationHandle()` - * to bind a NavigationHandle to a ViewModelProvider through a normal call to `by viewModels()` - */ - val viewModelFromDefault by viewModels( - factoryProducer = { - defaultViewModelProviderFactory.withNavigationHandle(getNavigationHandle()) - } - ) - - val viewModelFromCustom by viewModels( - factoryProducer = { - MyCustomViewModelFactory(/* ... */).withNavigationHandle(getNavigationHandle()) - } - ) - - /** - * enroViewModelFromDefault and enroViewModelFromCustom below show how to use `by enroViewModels()` to create a ViewModel that - * requests a NavigationHandle. `by enroViewModels()` essentially takes care of the `.withNavigationHandle()` call for you. - */ - val enroViewModelFromDefault by enroViewModels() - - val enroViewModelFromCustom by enroViewModels( - factoryProducer = { MyCustomViewModelFactory(/* ... */) } - ) -} -``` - -2. Composables -```kotlin -@Composable -@NavigationDestination(MyNavigationKey::class) -fun MyEnroComposable() { - - /** - * In a @Composable function that is also marked as @NavigationDestination - * any call to `= viewModel()` will by default use a ViewModelProvider.Factory that - * has a the `LocalNavigationHandle.current` bound - */ - val viewModelFromDefault = viewModel() - - /** - * If you need to provide a custom ViewModelProvider.Factory, you can use the `.withNavigationHandle()` - * function which as shown in the Activity/Fragment examples above. In a @Composable function, - * passing the `navigationHandle =` argument is optional, as this will use the `LocalNavigationHandle.current` - * unless you explicitly provide the argument. - */ - val viewModelFromCustomer = viewModel( - factory = MyCustomViewModelFactory(/* ... */).withNavigationHandle() - ) - -} -``` - -#### This Exception is occurring in tests -This exception will occur in tests if you are attempting to create a ViewModel to test, but have not used `putNavigationHandleForViewModel` from the `enro-test` library. - -### `MissingNavigator` -This exception can occur when you attempt to navigate to a `NavigationKey` that has not been bound to an Activity/Fragment/Composable, if you have forgotten to add the required `kapt` dependencies to make sure that Enro's code generation runs, or if code generation has not updated correctly when you have added a new destination. - -1. Make sure you have the correct `kapt` dependency on `enro-processor` -2. Make sure you've annotated your Activity/Fragment/Composable/SyntheticDestination with `@NavigationDestination` pointing to the correct NavigationKey -3. Clean the project (in case the incremental annotation processor has had an issue) -4. Make sure that your app module has a dependency on all modules that contain `@NavigationDestination` classes. The app module needs to be able to "see" all the `@NavigationDestination` classes that exist, and will not pick these up through transient dependencies. - -#### This Exception is occurring in tests - -### `IncorrectlyTypedNavigationHandle` - -### `InvalidViewForNavigationHandle` - -### `DestinationIsNotDialogDestination` - -### `EnroResultIsNotInstalled` - -### `ResultChannelIsNotInitialised` - -### `ReceivedIncorrectlyTypedResult` - -### `NavigationControllerIsNotAttached` - -### `UnreachableState` -This exception exists to mark when Enro does not expect this state could be reached, due to higher-level logic (for example, if an unchecked cast is always expected to succeed due to reflection based type checking). If you are seeing this Exception, this means that Enro's assumptions are incorrect. This could mean that you have reached a state that should actually throw a more descriptive Exception, or it means that you have reached a state that should be considered valid. In either case, this means that Enro needs to be changed to accomodate for this. - -Please open an issue [here](https://github.com/isaac-udy/Enro/issues), and make sure to include your stacktrace. diff --git a/enro-annotations/README.md b/enro-annotations/README.md new file mode 100644 index 000000000..b7c02915d --- /dev/null +++ b/enro-annotations/README.md @@ -0,0 +1,41 @@ +# `enro-annotations` + +The annotations consumed by [`enro-processor`](../enro-processor/) to +generate destination registrations and path bindings, plus the opt-in +marker annotations (`@AdvancedEnroApi`, `@ExperimentalEnroApi`) used +throughout the Enro public API. + +This module exists as a thin, dependency-free artefact so consumers can +reference Enro annotations without pulling in the full runtime — useful +for: + +- KMP modules with non-UI targets that share `NavigationKey` definitions + but never render UI (paired with [`enro-common`](../enro-common/)). +- Build-time tooling that needs to read Enro annotations without taking + a Compose / Android runtime dependency. + +## What's in here + +- `@NavigationDestination(keyType)` — marks a Composable / Fragment / + Activity as the destination for a given `NavigationKey` type. +- `@NavigationComponent` — marks the class whose generated companion + becomes your component's `installNavigationController(…)` entry point. +- `@NavigationPath(pattern)` — declares a URL pattern for a + `NavigationKey`, enabling deep-link / web-routing resolution. +- `@AdvancedEnroApi`, `@ExperimentalEnroApi` — opt-in markers for + surfaces that aren't part of the stable API contract. +- `@GeneratedNavigationBinding`, `@GeneratedNavigationComponent` — + emitted by the processor; not for hand-use. + +## Typical usage + +Pulled in transitively when you depend on `dev.enro:enro` or +`enro-runtime`. Depend on `enro-annotations` directly only when you +want the annotation types without the rest of the runtime (e.g. in a +`:common` KMP module shared with non-Compose targets). + +```kotlin +dependencies { + implementation("dev.enro:enro-annotations:3.0.0-beta01") +} +``` diff --git a/enro-annotations/build.gradle b/enro-annotations/build.gradle deleted file mode 100644 index 71a26e895..000000000 --- a/enro-annotations/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -apply plugin: 'java-library' -apply plugin: 'kotlin' -apply plugin: 'kotlin-kapt' -publishJavaModule("dev.enro", "enro-annotations") - -dependencies { - api deps.processing.jsr250 - implementation deps.kotlin.stdLib -} - -sourceCompatibility = "8" -targetCompatibility = "8" \ No newline at end of file diff --git a/enro-annotations/build.gradle.kts b/enro-annotations/build.gradle.kts new file mode 100644 index 000000000..e409bada8 --- /dev/null +++ b/enro-annotations/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("configure-library-with-js") + id("configure-publishing") +} \ No newline at end of file diff --git a/enro-annotations/src/androidMain/kotlin/dev/enro/annotations/NavigationDestination.android.kt b/enro-annotations/src/androidMain/kotlin/dev/enro/annotations/NavigationDestination.android.kt new file mode 100644 index 000000000..f710fe086 --- /dev/null +++ b/enro-annotations/src/androidMain/kotlin/dev/enro/annotations/NavigationDestination.android.kt @@ -0,0 +1,12 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(value = AnnotationRetention.BINARY) +@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) +public actual annotation class NavigationDestination(actual val key: KClass) { + + @Retention(value = AnnotationRetention.BINARY) + @Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) + public annotation class PlatformOverride(val key: KClass) +} \ No newline at end of file diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/AdvancedEnroApi.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/AdvancedEnroApi.kt new file mode 100644 index 000000000..2d7f73d9d --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/AdvancedEnroApi.kt @@ -0,0 +1,8 @@ +package dev.enro.annotations + +// Library code +@RequiresOptIn(message = "This is an advanced API, and should be used with care. The advanced APIs are designed to build advanced functionality on top of Enro, and may change without warning.") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR, AnnotationTarget.PROPERTY) +public annotation class AdvancedEnroApi + diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/ExperimentalEnroApi.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/ExperimentalEnroApi.kt new file mode 100644 index 000000000..e1dc5d34c --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/ExperimentalEnroApi.kt @@ -0,0 +1,7 @@ +package dev.enro.annotations + +// Library code +@RequiresOptIn(message = "This is an experimental API, and should be used with care. Experimental APIs may change without warning, or be removed entirely.") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +public annotation class ExperimentalEnroApi \ No newline at end of file diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/GeneratedNavigationBinding.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/GeneratedNavigationBinding.kt new file mode 100644 index 000000000..9781021db --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/GeneratedNavigationBinding.kt @@ -0,0 +1,8 @@ +package dev.enro.annotations + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS) +public annotation class GeneratedNavigationBinding( + val destination: String, + val navigationKey: String +) \ No newline at end of file diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/GeneratedNavigationComponent.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/GeneratedNavigationComponent.kt new file mode 100644 index 000000000..8c01c09d8 --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/GeneratedNavigationComponent.kt @@ -0,0 +1,9 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS) +public annotation class GeneratedNavigationComponent( + val bindings: Array>, +) \ No newline at end of file diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationComponent.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationComponent.kt new file mode 100644 index 000000000..c591e016f --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationComponent.kt @@ -0,0 +1,6 @@ +package dev.enro.annotations + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS) +public annotation class NavigationComponent() + diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationDestination.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationDestination.kt new file mode 100644 index 000000000..170336040 --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationDestination.kt @@ -0,0 +1,9 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY) +public expect annotation class NavigationDestination( + val key: KClass +) \ No newline at end of file diff --git a/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationPath.kt b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationPath.kt new file mode 100644 index 000000000..bd6986bd4 --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationPath.kt @@ -0,0 +1,29 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.CONSTRUCTOR) +@ExperimentalEnroApi +public annotation class NavigationPath( + val pattern: String, +) { + /** + * Declares that a [NavigationKey][dev.enro.NavigationKey] is bound to a path via a + * user-implemented `NavigationKey.PathBinding`. Use this for cases that don't + * fit the simple property-based mapping provided by the parent [NavigationPath] + * annotation — for example, when some properties get default values that aren't + * present in the URL, or when the deserialize/serialize logic needs to be hand-written. + * + * The referenced [binding] must be a class (typically a nested `object` on the key + * class) that implements `dev.enro.NavigationKey.PathBinding` where `T` is the + * key type itself. + */ + @Retention(AnnotationRetention.BINARY) + @Target(AnnotationTarget.CLASS) + @ExperimentalEnroApi + public annotation class FromBinding( + val binding: KClass, + ) +} diff --git a/enro-annotations/src/desktopMain/kotlin/dev/enro/annotations/NavigationDestination.desktop.kt b/enro-annotations/src/desktopMain/kotlin/dev/enro/annotations/NavigationDestination.desktop.kt new file mode 100644 index 000000000..f710fe086 --- /dev/null +++ b/enro-annotations/src/desktopMain/kotlin/dev/enro/annotations/NavigationDestination.desktop.kt @@ -0,0 +1,12 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(value = AnnotationRetention.BINARY) +@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) +public actual annotation class NavigationDestination(actual val key: KClass) { + + @Retention(value = AnnotationRetention.BINARY) + @Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) + public annotation class PlatformOverride(val key: KClass) +} \ No newline at end of file diff --git a/enro-annotations/src/jsMain/kotlin/dev/enro/annotations/NavigationDestination.js.kt b/enro-annotations/src/jsMain/kotlin/dev/enro/annotations/NavigationDestination.js.kt new file mode 100644 index 000000000..aa3285721 --- /dev/null +++ b/enro-annotations/src/jsMain/kotlin/dev/enro/annotations/NavigationDestination.js.kt @@ -0,0 +1,12 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(value = AnnotationRetention.BINARY) +@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) +public actual annotation class NavigationDestination actual constructor(actual val key: KClass) { + + @Retention(value = AnnotationRetention.BINARY) + @Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) + public annotation class PlatformOverride(val key: KClass) +} \ No newline at end of file diff --git a/enro-annotations/src/main/AndroidManifest.xml b/enro-annotations/src/main/AndroidManifest.xml deleted file mode 100644 index 06f04d663..000000000 --- a/enro-annotations/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/enro-annotations/src/main/java/dev/enro/annotations/Annotations.kt b/enro-annotations/src/main/java/dev/enro/annotations/Annotations.kt deleted file mode 100644 index 8805ccfb9..000000000 --- a/enro-annotations/src/main/java/dev/enro/annotations/Annotations.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.enro.annotations - -import kotlin.reflect.KClass - -@Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) -annotation class NavigationDestination( - val key: KClass -) - -@Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.CLASS) -annotation class NavigationComponent() - -@Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.CLASS) -annotation class GeneratedNavigationBinding( - val destination: String, - val navigationKey: String -) - -annotation class GeneratedNavigationModule( - val bindings: Array>, -) - -@Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.CLASS) -annotation class GeneratedNavigationComponent( - val bindings: Array>, - val modules: Array> -) - -@Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.FUNCTION) -annotation class ExperimentalComposableDestination \ No newline at end of file diff --git a/enro-annotations/src/nativeMain/kotlin/dev/enro/annotations/NavigationDestination.native.kt b/enro-annotations/src/nativeMain/kotlin/dev/enro/annotations/NavigationDestination.native.kt new file mode 100644 index 000000000..f710fe086 --- /dev/null +++ b/enro-annotations/src/nativeMain/kotlin/dev/enro/annotations/NavigationDestination.native.kt @@ -0,0 +1,12 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(value = AnnotationRetention.BINARY) +@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) +public actual annotation class NavigationDestination(actual val key: KClass) { + + @Retention(value = AnnotationRetention.BINARY) + @Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) + public annotation class PlatformOverride(val key: KClass) +} \ No newline at end of file diff --git a/enro-annotations/src/wasmJsMain/kotlin/dev/enro/annotations/NavigationDestination.wasmJs.kt b/enro-annotations/src/wasmJsMain/kotlin/dev/enro/annotations/NavigationDestination.wasmJs.kt new file mode 100644 index 000000000..aa3285721 --- /dev/null +++ b/enro-annotations/src/wasmJsMain/kotlin/dev/enro/annotations/NavigationDestination.wasmJs.kt @@ -0,0 +1,12 @@ +package dev.enro.annotations + +import kotlin.reflect.KClass + +@Retention(value = AnnotationRetention.BINARY) +@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) +public actual annotation class NavigationDestination actual constructor(actual val key: KClass) { + + @Retention(value = AnnotationRetention.BINARY) + @Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY]) + public annotation class PlatformOverride(val key: KClass) +} \ No newline at end of file diff --git a/enro-core/.gitignore b/enro-common/.gitignore similarity index 100% rename from enro-core/.gitignore rename to enro-common/.gitignore diff --git a/enro-common/README.md b/enro-common/README.md new file mode 100644 index 000000000..f72c72108 --- /dev/null +++ b/enro-common/README.md @@ -0,0 +1,14 @@ +# `enro-common` +The `enro-common` module exists as a place to put common Enro classes/interfaces/functions that do not depend on platform specific UI functionality. `enro-runtime` targets Android, iOS, JVM Desktop, and WASM JS, but `enro-common` also targets "normal" JS (non-WASM). At the surface level, it might seem a little odd to also target "normal" JS from this module, when `enro-runtime` does not support this target, but it's not uncommon to use NodeJS as a backend for Kotlin Multiplatform applications. By providing some of the non-UI related Enro definitions (such as `NavigationKey`) in `enro-common`, we allow KMP applications where a NodeJS backend is able to provide API responses that contain these objects. + +## Example +Imagine that you are working on a KMP project with the following modules: +`:common` (Android, iOS, JVM, WASM, JS) +`:frontend` (Android, iOS, JVM, WASM) +`:backend` (JS only, using NodeJS) + +The `:common` module is able to define API interfaces and their request/response classes (which are serialized using kotlinx serialization). The `:backend` module is able to implement these APIs, and the `:frontend` module is able to request a client for these APIs. This is a good developer experience, because you're dealing with the exact same kotlin classes on the frontend and backend, and can share almost anything related to the APIs/requests/responses. + +By providing the `enro-common` module, we allow the `:common` module to define `NavigationKey`s, which means that the `:backend` could include a `NavigationKey` in a response object. Even though the NodeJS `:backend` module could never render UI that uses Enro for navigation, this would allow the `:backend` module to control navigation on the clients. + +Allowing the backend to control the navigation of frontend clients in some situations can be very useful. For example, when A/B testing an onboarding flow, the backend may want to tell the frontend which screen to show next within that onboarding flow. \ No newline at end of file diff --git a/enro-common/build.gradle.kts b/enro-common/build.gradle.kts new file mode 100644 index 000000000..f13f9f2cc --- /dev/null +++ b/enro-common/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.google.devtools.ksp") + id("configure-library-with-js") + id("configure-publishing") + kotlin("plugin.serialization") +} + +kotlin { + sourceSets { + commonMain.dependencies { + api("dev.enro:enro-annotations:${project.enroVersionName}") + implementation(libs.androidx.savedState) + implementation(libs.kotlinx.serialization) + implementation(libs.kotlin.reflect) + implementation(libs.thauvin.urlencoder) + implementation(libs.compose.runtimeAnnotation) + } + + androidMain.dependencies { + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + } + } +} diff --git a/enro-common/consumer-rules.pro b/enro-common/consumer-rules.pro new file mode 100644 index 000000000..4401170be --- /dev/null +++ b/enro-common/consumer-rules.pro @@ -0,0 +1,9 @@ +-dontwarn dagger.hilt.** + +-keep class kotlin.LazyKt + +-keep class * extends dev.enro.NavigationKey + +#noinspection ShrinkerUnresolvedReference +-keep @dev.enro.annotations.GeneratedNavigationBinding public class ** +-keep @dev.enro.annotations.GeneratedNavigationComponent public class ** \ No newline at end of file diff --git a/enro-core/proguard-rules.pro b/enro-common/proguard-rules.pro similarity index 100% rename from enro-core/proguard-rules.pro rename to enro-common/proguard-rules.pro diff --git a/enro-common/src/androidMain/kotlin/dev/enro/metadataKeyName.android.kt b/enro-common/src/androidMain/kotlin/dev/enro/metadataKeyName.android.kt new file mode 100644 index 000000000..e33ec8575 --- /dev/null +++ b/enro-common/src/androidMain/kotlin/dev/enro/metadataKeyName.android.kt @@ -0,0 +1,9 @@ +package dev.enro + +import kotlin.reflect.KClass + +internal actual fun metadataKeyName(kClass: KClass<*>): String { + return kClass.qualifiedName + ?: kClass.simpleName + ?: error("MetadataKey class must have a qualifiedName or simpleName") +} diff --git a/enro-common/src/androidMain/kotlin/dev/enro/serialization/serializerForNavigationKey.android.kt b/enro-common/src/androidMain/kotlin/dev/enro/serialization/serializerForNavigationKey.android.kt new file mode 100644 index 000000000..2a10a2629 --- /dev/null +++ b/enro-common/src/androidMain/kotlin/dev/enro/serialization/serializerForNavigationKey.android.kt @@ -0,0 +1,85 @@ +package dev.enro.serialization + +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import androidx.core.os.BundleCompat +import androidx.savedstate.serialization.serializers.ParcelableSerializer +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlin.io.encoding.Base64 +import kotlin.reflect.KClass + +@AdvancedEnroApi +public actual inline fun serializerForNavigationKey(): KSerializer { + val serializer = runCatching { defaultSerializerForNavigationKey() } + .getOrNull() + if (serializer != null) { + return serializer + } + return SerializerForParcelableNavigationKey(T::class) +} + +@PublishedApi +internal class SerializerForParcelableNavigationKey( + private val type: KClass, +) : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("${type.qualifiedName}") { + element("value", String.serializer().descriptor) + } + private val parcelableSerializer = object : ParcelableSerializer() {} + + override fun deserialize(decoder: Decoder): T { + if (decoder is JsonDecoder) { + return decoder.decodeStructure(descriptor) { + val base64Encoded = decodeStringElement( + descriptor = descriptor, + index = decodeElementIndex(descriptor) + ) + val base64Decoded = Base64.decode(base64Encoded) + val savedParcel = Parcel.obtain().apply { + unmarshall(base64Decoded, 0, base64Decoded.size) + } + savedParcel.setDataPosition(0) + val readState = savedParcel.readBundle(type.java.classLoader)!! + savedParcel.recycle() + return@decodeStructure BundleCompat.getParcelable(readState, "value", type.java) as T + } + } + return parcelableSerializer.deserialize(decoder) as T + } + + override fun serialize(encoder: Encoder, value: T) { + if (encoder is JsonEncoder) { + value as Parcelable + val data = Bundle().apply { + putParcelable("value", value) + } + val parcel = Parcel.obtain() + data.writeToParcel(parcel, 0) + val base64Encoded = Base64.encode(parcel.marshall()) + parcel.recycle() + encoder.encodeStructure(descriptor) { + encodeStringElement( + descriptor, + 0, + base64Encoded, + ) + } + return + } + + parcelableSerializer.serialize(encoder, value as Parcelable) + } +} \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/NavigationBackstack.kt b/enro-common/src/commonMain/kotlin/dev/enro/NavigationBackstack.kt new file mode 100644 index 000000000..2ef4fd4fb --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/NavigationBackstack.kt @@ -0,0 +1,29 @@ +package dev.enro + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable + +@Stable +@Immutable +@Serializable +public class NavigationBackstack( + private val backstack: List> +): List> by backstack { + public val keys: List by lazy { + map { it.key } + } +} + +public fun emptyBackstack(): NavigationBackstack { + return NavigationBackstack(emptyList()) +} + +public fun backstackOf(vararg instance: NavigationKey.Instance<*>): NavigationBackstack { + return NavigationBackstack(instance.toList()) +} + +public fun List>.asBackstack(): NavigationBackstack { + return NavigationBackstack(this) +} + diff --git a/enro-common/src/commonMain/kotlin/dev/enro/NavigationKey.kt b/enro-common/src/commonMain/kotlin/dev/enro/NavigationKey.kt new file mode 100644 index 000000000..0f01b9548 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/NavigationKey.kt @@ -0,0 +1,294 @@ +package dev.enro + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.path.PathData +import dev.enro.serialization.internalUnwrapForSerialization +import dev.enro.serialization.internalWrapForSerialization +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.uuid.Uuid + +/** + * A NavigationKey represents the contract for a screen. A class that implements + * the NavigationKey interface uses properties on that class to represent the + * inputs/arguments/parameters for that contract. + * + * Example: + * ``` + * // Contract for the Profile screen, which displays the profile for + * // the user with the id passed in the "userId" parameter + * class Profile(val userId: String) : NavigationKey + * ``` + * + * NavigationKeys are also able to define outputs, as well as inputs. This is done by + * implementing the NavigationKey.WithResult interface, where `T` is the type of the + * result that is returned by that screen. + * + * Example: + * ``` + * // Contract for the SelectDate screen, which allows the user to select + * // a date within an (optional) range + * class SelectDate( + * val minimumDate: LocalDate?, + * val maximumDate: LocalDate?, + * ) : NavigationKey.WithResult + * ``` + * + */ +public interface NavigationKey { + + /** + * Marks a [NavigationKey] as producing a result of type [T]. + * Implementing this interface allows the screen associated with this key + * to return a typed value to its caller, enabling type-safe result handling. + */ + public interface WithResult : NavigationKey + + /** + * Describes how to convert between a URL-style path string and an instance of a + * [NavigationKey] of type [T]. Implement this interface (typically as a nested + * `object` on the key class) and reference it from + * [dev.enro.annotations.NavigationPath.FromBinding] for cases that don't fit the + * simple property-based mapping driven by [dev.enro.annotations.NavigationPath]. + * + * The [pattern] follows the standard Enro path-pattern grammar + * (e.g. `"/users/{id}?source={source?}"`). + */ + @ExperimentalEnroApi + public interface PathBinding { + public val pattern: String + public fun deserialize(data: PathData): T + public fun serialize(builder: PathData.Builder, key: T) + } + + /** + * A data class that bundles a [key] of type [T] with its associated [metadata]. + * This is often used to declaratively define a navigation target along with its initial + * metadata, before it's resolved into a [NavigationKey.Instance] by the navigation system. + */ + @ConsistentCopyVisibility + public data class WithMetadata internal constructor( + val key: T, + val metadata: Metadata, + ) + + /** + * Represents a realized, active instance of a [NavigationKey] within a navigation backstack. + * Each [NavigationKey.Instance] is uniquely identified by its [id], references the original [key] + * it is representing, and carries its own [metadata]. + */ + @Stable + @Immutable + @Serializable + public data class Instance @AdvancedEnroApi constructor( + @Polymorphic public val key: T, + public val id: String = Uuid.random().toString(), + public val metadata: Metadata = Metadata(), + ) { + @Deprecated( + "Use 'key' instead of 'navigationKey'", + level = DeprecationLevel.WARNING, + ) + public val navigationKey: T get() = key + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Instance<*> + + return id == other.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + } + + /** + * A type-safe, serializable key-value store for attaching arbitrary data to a + * navigation instance (either [NavigationKey.Instance] or through [NavigationKey.WithMetadata]). + * It allows associating additional, non-contractual information with a specific + * navigation event or screen instance, using string keys to access typed values. + * + * Note: When using [set], ensure that serializers for the types being stored are + * registered with the [NavigationController] if they are not standard Kotlin types, + * especially for polymorphic serialization of [Any]. This is checked in debug builds. + */ + @Serializable(with = Metadata.Serializer::class) + public class Metadata internal constructor( + @PublishedApi + internal val map: MutableMap, + internal val transientMap: MutableMap, + ) { + public constructor() : this(mutableMapOf(), mutableMapOf()) + + public object Serializer : KSerializer { + private val innerSerializer = MapSerializer(String.serializer(), PolymorphicSerializer(Any::class)) + override val descriptor: SerialDescriptor = innerSerializer.descriptor + + override fun serialize(encoder: Encoder, value: Metadata) { + innerSerializer.serialize( + encoder = encoder, + value = value.map + .mapValues { it.value.internalWrapForSerialization() }, + ) + } + + override fun deserialize(decoder: Decoder): Metadata { + val map = innerSerializer + .deserialize(decoder) + .mapValues { it.value.internalUnwrapForSerialization() } + return Metadata( + map = map.toMutableMap(), + transientMap = mutableMapOf(), + ) + } + } + + public fun get(key: MetadataKey): T { + @Suppress("UNCHECKED_CAST") + return when (key is TransientMetadataKey<*>) { + true -> transientMap[key.name] as T? ?: key.default + false -> map[key.name] as T? ?: key.default + } + } + + public fun remove(key: MetadataKey<*>) { + when (key is TransientMetadataKey<*>) { + true -> transientMap.remove(key.name) + false -> map.remove(key.name) + } + } + + @OptIn(ExperimentalSerializationApi::class) + public fun set(key: MetadataKey, value: T) { + NavigationKey.verifyMetadataSerialization(key, value) + val isTransient = key is TransientMetadataKey<*> + when (value) { + null -> when (isTransient) { + true -> transientMap.remove(key.name) + false -> map.remove(key.name) + } + else -> when (isTransient) { + true -> transientMap.put(key.name, value) + false -> map.put(key.name, value) + } + } + } + + public fun setFrom(other: Metadata) { + map.clear() + map.putAll(other.map) + transientMap.clear() + transientMap.putAll(other.transientMap) + } + + public fun addFrom(other: Metadata) { + map.putAll(other.map) + transientMap.putAll(other.transientMap) + } + + public fun copy(): Metadata { + return Metadata().apply { + setFrom(this@Metadata) + } + } + + override fun toString(): String { + return (map + transientMap).toString() + } + } + + /** + * A typed key used to access and store values within [NavigationKey.Metadata]. + * + * Example: + * ``` + * object IsDialog : NavigationKey.MetadataKey(default = false) + * val isDialog = metadata.get(IsDialog) // isDialog will be false if not set + * ``` + */ + public abstract class MetadataKey( + public val default: T, + ) { + public val name: String by lazy { + metadataKeyName(this::class) + } + } + + /** + * A TransientMetadataKey is a [MetadataKey] that is not persisted across saved instance states. + * + * This is marked as an [AdvancedEnroApi] because it is not recommended to use this unless you + * understand the implications of not persisting the metadata across saved instance states. + */ + @AdvancedEnroApi + public abstract class TransientMetadataKey( + default: T + ) : MetadataKey(default) + + public companion object { + // This is accessed and set in EnroController + internal var verifyMetadataSerialization: (key: MetadataKey, value: Any?) -> Unit = { _, _ -> } + } +} + +/** + * Creates a [NavigationKey.WithMetadata] instance from this [NavigationKey], with an empty metadata map. + */ +@AdvancedEnroApi +public fun K.withMetadata(): NavigationKey.WithMetadata { + return NavigationKey.WithMetadata( + key = this, + metadata = NavigationKey.Metadata(), + ) +} + +public fun K.withMetadata( + key: NavigationKey.MetadataKey, + value: T, +): NavigationKey.WithMetadata { + return NavigationKey.WithMetadata( + key = this, + metadata = NavigationKey.Metadata().apply { + set(key, value) + }, + ) +} + +public fun NavigationKey.WithMetadata.withMetadata( + key: NavigationKey.MetadataKey, + value: T, +): NavigationKey.WithMetadata { + return NavigationKey.WithMetadata( + key = this@withMetadata.key, + metadata = metadata.copy().apply { + set(key, value) + }, + ) +} + +public fun K.asInstance(): NavigationKey.Instance { + return NavigationKey.Instance( + key = this, + ) +} + +public fun NavigationKey.WithMetadata.asInstance(): NavigationKey.Instance { + return NavigationKey.Instance( + key = key, + metadata = metadata, + ) +} diff --git a/enro-common/src/commonMain/kotlin/dev/enro/metadataKeyName.kt b/enro-common/src/commonMain/kotlin/dev/enro/metadataKeyName.kt new file mode 100644 index 000000000..311f7b745 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/metadataKeyName.kt @@ -0,0 +1,21 @@ +package dev.enro + +import kotlin.reflect.KClass + +/** + * Stable string identifier for a [NavigationKey.MetadataKey]'s class. + * Used as the storage key inside [NavigationKey.Metadata] and persisted + * across saved-state round-trips, so the identifier must be: + * + * - **Stable across runs** — serialised metadata must restore to the + * same logical key on next launch. + * - **Unique per MetadataKey class** — two different MetadataKey objects + * must not collide. + * + * On every target except Kotlin/JS this is `KClass.qualifiedName`. On + * Kotlin/JS, `qualifiedName` is unsupported by the Kotlin reflection + * API, so we fall back to `simpleName`. The JS fallback is correct for + * the common `object MyKey : MetadataKey<...>` pattern as long as the + * `MyKey` simple names are unique within the app. + */ +internal expect fun metadataKeyName(kClass: KClass<*>): String diff --git a/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.fromNavigationKey.kt b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.fromNavigationKey.kt new file mode 100644 index 000000000..d36010ab3 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.fromNavigationKey.kt @@ -0,0 +1,614 @@ +package dev.enro.path + +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.typeOf + +/** + * Wraps a user-implemented [NavigationKey.PathBinding] into a runtime + * [NavigationPathBinding] that can be registered on a [dev.enro.controller.NavigationModule]. + * + * Used by code generated for `@NavigationPath.FromBinding(MyBinding::class)`, but also + * usable directly from hand-written modules. + */ +@OptIn(ExperimentalEnroApi::class) +public fun NavigationPathBinding.Companion.fromBinding( + keyType: KClass, + binding: NavigationKey.PathBinding, +): NavigationPathBinding { + return NavigationPathBinding( + keyType = keyType, + pattern = binding.pattern, + deserialize = { binding.deserialize(this) }, + serialize = { key -> binding.serialize(this, key) }, + ) +} + +@PublishedApi +internal inline fun checkParameterIsSupported( + property: KProperty1<*, P>, + elements: Set, + nullableElements: Set, +) { + val isSupportedType = when (P::class) { + String::class -> true + Int::class -> true + Long::class -> true + Float::class -> true + Double::class -> true + Short::class -> true + Byte::class -> true + Char::class -> true + Boolean::class -> true + else -> false + } + require(isSupportedType) { + "Property ${property.name} of type ${P::class} is not supported as a path parameter. Must be a primitive." + } + if (typeOf

().isMarkedNullable) { + return require(nullableElements.contains(property.name)) { + "Property ${property.name} of type ${P::class} is nullable, but the path parameter ${property.name} is not marked as optional." + } + } + return require(elements.contains(property.name)) { + "Property ${property.name} was not found in the path pattern." + } +} + +public inline fun PathData.Builder.set( + navigationKey: T, + property: KProperty1, +) { + val stringValue = property.get(navigationKey)?.toString() ?: return + set(property.name, stringValue) +} + +public inline fun PathData.get( + property: KProperty1<*, P>, +): P { + val stringValue = optional(property.name) + if (stringValue == null) { + val isNullable = typeOf

().isMarkedNullable + if (isNullable) return null as P + else error("Property ${property.name} is not nullable, but no value was found") + } + return when (P::class) { + String::class -> stringValue as P + Int::class -> stringValue.toInt() as P + Long::class -> stringValue.toLong() as P + Float::class -> stringValue.toFloat() as P + Double::class -> stringValue.toDouble() as P + Short::class -> stringValue.toShort() as P + Byte::class -> stringValue.toByte() as P + Char::class -> stringValue.first() as P + Boolean::class -> stringValue.toBoolean() as P + else -> error("Type ${P::class} is not supported") + } +} + + +public inline fun NavigationPathBinding.Companion.createPathBinding( + pattern: String, + crossinline constructor: () -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + require(parameterNames.size == 0) { + "Path pattern must not have any parameters, but found ${parameterNames.size}" + } + + return NavigationPathBinding( + keyType = T::class, + pattern = pattern, + deserialize = { constructor() }, + serialize = { } + ) +} + +public inline fun < + reified P1, + reified T : NavigationKey + > NavigationPathBinding.Companion.createPathBinding( + + pattern: String, + propertyOne: KProperty1, + crossinline constructor: (P1) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 1) { + "Path pattern must have exactly one parameter, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + } + ) +} + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + crossinline constructor: (P1, P2) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 2) { + "Path pattern must have exactly two parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + } + ) +} + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + propertyThree: KProperty1, + crossinline constructor: (P1, P2, P3) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 3) { + "Path pattern must have exactly three parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + checkParameterIsSupported(propertyThree, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo), + it.get(propertyThree) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + propertyThree.get(it)?.let { + set(propertyThree.name, it.toString()) + } + } + ) +} + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + propertyThree: KProperty1, + propertyFour: KProperty1, + crossinline constructor: (P1, P2, P3, P4) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 4) { + "Path pattern must have exactly four parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + checkParameterIsSupported(propertyThree, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFour, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo), + it.get(propertyThree), + it.get(propertyFour) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + propertyThree.get(it)?.let { + set(propertyThree.name, it.toString()) + } + propertyFour.get(it)?.let { + set(propertyFour.name, it.toString()) + } + } + ) +} + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + propertyThree: KProperty1, + propertyFour: KProperty1, + propertyFive: KProperty1, + crossinline constructor: (P1, P2, P3, P4, P5) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 5) { + "Path pattern must have exactly five parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + checkParameterIsSupported(propertyThree, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFour, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFive, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo), + it.get(propertyThree), + it.get(propertyFour), + it.get(propertyFive) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + propertyThree.get(it)?.let { + set(propertyThree.name, it.toString()) + } + propertyFour.get(it)?.let { + set(propertyFour.name, it.toString()) + } + propertyFive.get(it)?.let { + set(propertyFive.name, it.toString()) + } + } + ) +} + + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + propertyThree: KProperty1, + propertyFour: KProperty1, + propertyFive: KProperty1, + propertySix: KProperty1, + crossinline constructor: (P1, P2, P3, P4, P5, P6) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 6) { + "Path pattern must have exactly six parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + checkParameterIsSupported(propertyThree, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFour, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFive, parameterNames, nullableParameters) + checkParameterIsSupported(propertySix, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo), + it.get(propertyThree), + it.get(propertyFour), + it.get(propertyFive), + it.get(propertySix) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + propertyThree.get(it)?.let { + set(propertyThree.name, it.toString()) + } + propertyFour.get(it)?.let { + set(propertyFour.name, it.toString()) + } + propertyFive.get(it)?.let { + set(propertyFive.name, it.toString()) + } + propertySix.get(it)?.let { + set(propertySix.name, it.toString()) + } + } + ) +} + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + propertyThree: KProperty1, + propertyFour: KProperty1, + propertyFive: KProperty1, + propertySix: KProperty1, + propertySeven: KProperty1, + crossinline constructor: (P1, P2, P3, P4, P5, P6, P7) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 7) { + "Path pattern must have exactly seven parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + checkParameterIsSupported(propertyThree, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFour, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFive, parameterNames, nullableParameters) + checkParameterIsSupported(propertySix, parameterNames, nullableParameters) + checkParameterIsSupported(propertySeven, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo), + it.get(propertyThree), + it.get(propertyFour), + it.get(propertyFive), + it.get(propertySix), + it.get(propertySeven) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + propertyThree.get(it)?.let { + set(propertyThree.name, it.toString()) + } + propertyFour.get(it)?.let { + set(propertyFour.name, it.toString()) + } + propertyFive.get(it)?.let { + set(propertyFive.name, it.toString()) + } + propertySix.get(it)?.let { + set(propertySix.name, it.toString()) + } + propertySeven.get(it)?.let { + set(propertySeven.name, it.toString()) + } + } + ) +} + +public inline fun + NavigationPathBinding.Companion.createPathBinding( + pattern: String, + propertyOne: KProperty1, + propertyTwo: KProperty1, + propertyThree: KProperty1, + propertyFour: KProperty1, + propertyFive: KProperty1, + propertySix: KProperty1, + propertySeven: KProperty1, + propertyEight: KProperty1, + crossinline constructor: (P1, P2, P3, P4, P5, P6, P7, P8) -> T, +): NavigationPathBinding { + val pathPattern = PathPattern.fromString(pattern) + + val parameterNames = pathPattern.pathElements + .filterIsInstance() + .map { it.name } + .plus(pathPattern.queryElements.map { it.paramName }) + .toSet() + + val nullableParameters = pathPattern.queryElements + .filterIsInstance() + .map { it.paramName } + .toSet() + + require(parameterNames.size == 8) { + "Path pattern must have exactly eight parameters, but found ${parameterNames.size}" + } + + checkParameterIsSupported(propertyOne, parameterNames, nullableParameters) + checkParameterIsSupported(propertyTwo, parameterNames, nullableParameters) + checkParameterIsSupported(propertyThree, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFour, parameterNames, nullableParameters) + checkParameterIsSupported(propertyFive, parameterNames, nullableParameters) + checkParameterIsSupported(propertySix, parameterNames, nullableParameters) + checkParameterIsSupported(propertySeven, parameterNames, nullableParameters) + checkParameterIsSupported(propertyEight, parameterNames, nullableParameters) + + return NavigationPathBinding( + keyType = T::class, + pattern = pathPattern, + deserialize = { + constructor( + it.get(propertyOne), + it.get(propertyTwo), + it.get(propertyThree), + it.get(propertyFour), + it.get(propertyFive), + it.get(propertySix), + it.get(propertySeven), + it.get(propertyEight) + ) + }, + serialize = { + propertyOne.get(it)?.let { + set(propertyOne.name, it.toString()) + } + propertyTwo.get(it)?.let { + set(propertyTwo.name, it.toString()) + } + propertyThree.get(it)?.let { + set(propertyThree.name, it.toString()) + } + propertyFour.get(it)?.let { + set(propertyFour.name, it.toString()) + } + propertyFive.get(it)?.let { + set(propertyFive.name, it.toString()) + } + propertySix.get(it)?.let { + set(propertySix.name, it.toString()) + } + propertySeven.get(it)?.let { + set(propertySeven.name, it.toString()) + } + propertyEight.get(it)?.let { + set(propertyEight.name, it.toString()) + } + } + ) +} diff --git a/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.kt b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.kt new file mode 100644 index 000000000..992c230f8 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.kt @@ -0,0 +1,72 @@ +package dev.enro.path + +import dev.enro.NavigationKey +import kotlin.reflect.KClass + + +public class NavigationPathBinding @PublishedApi internal constructor( + internal val keyType: KClass, + internal val pattern: PathPattern, + internal val deserialize: (PathData) -> T, + internal val serialize: PathData.Builder.(T) -> Unit, +) { + public constructor( + keyType: KClass, + pattern: String, + deserialize: PathData.() -> T, + serialize: PathData.Builder.(T) -> Unit, + ) : this( + keyType = keyType, + pattern = PathPattern.fromString(pattern), + deserialize = deserialize, + serialize = serialize + ) + + public fun matches(path: ParsedPath) : Boolean { + return pattern.matches(path) + } + + public fun matches(key: NavigationKey): Boolean { + return keyType.isInstance(key) + } + + public fun fromPath(path: ParsedPath): T { + if (!matches(path)) { + throw IllegalArgumentException("Path does not match the pattern") + } + val data = pattern.toPathData(path) + return deserialize(data) + } + + public fun toPath(key: T): String { + val builder = PathData.Builder() + builder.serialize(key) + return pattern.toPath(builder.build()) + } + + public companion object { + /** + * Picks the most specific binding from [bindings] that [matches][NavigationPathBinding.matches] + * [path]. When multiple bindings match, more literal path segments wins, then more + * required query parameters. + * + * Returns `null` when no binding matches; throws when the top-scoring set is + * itself ambiguous (multiple bindings tied at the most-specific score). + */ + public fun resolveForPath( + bindings: List>, + path: ParsedPath, + ): NavigationPathBinding<*>? { + val matching = bindings.filter { it.matches(path) } + if (matching.isEmpty()) return null + if (matching.size == 1) return matching.single() + + val topScore = matching.maxOf { it.pattern.specificityScore } + val mostSpecific = matching.filter { it.pattern.specificityScore == topScore } + require(mostSpecific.size == 1) { + "Multiple path bindings found for path: $path" + } + return mostSpecific.single() + } + } +} diff --git a/enro-common/src/commonMain/kotlin/dev/enro/path/ParsedPath.kt b/enro-common/src/commonMain/kotlin/dev/enro/path/ParsedPath.kt new file mode 100644 index 000000000..fcdf58047 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/ParsedPath.kt @@ -0,0 +1,36 @@ +package dev.enro.path + +import net.thauvin.erik.urlencoder.UrlEncoderUtil + +public data class ParsedPath internal constructor( + val pathParts: List, + val queryParts: Map, +) { + public companion object { + public fun fromString(path: String): ParsedPath { + val pathParts = mutableListOf() + val queryParts = mutableMapOf() + + val parts = path.split("?") + val pathPattern = parts[0] + .removePrefix("/") + .removeSuffix("/") + val queryPattern = parts.getOrNull(1) + + // Parse path pattern + pathPattern.split("/").forEach { segment -> + pathParts.add(UrlEncoderUtil.decode(segment)) + } + + // Parse query pattern + queryPattern?.split("&")?.forEach { param -> + val keyValue = param.split("=") + if (keyValue.size == 2) { + queryParts[UrlEncoderUtil.decode(keyValue[0])] = UrlEncoderUtil.decode(keyValue[1]) + } + } + + return ParsedPath(pathParts, queryParts) + } + } +} \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/path/PathData.kt b/enro-common/src/commonMain/kotlin/dev/enro/path/PathData.kt new file mode 100644 index 000000000..91f140a04 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/PathData.kt @@ -0,0 +1,27 @@ +package dev.enro.path + +public class PathData internal constructor( + internal val data: Map, +) { + public fun optional(key: String): String? { + return data[key] + } + + public fun require(key: String): String { + return requireNotNull(data[key]) { + "No value found for path parameter '$key'" + } + } + + public class Builder internal constructor() { + private val data = mutableMapOf() + + public fun set(key: String, value: String) { + data[key] = value + } + + internal fun build(): PathData { + return PathData(data.toMap()) + } + } +} diff --git a/enro-common/src/commonMain/kotlin/dev/enro/path/PathPattern.kt b/enro-common/src/commonMain/kotlin/dev/enro/path/PathPattern.kt new file mode 100644 index 000000000..f7be886d0 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/PathPattern.kt @@ -0,0 +1,202 @@ +package dev.enro.path + +import net.thauvin.erik.urlencoder.UrlEncoderUtil + +@PublishedApi +internal data class PathPattern( + val pathElements: List, + val queryElements: List, +) { + fun matches(path: ParsedPath): Boolean { + if (pathElements.size != path.pathParts.size) { + return false + } + + for (i in pathElements.indices) { + val element = pathElements[i] + val part = path.pathParts[i] + + when (element) { + is PathElement.Segment -> if (element.value != part) return false + is PathElement.PathParam -> continue + } + } + + for (queryElement in queryElements) { + when (queryElement) { + is QueryElement.QueryParam -> if (!path.queryParts.containsKey(queryElement.queryName)) return false + is QueryElement.OptionalQueryParam -> continue + } + } + + return true + } + + /** + * A specificity score used to disambiguate between multiple bindings that all + * [matches] the same path. Higher is more specific. Literal path segments dominate + * (weighted ahead of query params), then required query parameters. + */ + internal val specificityScore: Int + get() { + val literalSegments = pathElements.count { it is PathElement.Segment } + val requiredQueryParams = queryElements.count { it is QueryElement.QueryParam } + return literalSegments * 1_000 + requiredQueryParams + } + + fun toPathData(parsedPath: ParsedPath): PathData { + val data = mutableMapOf() + for (i in pathElements.indices) { + val element = pathElements[i] + val parsedPart = parsedPath.pathParts[i] + + when (element) { + is PathElement.Segment -> continue + is PathElement.PathParam -> data[element.name] = parsedPart + } + } + + for (queryElement in queryElements) { + when (queryElement) { + is QueryElement.QueryParam -> { + val queryValue = parsedPath.queryParts[queryElement.queryName] + requireNotNull(queryValue) + data[queryElement.paramName] = queryValue + } + is QueryElement.OptionalQueryParam -> { + val queryValue = parsedPath.queryParts[queryElement.queryName] + ?: continue + data[queryElement.paramName] = queryValue + } + } + } + + return PathData(data) + } + + fun toPath(data: PathData): String { + val pathBuilder = StringBuilder() + val queryBuilder = StringBuilder() + + for (i in pathElements.indices) { + val element = pathElements[i] + when (element) { + is PathElement.Segment -> pathBuilder.append("/").append(UrlEncoderUtil.encode(element.value)) + is PathElement.PathParam -> { + val value = data.data[element.name] + requireNotNull(value) { "Missing value for path parameter: ${element.name}" } + pathBuilder.append("/").append(UrlEncoderUtil.encode(value)) + } + } + } + + if (queryElements.isNotEmpty()) { + queryBuilder.append("?") + val queryValues = mutableListOf() + for (i in queryElements.indices) { + val element = queryElements[i] + when (element) { + is QueryElement.QueryParam -> { + val value = data.data[element.paramName] + requireNotNull(value) { "Missing value for query parameter: ${element.paramName}" } + queryValues.add("${element.queryName}=${UrlEncoderUtil.encode(value)}") + } + is QueryElement.OptionalQueryParam -> { + val value = data.data[element.paramName] + if (value != null) { + queryValues.add("${element.queryName}=${UrlEncoderUtil.encode(value)}") + } + } + } + } + queryBuilder.append( + queryValues.joinToString("&") + ) + } + + return pathBuilder.toString() + queryBuilder.toString() + } + + sealed class PathElement { + data class Segment(val value: String) : PathElement() + data class PathParam(val name: String) : PathElement() + } + + sealed class QueryElement { + abstract val queryName: String + abstract val paramName: String + + data class QueryParam( + override val queryName: String, + override val paramName: String + ) : QueryElement() + + data class OptionalQueryParam( + override val queryName: String, + override val paramName: String + ) : QueryElement() + } + + companion object { + @PublishedApi + internal fun fromString(pattern: String): PathPattern { + val pathElements = mutableListOf() + val queryElements = mutableListOf() + + val parts = pattern.split("?", limit = 2) + val pathPattern = parts[0] + .removePrefix("/") + .removeSuffix("/") + val queryPattern = parts.getOrNull(1) + + // Parse path pattern + pathPattern.split("/").forEach { segment -> + if (segment.startsWith("{") && segment.endsWith("}")) { + pathElements.add( + PathElement.PathParam( + segment.substring( + 1, + segment.length - 1 + ) + ) + ) + } else { + pathElements.add(PathElement.Segment(segment)) + } + } + + // Parse query pattern + queryPattern?.split("&")?.forEach { param -> + val keyValue = param.split("=") + require(keyValue.size == 2) { + "Invalid query parameter format: $param" + } + val key = keyValue[0] + val value = keyValue[1] + when { + value.startsWith("{") && value.endsWith("?}") -> { + queryElements.add( + QueryElement.OptionalQueryParam( + queryName = key, + paramName = value.substring(1, value.length - 2) + ) + ) + } + value.startsWith("{") && value.endsWith("}") -> { + queryElements.add( + QueryElement.QueryParam( + queryName = key, + paramName = value.substring(1, value.length - 1) + ) + ) + } + else -> { + error("Invalid query parameter format: $param") + } + } + } + + return PathPattern(pathElements, queryElements) + } + } +} \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/Any.internalUnwrapForSerialization.kt b/enro-common/src/commonMain/kotlin/dev/enro/serialization/Any.internalUnwrapForSerialization.kt new file mode 100644 index 000000000..f9e48baaa --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/Any.internalUnwrapForSerialization.kt @@ -0,0 +1,26 @@ +package dev.enro.serialization + +internal fun Any?.internalUnwrapForSerialization(): Any { + return when (this) { + // primitives + null -> WrappedNull + is WrappedBoolean -> value + is WrappedDouble -> value + is WrappedFloat -> value + is WrappedInt -> value + is WrappedLong -> value + is WrappedShort -> value + is WrappedString -> value + is WrappedByte -> value + is WrappedChar -> value + + // collections + is WrappedList -> value.map { it?.internalUnwrapForSerialization() }.toMutableList() + is WrappedSet -> value.map { it?.internalUnwrapForSerialization() }.toMutableSet() + is WrappedMap -> value.mapValues { it.value?.internalUnwrapForSerialization() } + .mapKeys { it.key.internalUnwrapForSerialization() } + .toMutableMap() + + else -> this + } +} \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/Any.internalWrapForSerialization.kt b/enro-common/src/commonMain/kotlin/dev/enro/serialization/Any.internalWrapForSerialization.kt new file mode 100644 index 000000000..303af900a --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/Any.internalWrapForSerialization.kt @@ -0,0 +1,29 @@ +package dev.enro.serialization + +internal fun Any?.internalWrapForSerialization(): Any { + return when (this) { + // primitives + null -> WrappedNull + is Boolean -> WrappedBoolean(this) + is Double -> WrappedDouble(this) + is Float -> WrappedFloat(this) + is Int -> WrappedInt(this) + is Long -> WrappedLong(this) + is Short -> WrappedShort(this) + is String -> WrappedString(this) + is Byte -> WrappedByte(this) + is Char -> WrappedChar(this) + + // collections + is List<*> -> WrappedList(this.map { it?.internalWrapForSerialization() }.toMutableList()) + is Set<*> -> WrappedSet(this.map { it?.internalWrapForSerialization() }.toMutableSet()) + is Map<*, *> -> WrappedMap( + this.mapValues { it.value?.internalWrapForSerialization() } + .mapKeys { it.key.internalWrapForSerialization() } + .toMutableMap() + ) + + // don't wrap other types + else -> this + } +} \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/README.md b/enro-common/src/commonMain/kotlin/dev/enro/serialization/README.md new file mode 100644 index 000000000..c6e285825 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/README.md @@ -0,0 +1,5 @@ +# dev.enro.serialization + +This package contains helper functions for wrapping and unwrapping primitive types and collection types. This is primarily used for serializing and deserializing data to and from the NavigationInstructionExtras objects that are included in NavigationInstruction.Open classes. + +The primary way of interacting with this package is through `Any?.wrapForSerialization()` and `Any.unwrapForSerialization`. These functions will use WrappedCollection and WrappedPrimitive subclasses to wrap values, which is important for correctly serializing and deserializing values included in NavigationInstructionExtras, which is essentially a `Map`. \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/WrappedCollection.kt b/enro-common/src/commonMain/kotlin/dev/enro/serialization/WrappedCollection.kt new file mode 100644 index 000000000..68a34d049 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/WrappedCollection.kt @@ -0,0 +1,130 @@ +package dev.enro.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +@Serializable +internal sealed class WrappedCollection + +@Serializable(with = WrappedList.Serializer::class) +@SerialName("WrappedList") +internal class WrappedList(val value: MutableList) : WrappedCollection() { + object Serializer : KSerializer { + private val innerSerializer = ListSerializer(PolymorphicSerializer(Any::class)) + override val descriptor = buildClassSerialDescriptor("WrappedList") { + element("value", innerSerializer.descriptor) + } + + override fun serialize(encoder: Encoder, value: WrappedList) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement( + descriptor = descriptor, + index = 0, + serializer = innerSerializer, + value = value.value + .map { it.internalWrapForSerialization() }, + ) + } + } + + override fun deserialize(decoder: Decoder): WrappedList { + val list = decoder + .decodeStructure(descriptor) { + decodeSerializableElement( + descriptor = descriptor, + index = decodeElementIndex(descriptor), + deserializer = innerSerializer, + ) + } + .map { it.internalUnwrapForSerialization() } + return WrappedList(list.toMutableList()) + } + } +} + +@Serializable(with = WrappedSet.Serializer::class) +@SerialName("WrappedSet") +internal class WrappedSet(val value: MutableSet) : WrappedCollection() { + object Serializer : KSerializer { + private val innerSerializer = ListSerializer(PolymorphicSerializer(Any::class)) + override val descriptor = buildClassSerialDescriptor("WrappedSet") { + element("value", innerSerializer.descriptor) + } + + override fun serialize(encoder: Encoder, value: WrappedSet) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement( + descriptor = descriptor, + index = 0, + serializer = innerSerializer, + value = value.value + .map { it.internalWrapForSerialization() }, + ) + } + } + + override fun deserialize(decoder: Decoder): WrappedSet { + val list = decoder + .decodeStructure(descriptor) { + decodeSerializableElement( + descriptor = descriptor, + index = decodeElementIndex(descriptor), + deserializer = innerSerializer, + ) + } + .map { it.internalUnwrapForSerialization() } + return WrappedSet(list.toMutableSet()) + } + } +} + +@Serializable(with = WrappedMap.Serializer::class) +@SerialName("WrappedMap") +internal class WrappedMap(val value: MutableMap) : WrappedCollection() { + object Serializer : KSerializer { + private val innerSerializer = MapSerializer(PolymorphicSerializer(Any::class), PolymorphicSerializer(Any::class)) + override val descriptor = buildClassSerialDescriptor("WrappedMap") { + element("value", innerSerializer.descriptor) + } + + override fun serialize(encoder: Encoder, value: WrappedMap) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement( + descriptor = descriptor, + index = 0, + serializer = innerSerializer, + value = value.value + .map { (key, value) -> + key.internalWrapForSerialization() to value.internalWrapForSerialization() + } + .toMap(), + ) + } + } + + override fun deserialize(decoder: Decoder): WrappedMap { + val list = decoder + .decodeStructure(descriptor) { + decodeSerializableElement( + descriptor = descriptor, + index = decodeElementIndex(descriptor), + deserializer = innerSerializer, + ) + } + .map { (key, value) -> + key.internalUnwrapForSerialization() to value.internalUnwrapForSerialization() + } + .toMap() + return WrappedMap(list.toMutableMap()) + } + } +} \ No newline at end of file diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/WrappedPrimitive.kt b/enro-common/src/commonMain/kotlin/dev/enro/serialization/WrappedPrimitive.kt new file mode 100644 index 000000000..d444c8577 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/WrappedPrimitive.kt @@ -0,0 +1,47 @@ +package dev.enro.serialization + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class WrappedPrimitive + +@Serializable +@SerialName("WrappedBoolean") +internal class WrappedBoolean(val value: Boolean) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedDouble") +internal class WrappedDouble(val value: Double) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedFloat") +internal class WrappedFloat(val value: Float) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedInt") +internal class WrappedInt(val value: Int) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedLong") +internal class WrappedLong(val value: Long) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedShort") +internal class WrappedShort(val value: Short) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedString") +internal class WrappedString(val value: String) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedByte") +internal class WrappedByte(val value: Byte) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedChar") +internal class WrappedChar(val value: Char) : WrappedPrimitive() + +@Serializable +@SerialName("WrappedNull") +internal data object WrappedNull : WrappedPrimitive() diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/serializerForNavigationKey.kt b/enro-common/src/commonMain/kotlin/dev/enro/serialization/serializerForNavigationKey.kt new file mode 100644 index 000000000..c44fd5655 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/serializerForNavigationKey.kt @@ -0,0 +1,32 @@ +package dev.enro.serialization + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.serializer +import kotlin.reflect.typeOf + +/** + * This exists for the purpose of supporting Parcelable NavigationKeys on Android. + * + * Platforms other than Android will always delegate to defaultSerializerForNavigationKey(). + */ +@AdvancedEnroApi +public expect inline fun serializerForNavigationKey(): KSerializer + +@PublishedApi +internal inline fun defaultSerializerForNavigationKey(): KSerializer { + val it = serializer( + kClass = T::class, + // TODO need to support generic serialization, this probably needs to be done in the + // annotation processor, and the serializer passed as an argument somewhere, + // because using typeof here is likely slow + typeArgumentsSerializers = typeOf().arguments.map { + PolymorphicSerializer(Any::class) + }, + isNullable = false + ) + @Suppress("UNCHECKED_CAST") + return it as KSerializer +} diff --git a/enro-common/src/commonMain/kotlin/dev/enro/serialization/serializerModuleForWrapped.kt b/enro-common/src/commonMain/kotlin/dev/enro/serialization/serializerModuleForWrapped.kt new file mode 100644 index 000000000..a2cc59385 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/serialization/serializerModuleForWrapped.kt @@ -0,0 +1,26 @@ +package dev.enro.serialization + +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +internal val serializerModuleForWrapped = SerializersModule { + polymorphic(Any::class) { + subclass(Unit.serializer()) + subclass(WrappedBoolean.serializer()) + subclass(WrappedDouble.serializer()) + subclass(WrappedFloat.serializer()) + subclass(WrappedInt.serializer()) + subclass(WrappedLong.serializer()) + subclass(WrappedShort.serializer()) + subclass(WrappedString.serializer()) + subclass(WrappedByte.serializer()) + subclass(WrappedChar.serializer()) + subclass(WrappedNull.serializer()) + + subclass(WrappedList.serializer()) + subclass(WrappedSet.serializer()) + subclass(WrappedMap.serializer()) + } +} \ No newline at end of file diff --git a/enro-common/src/desktopMain/kotlin/dev/enro/metadataKeyName.desktop.kt b/enro-common/src/desktopMain/kotlin/dev/enro/metadataKeyName.desktop.kt new file mode 100644 index 000000000..e33ec8575 --- /dev/null +++ b/enro-common/src/desktopMain/kotlin/dev/enro/metadataKeyName.desktop.kt @@ -0,0 +1,9 @@ +package dev.enro + +import kotlin.reflect.KClass + +internal actual fun metadataKeyName(kClass: KClass<*>): String { + return kClass.qualifiedName + ?: kClass.simpleName + ?: error("MetadataKey class must have a qualifiedName or simpleName") +} diff --git a/enro-common/src/desktopMain/kotlin/dev/enro/serialization/serializerForNavigationKey.desktop.kt b/enro-common/src/desktopMain/kotlin/dev/enro/serialization/serializerForNavigationKey.desktop.kt new file mode 100644 index 000000000..3dfd9790c --- /dev/null +++ b/enro-common/src/desktopMain/kotlin/dev/enro/serialization/serializerForNavigationKey.desktop.kt @@ -0,0 +1,12 @@ +package dev.enro.serialization + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer + +@OptIn(ExperimentalSerializationApi::class) +@AdvancedEnroApi +public actual inline fun serializerForNavigationKey(): KSerializer { + return defaultSerializerForNavigationKey() +} \ No newline at end of file diff --git a/enro-common/src/iosMain/kotlin/dev/enro/metadataKeyName.ios.kt b/enro-common/src/iosMain/kotlin/dev/enro/metadataKeyName.ios.kt new file mode 100644 index 000000000..e33ec8575 --- /dev/null +++ b/enro-common/src/iosMain/kotlin/dev/enro/metadataKeyName.ios.kt @@ -0,0 +1,9 @@ +package dev.enro + +import kotlin.reflect.KClass + +internal actual fun metadataKeyName(kClass: KClass<*>): String { + return kClass.qualifiedName + ?: kClass.simpleName + ?: error("MetadataKey class must have a qualifiedName or simpleName") +} diff --git a/enro-common/src/iosMain/kotlin/dev/enro/serialization/serializerForNavigationKey.ios.kt b/enro-common/src/iosMain/kotlin/dev/enro/serialization/serializerForNavigationKey.ios.kt new file mode 100644 index 000000000..3dfd9790c --- /dev/null +++ b/enro-common/src/iosMain/kotlin/dev/enro/serialization/serializerForNavigationKey.ios.kt @@ -0,0 +1,12 @@ +package dev.enro.serialization + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer + +@OptIn(ExperimentalSerializationApi::class) +@AdvancedEnroApi +public actual inline fun serializerForNavigationKey(): KSerializer { + return defaultSerializerForNavigationKey() +} \ No newline at end of file diff --git a/enro-common/src/jsMain/kotlin/dev/enro/metadataKeyName.js.kt b/enro-common/src/jsMain/kotlin/dev/enro/metadataKeyName.js.kt new file mode 100644 index 000000000..958377099 --- /dev/null +++ b/enro-common/src/jsMain/kotlin/dev/enro/metadataKeyName.js.kt @@ -0,0 +1,12 @@ +package dev.enro + +import kotlin.reflect.KClass + +// Kotlin/JS reflection does not expose qualifiedName. simpleName is the +// only stable identifier available — collisions across packages are +// possible in principle, but the recommended `object MyKey : MetadataKey<…>` +// pattern produces a unique simpleName per definition site. +internal actual fun metadataKeyName(kClass: KClass<*>): String { + return kClass.simpleName + ?: error("MetadataKey class must have a simpleName on Kotlin/JS") +} diff --git a/enro-common/src/jsMain/kotlin/dev/enro/serialization/serializerForNavigationKey.js.kt b/enro-common/src/jsMain/kotlin/dev/enro/serialization/serializerForNavigationKey.js.kt new file mode 100644 index 000000000..3a6fcacb6 --- /dev/null +++ b/enro-common/src/jsMain/kotlin/dev/enro/serialization/serializerForNavigationKey.js.kt @@ -0,0 +1,10 @@ +package dev.enro.serialization + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlinx.serialization.KSerializer + +@AdvancedEnroApi +public actual inline fun serializerForNavigationKey(): KSerializer { + return defaultSerializerForNavigationKey() +} \ No newline at end of file diff --git a/enro-common/src/wasmJsMain/kotlin/dev/enro/metadataKeyName.wasmJs.kt b/enro-common/src/wasmJsMain/kotlin/dev/enro/metadataKeyName.wasmJs.kt new file mode 100644 index 000000000..e33ec8575 --- /dev/null +++ b/enro-common/src/wasmJsMain/kotlin/dev/enro/metadataKeyName.wasmJs.kt @@ -0,0 +1,9 @@ +package dev.enro + +import kotlin.reflect.KClass + +internal actual fun metadataKeyName(kClass: KClass<*>): String { + return kClass.qualifiedName + ?: kClass.simpleName + ?: error("MetadataKey class must have a qualifiedName or simpleName") +} diff --git a/enro-common/src/wasmJsMain/kotlin/dev/enro/serialization/serializerForNavigationKey.wasmJs.kt b/enro-common/src/wasmJsMain/kotlin/dev/enro/serialization/serializerForNavigationKey.wasmJs.kt new file mode 100644 index 000000000..3a6fcacb6 --- /dev/null +++ b/enro-common/src/wasmJsMain/kotlin/dev/enro/serialization/serializerForNavigationKey.wasmJs.kt @@ -0,0 +1,10 @@ +package dev.enro.serialization + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlinx.serialization.KSerializer + +@AdvancedEnroApi +public actual inline fun serializerForNavigationKey(): KSerializer { + return defaultSerializerForNavigationKey() +} \ No newline at end of file diff --git a/enro-compat/build.gradle.kts b/enro-compat/build.gradle.kts new file mode 100644 index 000000000..85ae1e7f8 --- /dev/null +++ b/enro-compat/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.google.devtools.ksp") + id("configure-library") + id("configure-publishing") + id("configure-compose") + kotlin("plugin.serialization") +} + +kotlin { + sourceSets { + desktopMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + } + commonMain.dependencies { + api(project(":enro-runtime")) + implementation(libs.compose.viewmodel) + implementation(libs.compose.lifecycle) + implementation(libs.androidx.savedState) + implementation(libs.androidx.savedState.compose) + implementation(libs.kotlinx.serialization) + implementation(libs.kotlin.reflect) + implementation(libs.thauvin.urlencoder) + } + commonTest.dependencies { + implementation(project(":enro-test")) + } + androidMain.dependencies { + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.fragment) + implementation(libs.androidx.fragment.compose) + implementation(libs.androidx.activity) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.lifecycle.process) + implementation(libs.kotlin.reflect) + } + + wasmJsMain.dependencies { + implementation(libs.kotlin.js) + } + } +} \ No newline at end of file diff --git a/enro-compat/consumer-rules.pro b/enro-compat/consumer-rules.pro new file mode 100644 index 000000000..4401170be --- /dev/null +++ b/enro-compat/consumer-rules.pro @@ -0,0 +1,9 @@ +-dontwarn dagger.hilt.** + +-keep class kotlin.LazyKt + +-keep class * extends dev.enro.NavigationKey + +#noinspection ShrinkerUnresolvedReference +-keep @dev.enro.annotations.GeneratedNavigationBinding public class ** +-keep @dev.enro.annotations.GeneratedNavigationComponent public class ** \ No newline at end of file diff --git a/enro-masterdetail/proguard-rules.pro b/enro-compat/proguard-rules.pro similarity index 100% rename from enro-masterdetail/proguard-rules.pro rename to enro-compat/proguard-rules.pro diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/animation/NavigationAnimationOverrideBuilder.kt b/enro-compat/src/androidMain/kotlin/dev/enro/animation/NavigationAnimationOverrideBuilder.kt new file mode 100644 index 000000000..e744f3874 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/animation/NavigationAnimationOverrideBuilder.kt @@ -0,0 +1,130 @@ +package dev.enro.animation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationKey +import kotlin.reflect.KClass + +/** + * @deprecated NavigationAnimationOverrides are no longer supported to be set on containers + * or at a global level. Instead, you should set a [dev.enro.ui.NavigationAnimations] object + * on a NavigationDisplay object directly. + */ +@Deprecated( + message = "NavigationAnimationOverrides are no longer supported to be set on containers or at a global level. Instead, you should set a dev.enro.ui.NavigationAnimations object on a NavigationDisplay object directly.", + level = DeprecationLevel.WARNING +) +public class NavigationAnimationOverrideBuilder { + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public fun addOpeningTransition( + priority: Int, + transition: (exiting: Any?, entering: Any) -> Any? + ) { + } + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public fun addClosingTransition( + priority: Int, + transition: (exiting: Any, entering: Any?) -> Any? + ) { + } + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public fun defaults( + type: KClass, + defaults: Any, + ) { + } + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public inline fun defaults( + defaults: Any + ) { + } + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public fun direction( + direction: NavigationDirection, + animation: Any, + returnAnimation: Any? = null, + ) { + } + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public inline fun transitionTo( + direction: NavigationDirection? = null, + animation: Any, + returnAnimation: Any? = null, + ) { + } + + @Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR + ) + public inline fun transitionBetween( + direction: NavigationDirection? = null, + animation: Any, + returnAnimation: Any? = null, + ) { + } +} + +@Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR +) +public fun NavigationAnimationOverrideBuilder.direction( + direction: NavigationDirection, + entering: EnterTransition, + exiting: ExitTransition, + returnEntering: EnterTransition? = entering, + returnExiting: ExitTransition? = exiting, +) { +} + +@Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR +) +public inline fun NavigationAnimationOverrideBuilder.transitionTo( + direction: NavigationDirection? = null, + entering: EnterTransition, + exiting: ExitTransition, + returnEntering: EnterTransition? = entering, + returnExiting: ExitTransition? = exiting, +) { +} + +@Deprecated( + message = "Please read the deprecation message on NavigationAnimationOverrideBuilder class itself", + level = DeprecationLevel.ERROR +) +public inline fun NavigationAnimationOverrideBuilder.transitionBetween( + direction: NavigationDirection? = null, + entering: EnterTransition, + exiting: ExitTransition, + returnEntering: EnterTransition? = entering, + returnExiting: ExitTransition? = exiting, +) { +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/compat/EnroCompat.kt b/enro-compat/src/androidMain/kotlin/dev/enro/compat/EnroCompat.kt new file mode 100644 index 000000000..89ff73369 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/compat/EnroCompat.kt @@ -0,0 +1,36 @@ +package dev.enro.compat + +import dev.enro.controller.NavigationModule +import dev.enro.controller.createNavigationModule +import dev.enro.core.NavigationDirection +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +/** + * EnroCompat provides compatibility support for applications migrating from Enro 2.x to Enro 3.x APIs. + * + * When this class is found on the classpath at runtime during the instantiation of Enro's Android + * platform module, it will be automatically instantiated and its [compatModule] will be registered + * with the navigation system. + * + * The compatibility module registers functionality that helps bridge the gap between the older + * Enro 2.x APIs and the new Enro 3.x APIs, making it easier for applications to gradually migrate + * their navigation code without breaking existing functionality. + * + * This includes serialization support for legacy navigation directions and other compatibility + * features that ensure smooth interoperability between different versions of the Enro navigation + * framework. + */ +public class EnroCompat { + @JvmField + public val compatModule: NavigationModule = createNavigationModule { + plugin(LegacyNavigationDirectionPlugin) + serializersModule(SerializersModule { + polymorphic(Any::class) { + subclass(NavigationDirection.Push::class) + subclass(NavigationDirection.Present::class) + } + }) + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/compat/LegacyNavigationDirectionPlugin.kt b/enro-compat/src/androidMain/kotlin/dev/enro/compat/LegacyNavigationDirectionPlugin.kt new file mode 100644 index 000000000..78d5078b6 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/compat/LegacyNavigationDirectionPlugin.kt @@ -0,0 +1,20 @@ +package dev.enro.compat + +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.core.NavigationDirection +import dev.enro.plugin.NavigationPlugin +import dev.enro.ui.NavigationDestination +import dev.enro.ui.scenes.DirectOverlaySceneStrategy + +internal object LegacyNavigationDirectionPlugin : NavigationPlugin() { + @AdvancedEnroApi + override fun onDestinationCreated( + destination: NavigationDestination<*>, + additionalMetadata: MutableMap, + ) { + val direction = destination.instance.metadata.get(NavigationDirection.MetadataKey) + if (direction == null) return + if (direction != NavigationDirection.Present) return + additionalMetadata[DirectOverlaySceneStrategy.IsDirectOverlayKey.name] = true + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/Bundle.addOpenInstruction.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/Bundle.addOpenInstruction.kt new file mode 100644 index 000000000..a879fa8e6 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/Bundle.addOpenInstruction.kt @@ -0,0 +1,8 @@ +package dev.enro.core + +import android.os.Bundle +import dev.enro.platform.putNavigationKeyInstance + +public fun Bundle.addOpenInstruction(instruction: AnyOpenInstruction): Bundle { + return this.putNavigationKeyInstance(instruction) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/Bundle.readOpenInstruction.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/Bundle.readOpenInstruction.kt new file mode 100644 index 000000000..f9dfa3459 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/Bundle.readOpenInstruction.kt @@ -0,0 +1,8 @@ +package dev.enro.core + +import android.os.Bundle +import dev.enro.platform.getNavigationKeyInstance + +public fun Bundle.readOpenInstruction(): AnyOpenInstruction? { + return getNavigationKeyInstance() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/Fragment.addOpenInstruction.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/Fragment.addOpenInstruction.kt new file mode 100644 index 000000000..e4f07a8bf --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/Fragment.addOpenInstruction.kt @@ -0,0 +1,12 @@ +package dev.enro.core + +import android.os.Bundle +import androidx.fragment.app.Fragment +import dev.enro.platform.putNavigationKeyInstance + +public fun Fragment.addOpenInstruction(instruction: AnyOpenInstruction): Fragment { + arguments = (arguments ?: Bundle()).apply { + putNavigationKeyInstance(instruction) + } + return this +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/Intent.addOpenInstruction.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/Intent.addOpenInstruction.kt new file mode 100644 index 000000000..06a464305 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/Intent.addOpenInstruction.kt @@ -0,0 +1,8 @@ +package dev.enro.core + +import android.content.Intent +import dev.enro.ui.destinations.putNavigationKeyInstance + +public fun Intent.addOpenInstruction(instruction: AnyOpenInstruction): Intent { + return putNavigationKeyInstance(instruction) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContainerKey.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContainerKey.kt new file mode 100644 index 000000000..2e33b430b --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContainerKey.kt @@ -0,0 +1,5 @@ +package dev.enro.core + +import dev.enro.NavigationContainer + +public typealias NavigationContainerKey = NavigationContainer.Key \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContext.activity.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContext.activity.kt new file mode 100644 index 000000000..0efbfdabe --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContext.activity.kt @@ -0,0 +1,10 @@ +package dev.enro.core + +import androidx.activity.ComponentActivity +import dev.enro.context.AnyNavigationContext +import dev.enro.context.root +import dev.enro.platform.activity as platformActivity + +public val AnyNavigationContext.activity: ComponentActivity get() { + return root().platformActivity +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContext.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContext.kt new file mode 100644 index 000000000..1754e814b --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationContext.kt @@ -0,0 +1,15 @@ +package dev.enro.core + +import androidx.compose.runtime.Composable +import dev.enro.context.activeLeaf +import dev.enro.ui.LocalNavigationContext + +public typealias NavigationContext = dev.enro.context.AnyNavigationContext + +public val navigationContext: NavigationContext<*> + @Composable + get() = LocalNavigationContext.current + +public fun NavigationContext<*>.leafContext(): NavigationContext<*> { + return activeLeaf() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationDirection.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationDirection.kt new file mode 100644 index 000000000..0b702ce57 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationDirection.kt @@ -0,0 +1,64 @@ +package dev.enro.core + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * NavigationDirection was used to control how a destination is displayed when navigated to. + * + * @deprecated Navigation directions are no longer the recommended way to control how destinations + * are displayed. Instead, destinations should define their own display behavior using metadata. + * + * For destinations that should be presented as overlays (equivalent to the old [Present] direction), + * use the `directOverlay` metadata on the destination's definition. + * + * Example of the new approach: + * ``` + * @NavigationDestination(MyOverlayKey::class) + * val myOverlayDestination = navigationDestination( + * metadata = { directOverlay() } + * ) { + * // Destination content + * } + * ``` + * + * This approach gives destinations control over their own display behavior rather than + * requiring the caller to specify it at navigation time. + */ +@Deprecated( + message = "NavigationDirection is deprecated. Destinations should define their own display behavior using metadata. Use directOverlay metadata for overlay/present behavior.", + level = DeprecationLevel.WARNING +) +@Serializable +@Parcelize +public sealed class NavigationDirection: Parcelable { + + /** + * Push direction for standard navigation transitions. + * + * @deprecated Use default navigation without specifying a direction. This is the default behavior. + */ + @Deprecated( + message = "Push direction is no longer needed. This is the default navigation behavior.", + level = DeprecationLevel.WARNING + ) + @Serializable + public data object Push : NavigationDirection() + + /** + * Present direction for overlay/modal presentations. + * + * @deprecated Use [dev.enro.ui.scenes.directOverlay] metadata on the destination's NavigationKey instead. + */ + @Deprecated( + message = "Present direction is deprecated. Use directOverlay metadata on the destination instead.", + level = DeprecationLevel.WARNING + ) + @Serializable + public data object Present : NavigationDirection() + + internal object MetadataKey : dev.enro.NavigationKey.MetadataKey( + default = null, + ) +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationHandle.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationHandle.kt new file mode 100644 index 000000000..82c3796d3 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationHandle.kt @@ -0,0 +1,90 @@ +package dev.enro.core + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.complete +import dev.enro.open +import dev.enro.viewmodel.getNavigationHandle +import dev.enro.withMetadata +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass +import dev.enro.requestClose as realRequestClose +import dev.enro.close as realClose +import dev.enro.navigationHandle as androidNavigationHandle + +public typealias NavigationHandle = dev.enro.NavigationHandle +public typealias TypedNavigationHandle = dev.enro.NavigationHandle + +public val NavigationHandle<*>.instruction: AnyOpenInstruction + get() = this.instance + +public fun dev.enro.NavigationHandle<*>.present(key: dev.enro.core.NavigationKey.SupportsPresent) { + open( + key.withMetadata( + NavigationDirection.MetadataKey, + NavigationDirection.Present, + ) + ) +} + +public fun dev.enro.NavigationHandle<*>.push(key: dev.enro.core.NavigationKey.SupportsPush) { + open( + key.withMetadata( + NavigationDirection.MetadataKey, + NavigationDirection.Push, + ) + ) +} + +public fun dev.enro.NavigationHandle<*>.close() { + realClose() +} + +public fun dev.enro.NavigationHandle<*>.requestClose() { + realRequestClose() +} + +public fun dev.enro.NavigationHandle>.closeWithResult(result: R) { + complete(result) +} + +public inline fun Fragment.navigationHandle() : ReadOnlyProperty> { + return navigationHandle(T::class) +} + +public fun Fragment.navigationHandle( + keyType: KClass +) : ReadOnlyProperty> { + return androidNavigationHandle(keyType) +} + +public inline fun ComponentActivity.navigationHandle() : ReadOnlyProperty> { + return navigationHandle(T::class) +} + +public fun ComponentActivity.navigationHandle( + keyType: KClass +) : ReadOnlyProperty> { + return androidNavigationHandle(keyType) +} + +public fun ViewModelStoreOwner.getNavigationHandle(): NavigationHandle { + return getNavigationHandle(NavigationKey::class) +} + +public fun View.getNavigationHandle(): NavigationHandle? = + findViewTreeViewModelStoreOwner()?.getNavigationHandle() + +public fun View.requireNavigationHandle(): NavigationHandle { + if (!isAttachedToWindow) { + error("$this is not attached to any Window, which is required to retrieve a NavigationHandle") + } + val viewModelStoreOwner = findViewTreeViewModelStoreOwner() + ?: error("Could not find ViewTreeViewModelStoreOwner for $this, which is required to retrieve a NavigationHandle") + return viewModelStoreOwner.getNavigationHandle() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationHandle.onContainer.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationHandle.onContainer.kt new file mode 100644 index 000000000..1e37b8325 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationHandle.onContainer.kt @@ -0,0 +1,28 @@ +package dev.enro.core + +import dev.enro.NavigationBackstack + +public fun dev.enro.NavigationHandle<*>.onContainer( + key: NavigationContainerKey, + block: OnActiveContainerScope.() -> Unit +) { + +} + +public fun dev.enro.NavigationHandle<*>.onParentContainer( + block: OnActiveContainerScope.() -> Unit +) { + +} + +public fun dev.enro.NavigationHandle<*>.onActiveContainer( + block: OnActiveContainerScope.() -> Unit +) { + +} + +public class OnActiveContainerScope( + public val backstack: NavigationBackstack +) { + public fun setBackstack(backstack: NavigationBackstack) {} +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationInstruction.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationInstruction.kt new file mode 100644 index 000000000..3df9ff271 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationInstruction.kt @@ -0,0 +1,58 @@ +@file:Suppress("TYPEALIAS_EXPANSION_DEPRECATION", "DEPRECATION") +package dev.enro.core + +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState + +public typealias AnyOpenInstruction = NavigationInstructionOpen +public typealias OpenPushInstruction = NavigationInstructionOpen +public typealias OpenPresentInstruction = NavigationInstructionOpen + +public typealias NavigationInstructionOpen = dev.enro.NavigationKey.Instance + +@Deprecated("Use dev.enro.NavigationKey.Instance instead") +public object NavigationInstruction { + + public typealias Open = dev.enro.NavigationKey.Instance + + public fun Push(navigationKey: NavigationKey.SupportsPush): NavigationInstructionOpen { + return navigationKey.asPush() + } + + public fun Present(navigationKey: NavigationKey.SupportsPresent): NavigationInstructionOpen { + return navigationKey.asPresent() + } + + public fun Push(navigationKey: dev.enro.NavigationKey.WithMetadata): NavigationInstructionOpen { + return navigationKey.key.asPush().apply { + metadata.setFrom(navigationKey.metadata) + } + } + + public fun Present(navigationKey: dev.enro.NavigationKey.WithMetadata): NavigationInstructionOpen { + return navigationKey.key.asPresent().apply { + metadata.setFrom(navigationKey.metadata) + } + } + + public object Parceler : kotlinx.parcelize.Parceler { + override fun create(parcel: android.os.Parcel): NavigationInstructionOpen { + val savedState = parcel.readBundle(this::class.java.classLoader) ?: throw IllegalArgumentException("Saved state bundle is null") + return decodeFromSavedState(savedState, dev.enro.EnroController.savedStateConfiguration) + } + + override fun NavigationInstructionOpen.write(parcel: android.os.Parcel, flags: Int) { + val savedState = encodeToSavedState(this@write, dev.enro.EnroController.savedStateConfiguration) + parcel.writeBundle(savedState) + } + } +} + +public val AnyOpenInstruction.navigationDirection: NavigationDirection + get() { + return metadata.get(NavigationDirection.MetadataKey) ?: NavigationDirection.Push + } + +internal fun AnyOpenInstruction.setNavigationDirection(navigationDirection: NavigationDirection) { + metadata.set(NavigationDirection.MetadataKey, navigationDirection) +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationKey.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationKey.kt new file mode 100644 index 000000000..9c9310105 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationKey.kt @@ -0,0 +1,55 @@ +package dev.enro.core + +import android.os.Parcelable +import dev.enro.asInstance +import dev.enro.withMetadata + +@Suppress("DEPRECATION") +@Deprecated("Use dev.enro.NavigationKey") +public interface NavigationKey : dev.enro.NavigationKey, Parcelable { + @Deprecated("Use dev.enro.NavigationKey.WithResult") + public interface WithResult : NavigationKey, dev.enro.NavigationKey.WithResult + + @Deprecated("Use dev.enro.NavigationKey.WithMetadata") + public typealias WithExtras = dev.enro.NavigationKey.WithMetadata + + @Deprecated("Use dev.enro.NavigationKey") + public interface SupportsPush : NavigationKey { + @Deprecated("Use dev.enro.NavigationKey.WithResult") + public interface WithResult : SupportsPush, NavigationKey.WithResult + } + + @Deprecated("Use dev.enro.NavigationKey") + public interface SupportsPresent : NavigationKey { + @Deprecated("Use dev.enro.NavigationKey.WithResult") + public interface WithResult : SupportsPresent, NavigationKey.WithResult + } +} + +public fun T.asPush(): dev.enro.NavigationKey.Instance { + return withMetadata( + NavigationDirection.MetadataKey, + NavigationDirection.Push, + ).asInstance() +} + +public fun T.asPresent(): dev.enro.NavigationKey.Instance { + return withMetadata( + NavigationDirection.MetadataKey, + NavigationDirection.Present, + ).asInstance() +} + +public fun T.withExtra( + key: dev.enro.NavigationKey.MetadataKey, + value: M, +): dev.enro.NavigationKey.WithMetadata { + return withMetadata(key, value) +} + +public fun dev.enro.NavigationKey.WithMetadata.withExtra( + key: dev.enro.NavigationKey.MetadataKey, + value: M, +): dev.enro.NavigationKey.WithMetadata { + return withMetadata(key, value) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/activity/ActivityDestination.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/activity/ActivityDestination.kt new file mode 100644 index 000000000..e134fe697 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/activity/ActivityDestination.kt @@ -0,0 +1,140 @@ +package dev.enro.core.activity + +import android.content.Context +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import dev.enro.NavigationOperation +import dev.enro.core.* +import dev.enro.core.compose.navigationHandle +import dev.enro.core.result.AdvancedResultExtensions +import dev.enro.core.synthetic.syntheticDestination +import dev.enro.ui.NavigationDestinationProvider +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler +import kotlin.reflect.KClass + + +public class ActivityResultParameters internal constructor( + internal val contract: ActivityResultContract, + internal val input: I, + internal val result: (O) -> R +) + +public fun ActivityResultContract.withInput(input: I): ActivityResultParameters = + ActivityResultParameters( + contract = this, + input = input, + result = { it } + ) + +public fun ActivityResultParameters.withMappedResult(block: (O) -> R): ActivityResultParameters = + ActivityResultParameters( + contract = contract, + input = input, + result = block + ) + +public class ActivityResultDestinationScope> +internal constructor( + public val key: T, + public val instruction: NavigationInstructionOpen, + public val context: Context, + public val activity: ComponentActivity, +) + +@dev.enro.annotations.ExperimentalEnroApi +public fun > activityResultDestination( + @Suppress("UNUSED_PARAMETER") // used to infer types + keyType: KClass, + block: ActivityResultDestinationScope.() -> ActivityResultParameters<*, *, R> +): NavigationDestinationProvider = syntheticDestination { + val scope = ActivityResultDestinationScope( + key = key, + instruction = instruction, + context = navigationContext.activity, + activity = navigationContext.activity, + ) + val parameters = scope.block() as ActivityResultParameters + + val pendingResult = instruction.metadata.get(PendingActivityResultKey) + if (pendingResult != null) { + val parsedResult = parameters.contract.parseResult(pendingResult.resultCode, pendingResult.data) + val mappedResult = parsedResult?.let { parameters.result(it) } + when (mappedResult) { + null -> AdvancedResultExtensions.setClosedResultForInstruction( + navigationController = navigationContext.controller, + instruction = instruction, + ) + else -> AdvancedResultExtensions.setResultForInstruction( + navigationController = navigationContext.controller, + instruction = instruction, + result = mappedResult, + ) + } + return@syntheticDestination + } + + val synchronousResult = parameters.contract.getSynchronousResult(navigationContext.activity, parameters.input) + if (synchronousResult != null) { + val mappedResult = synchronousResult.value?.let { parameters.result(it) } + if (mappedResult != null) { + AdvancedResultExtensions.setResultForInstruction( + navigationController = navigationContext.controller, + instruction = instruction, + result = mappedResult, + ) + return@syntheticDestination + } + } + + navigationContext + .getNavigationHandle() + .present( + ActivityResultDestination( + wrapped = instruction, + intent = parameters.contract.createIntent(navigationContext.activity, parameters.input), + ) + ) +} + +@PublishedApi +internal object PendingActivityResultKey: dev.enro.NavigationKey.MetadataKey(default = null) + +@Parcelize +internal class ActivityResultDestination( + @TypeParceler val wrapped: NavigationInstructionOpen, + val intent: Intent, +) : NavigationKey.SupportsPresent + +@Composable +internal fun ActivityResultBridge() { + val navigation = navigationHandle() + val launched = rememberSaveable { mutableStateOf(false) } + val resultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + navigation.execute( + NavigationOperation.Open( + navigation.key.wrapped.apply { + metadata.set(PendingActivityResultKey, result) + } + ) + + ) + navigation.close() + } + LaunchedEffect(Unit) { + if (launched.value) return@LaunchedEffect + resultLauncher.launch(navigation.key.intent) + launched.value = true + } + // No content +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/asTyped.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/asTyped.kt new file mode 100644 index 000000000..0300cdf72 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/asTyped.kt @@ -0,0 +1,9 @@ +package dev.enro.core + +import dev.enro.NavigationKey + +public inline fun NavigationHandle.asTyped(): TypedNavigationHandle { + require(T::class.isInstance(key)) + @Suppress("UNCHECKED_CAST") + return this as TypedNavigationHandle +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/BottomSheetDestination.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/BottomSheetDestination.kt new file mode 100644 index 000000000..f0edc4c4c --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/BottomSheetDestination.kt @@ -0,0 +1,104 @@ +package dev.enro.core.compose.dialog + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.SwipeableDefaults +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.core.close +import dev.enro.navigationHandle +import dev.enro.ui.LocalNavigationContainer +import kotlinx.coroutines.isActive + +@Composable +@AdvancedEnroApi +public fun ModalBottomSheetState.bindToNavigationHandle(): ModalBottomSheetState { + val navigationHandle = navigationHandle() + + val parent = requireNotNull(LocalNavigationContainer.current) { + "Failed to bind ModalBottomSheetState to NavigationHandle: parentContainer was not found" + } + val backstack = parent.backstack + val isInBackstack by remember { + derivedStateOf { backstack.any { it.id == navigationHandle.instance.id } } + } + val isActive by remember { + derivedStateOf { backstack.lastOrNull()?.id == navigationHandle.instance.id } + } + var isInitialised by remember { + mutableStateOf(false) + } + + LaunchedEffect(isInBackstack, isInitialised, isActive, isVisible) { + when { + !isInitialised -> { + // In some cases, full screen dialogs and other things that don't necessarily render immediately + // can cause the show animation to be cancelled, so when we're initialising, we're going to + // force the show by looping until isVisible is true + while(!isVisible && this@LaunchedEffect.isActive) { runCatching { show() } } + isInitialised = true + } + isActive -> if(!isVisible) { + navigationHandle.close() + if (isActive) show() + } + isInBackstack -> if (isVisible) hide() + else -> hide() + } + } + return this +} + +@Composable +@ExperimentalMaterialApi +public fun BottomSheetDestination( + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true }, + skipHalfExpanded: Boolean = false, + content: @Composable (ModalBottomSheetState) -> Unit, +) { + val navigationHandle = navigationHandle() + val container = requireNotNull(LocalNavigationContainer.current) { + "Failed to render BottomSheetDestination: parentContainer was not found" + } + val backstack = container.backstack + val isActive = remember { derivedStateOf { backstack.lastOrNull()?.id == navigationHandle.instance.id } } + var hasBeenDisplayed by rememberSaveable { mutableStateOf(false) } + + val bottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + animationSpec = animationSpec, + confirmValueChange = remember(Unit) { + fun(it: ModalBottomSheetValue): Boolean { + val isHiding = it == ModalBottomSheetValue.Hidden + if (isHiding && !hasBeenDisplayed) return false + return when { + !confirmValueChange(it) -> false + isHiding && isActive.value -> { + navigationHandle.close() + !isActive.value + } + else -> true + } + } + }, + skipHalfExpanded = skipHalfExpanded, + ).bindToNavigationHandle() + + SideEffect { + hasBeenDisplayed = hasBeenDisplayed || bottomSheetState.isVisible + } + + content(bottomSheetState) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/OverrideNavigationAnimations.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/OverrideNavigationAnimations.kt new file mode 100644 index 000000000..f54bb9254 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/OverrideNavigationAnimations.kt @@ -0,0 +1,34 @@ +package dev.enro.core.compose + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.ui.LocalNavigationAnimatedVisibilityScope + + +@Composable +@AdvancedEnroApi +@Deprecated( + message = "OverrideNavigationAnimations is now a no-op and doesn't actually do anything. Navigation overrides should be set on the destination or on the container itself.", + level = DeprecationLevel.WARNING +) +public fun OverrideNavigationAnimations( + enter: EnterTransition, + exit: ExitTransition, +) {} + +@Composable +@AdvancedEnroApi +@Deprecated( + message = "OverrideNavigationAnimations is now a no-op and doesn't actually do anything. Navigation overrides should be set on the destination or on the container itself.", + level = DeprecationLevel.WARNING +) +public fun OverrideNavigationAnimations( + enter: EnterTransition, + exit: ExitTransition, + content: @Composable AnimatedVisibilityScope.() -> Unit +) { + LocalNavigationAnimatedVisibilityScope.current.content() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/configure.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/configure.kt new file mode 100644 index 000000000..5899081a3 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/configure.kt @@ -0,0 +1,17 @@ +package dev.enro.core.compose + +import androidx.compose.runtime.Composable +import dev.enro.NavigationHandle +import dev.enro.NavigationHandleConfiguration +import dev.enro.NavigationKey +import dev.enro.configure as realConfigure + +@Composable +public inline fun NavigationHandle.configure( + crossinline block: NavigationHandleConfiguration.() -> Unit +) : NavigationHandle { + realConfigure { + block() + } + return this +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/container/ComposableNavigationContainer.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/container/ComposableNavigationContainer.kt new file mode 100644 index 000000000..20a536e7f --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/container/ComposableNavigationContainer.kt @@ -0,0 +1,25 @@ +package dev.enro.core.compose.container + +import androidx.compose.runtime.Composable +import dev.enro.ui.NavigationContainerState +import dev.enro.ui.NavigationDisplay + +/** + * Renders the navigation container. + * + * @deprecated Use NavigationDisplay instead, which allows configuration of additional options + * such as animations and modifiers. The modifier parameter in NavigationDisplay specifically + * removes the need for the common pattern of wrapping container.Render() invocations in a + * Box(modifier = Modifier). + */ +@Deprecated( + message = "Use NavigationDisplay instead for more configuration options", + replaceWith = ReplaceWith( + expression = "NavigationDisplay(this)", + imports = ["dev.enro.ui.NavigationDisplay"] + ) +) +@Composable +public fun NavigationContainerState.Render() { + NavigationDisplay(this) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/container/rememberNavigationContainerGroup.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/container/rememberNavigationContainerGroup.kt new file mode 100644 index 000000000..aae2ba9df --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/container/rememberNavigationContainerGroup.kt @@ -0,0 +1,64 @@ +package dev.enro.core.compose.container + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import dev.enro.context.ContainerContext +import dev.enro.ui.LocalNavigationContext +import dev.enro.ui.NavigationContainerState + + +public class NavigationContainerGroup( + private val activeContainerState: MutableState, + public val containers: List, +) { + public val activeContainer: NavigationContainerState by activeContainerState + + public fun setActive(container: NavigationContainerState) { + activeContainerState.value = container + container.context.requestActive() + } +} + +@Composable +public fun rememberNavigationContainerGroup( + vararg containers: NavigationContainerState, +): NavigationContainerGroup { + val containerReference = containers.map { it.container } + val activeContainer = rememberSaveable( + containerReference, + saver = object : Saver, String> { + override fun restore(value: String): MutableState? { + return containers.firstOrNull { it.container.key.name == value } + ?.let { mutableStateOf(it) } + } + + override fun SaverScope.save(value: MutableState): String? { + return value.value.container.key.name + } + } + ) { + mutableStateOf(containers.first()) + } + val group = remember(containerReference) { + NavigationContainerGroup( + activeContainer, + containers.toList(), + ) + } + val locallyActiveChild = LocalNavigationContext.current.activeChild + LaunchedEffect(locallyActiveChild) { + if (locallyActiveChild !is ContainerContext) return@LaunchedEffect + if (locallyActiveChild != group.activeContainer.context) { + containers.firstOrNull { it.context == locallyActiveChild } + ?.let { group.setActive(it) } + } + } + return group +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/dialog/DialogDestination.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/dialog/DialogDestination.kt new file mode 100644 index 000000000..2cea18761 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/dialog/DialogDestination.kt @@ -0,0 +1,10 @@ +package dev.enro.core.compose.dialog + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.runtime.Composable +import dev.enro.ui.LocalNavigationAnimatedVisibilityScope + +@Composable +public fun DialogDestination(content: @Composable AnimatedVisibilityScope.() -> Unit) { + content(LocalNavigationAnimatedVisibilityScope.current) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/navigationHandle.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/navigationHandle.kt new file mode 100644 index 000000000..dd4a220c9 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/navigationHandle.kt @@ -0,0 +1,16 @@ +package dev.enro.core.compose + +import androidx.compose.runtime.Composable +import dev.enro.NavigationHandle +import dev.enro.NavigationKey + +@Composable +public fun navigationHandle(): NavigationHandle { + return dev.enro.navigationHandle() +} + +@JvmName("typedNavigationHandle") +@Composable +public inline fun navigationHandle(): NavigationHandle { + return dev.enro.navigationHandle() +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/preview/EnroPreview.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/preview/EnroPreview.kt new file mode 100644 index 000000000..3a3715f54 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/preview/EnroPreview.kt @@ -0,0 +1,12 @@ +package dev.enro.core.compose.preview + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey + +@Composable +public fun EnroPreview( + navigationKey: NavigationKey, + content: @Composable () -> Unit, +) { + // TODO +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/registerForNavigationResult.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/registerForNavigationResult.kt new file mode 100644 index 000000000..44cf46f68 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/registerForNavigationResult.kt @@ -0,0 +1,21 @@ +package dev.enro.core.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.enro.NavigationKey +import dev.enro.core.result.NavigationResultChannel +import dev.enro.result.NavigationResultScope + +@Composable +public inline fun registerForNavigationResult( + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onResult: NavigationResultScope>.(R) -> Unit, +): NavigationResultChannel { + val channel = dev.enro.result.registerForNavigationResult( + onClosed = onClosed, + onCompleted = onResult, + ) + return remember(channel) { + NavigationResultChannel(channel) + } +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/rememberNavigationContainer.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/rememberNavigationContainer.kt new file mode 100644 index 000000000..1d4e97a08 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/rememberNavigationContainer.kt @@ -0,0 +1,162 @@ +package dev.enro.core.compose + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.currentCompositeKeyHashCode +import androidx.compose.runtime.saveable.rememberSaveable +import dev.enro.NavigationBackstack +import dev.enro.NavigationContainer +import dev.enro.NavigationOperation +import dev.enro.animation.NavigationAnimationOverrideBuilder +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.asBackstack +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext +import dev.enro.core.asPush +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.NavigationInstructionFilter +import dev.enro.core.container.acceptAll +import dev.enro.core.container.backstackOf +import dev.enro.interceptor.builder.NavigationInterceptorBuilder +import dev.enro.interceptor.builder.navigationInterceptor +import dev.enro.ui.LocalNavigationContext +import dev.enro.ui.NavigationContainerState +import kotlin.uuid.Uuid +import dev.enro.ui.EmptyBehavior as NewEmptyBehavior +import dev.enro.ui.rememberNavigationContainer as newRememberNavigationContainer + + +@Composable +public fun rememberNavigationContainer( + key: NavigationContainer.Key = rememberSaveable(saver = NavigationContainer.Key.Saver) { + NavigationContainer.Key("NavigationContainer@${Uuid.random()}") + }, + root: dev.enro.core.NavigationKey.SupportsPush, + emptyBehavior: EmptyBehavior, + interceptor: NavigationInterceptorBuilder.() -> Unit = {}, + animations: NavigationAnimationOverrideBuilder.() -> Unit = {}, + filter: NavigationInstructionFilter = acceptAll(), +): NavigationContainerState { + return rememberNavigationContainer( + key = key, + initialBackstack = backstackOf(root.asPush()), + emptyBehavior = emptyBehavior, + interceptor = interceptor, + animations = animations, + filter = filter, + ) +} + +@Composable +public fun rememberNavigationContainer( + key: NavigationContainer.Key = rememberSaveable(saver = NavigationContainer.Key.Saver) { + NavigationContainer.Key("NavigationContainer@${Uuid.random()}") + }, + initialBackstack: List = emptyList(), + emptyBehavior: EmptyBehavior, + interceptor: NavigationInterceptorBuilder.() -> Unit = {}, + animations: NavigationAnimationOverrideBuilder.() -> Unit = {}, + filter: NavigationInstructionFilter = acceptAll(), +): NavigationContainerState { + return rememberNavigationContainer( + key = key, + initialBackstack = initialBackstack.map { + it.asPush() + }.asBackstack(), + emptyBehavior = emptyBehavior, + interceptor = interceptor, + animations = animations, + filter = filter, + ) +} + +@Composable +@AdvancedEnroApi +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +@JvmName("rememberNavigationContainerWithBackstack") +public fun rememberNavigationContainer( + key: NavigationContainer.Key = rememberSaveable(saver = NavigationContainer.Key.Saver) { + NavigationContainer.Key("NavigationContainer@${Uuid.random()}") + }, + initialBackstack: NavigationBackstack, + emptyBehavior: EmptyBehavior, + interceptor: NavigationInterceptorBuilder.() -> Unit = {}, + animations: NavigationAnimationOverrideBuilder.() -> Unit = {}, + filter: NavigationInstructionFilter = acceptAll(), +): NavigationContainerState { + val parentContext = LocalNavigationContext.current + return newRememberNavigationContainer( + key = key, + backstack = initialBackstack, + emptyBehavior = when (emptyBehavior) { + is EmptyBehavior.Action -> NewEmptyBehavior( + isBackHandlerEnabled = { true }, + onPredictiveBackProgress = { true }, + onEmpty = { + val keepActive = emptyBehavior.onEmpty() + return@NewEmptyBehavior when(keepActive) { + true -> denyEmpty() + else -> allowEmpty() + } + } + ) + + EmptyBehavior.AllowEmpty -> NewEmptyBehavior.allowEmpty() + EmptyBehavior.CloseParent -> NewEmptyBehavior( + isBackHandlerEnabled = { true }, + onPredictiveBackProgress = { true }, + onEmpty = { + denyEmptyAnd { + when (parentContext) { + is ContainerContext -> { + val parentContainer = parentContext.container + parentContainer.backstack.lastOrNull()?.let { + parentContainer.execute(parentContext, NavigationOperation.Close(it)) + } + } + is DestinationContext<*> -> { + val parentContainer = parentContext.parent.container + parentContainer.backstack.lastOrNull()?.let { + parentContainer.execute(parentContext, NavigationOperation.Close(it)) + } + } + is RootContext -> { + (parentContext.parent as? Activity)?.finish() + } + } + } + } + ) + + EmptyBehavior.ForceCloseParent -> NewEmptyBehavior( + isBackHandlerEnabled = { true }, + onPredictiveBackProgress = { true }, + onEmpty = { + denyEmptyAnd { + when (parentContext) { + is ContainerContext -> { + val parentContainer = parentContext.container + parentContainer.backstack.lastOrNull()?.let { + parentContainer.execute(parentContext, NavigationOperation.Close(it)) + } + } + is DestinationContext<*> -> { + val parentContainer = parentContext.parent.container + parentContainer.backstack.lastOrNull()?.let { + parentContainer.execute(parentContext, NavigationOperation.Close(it)) + } + } + is RootContext -> { + (parentContext.parent as? Activity)?.finish() + } + } + } + } + ) + }, + interceptor = navigationInterceptor(interceptor), + filter = filter, + ) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/container.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/container.kt new file mode 100644 index 000000000..87bce6e81 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/container.kt @@ -0,0 +1,31 @@ +package dev.enro.core.container + +import dev.enro.NavigationBackstack +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.asBackstack +import dev.enro.asInstance +import dev.enro.ui.NavigationContainerState + +public fun backstackOf(vararg elements: NavigationKey.Instance): NavigationBackstack { + return elements.toList().asBackstack() +} + +public fun emptyBackstack(): NavigationBackstack = backstackOf() + +public fun NavigationContainerState.setBackstack(backstack: NavigationBackstack) { + setBackstack { backstack } +} + +public fun NavigationContainerState.setBackstack(block: (NavigationBackstack) -> NavigationBackstack) { + execute( + operation = NavigationOperation.SetBackstack( + currentBackstack = container.backstack, + targetBackstack = block(container.backstack), + ) + ) +} + +public fun NavigationBackstack.push(key: NavigationKey) : NavigationBackstack { + return (this + key.asInstance()).asBackstack() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/container/EmptyBehavior.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/EmptyBehavior.kt new file mode 100644 index 000000000..4442c349a --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/EmptyBehavior.kt @@ -0,0 +1,40 @@ +package dev.enro.core.container + +public sealed class EmptyBehavior { + /** + * When this container is about to become empty, allow this container to become empty + */ + public data object AllowEmpty : EmptyBehavior() + + /** + * When this container is about to become empty, do not close the NavigationDestination in the + * container, but instead request a close of the parent NavigationDestination (i.e. the owner of this container) + * + * This calls "requestClose" on the parent, not "close", so that the parent has an opportunity to + * intercept the close functionality. If you want to *force* the parent container to close, and + * not allow the parent container to intercept the close request, use [ForceCloseParent] instead. + */ + public data object CloseParent : EmptyBehavior() + + /** + * When this container is about to become empty, do not close the NavigationDestination in the + * container, but instead force the parent NavigationDestination to close (i.e. the owner of this container). + * + * This calls "close" on the parent, rather than request close, so that the parent has no opportunity to + * intercept the close with onCloseRequested. If you want to allow the parent container to be able + * to intercept the close request, use [CloseParent] instead. + */ + public data object ForceCloseParent : EmptyBehavior() + + /** + * When this container is about to become empty, execute an action. If the result of the action function is + * "true", then the action is considered to have consumed the request to become empty, and the container + * will not close the last navigation destination. When the action function returns "false", the default + * behaviour will happen, and the container will become empty. + * + * @returns true to keep the destination in the container, false to allow the container to become empty + */ + public class Action( + public val onEmpty: () -> Boolean + ) : EmptyBehavior() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationBackstack.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationBackstack.kt new file mode 100644 index 000000000..5d97f78be --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationBackstack.kt @@ -0,0 +1,11 @@ +package dev.enro.core.container + +import dev.enro.NavigationKey +import dev.enro.asBackstack + +@Deprecated("This function just returns the input list, NavigationBackstack is a type alias for the list type that is passed as a parameter, you should remove the function call and just reference the list directly.") +public fun NavigationBackstack( + list: List> +): dev.enro.NavigationBackstack { + return list.asBackstack() +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationContainer.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationContainer.kt new file mode 100644 index 000000000..979aaf9f9 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationContainer.kt @@ -0,0 +1,5 @@ +package dev.enro.core.container + +import dev.enro.ui.NavigationContainerState + +public typealias NavigationContainer = NavigationContainerState \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationInstructionFilter.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationInstructionFilter.kt new file mode 100644 index 000000000..65ffa7a7d --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/NavigationInstructionFilter.kt @@ -0,0 +1,21 @@ +package dev.enro.core.container + +import dev.enro.NavigationContainerFilter +import dev.enro.NavigationContainerFilterBuilder +import dev.enro.doNotAccept + +public typealias NavigationInstructionFilter = NavigationContainerFilter +public typealias NavigationInstructionFilterBuilder = NavigationContainerFilterBuilder + +public fun acceptAll(): NavigationContainerFilter = + dev.enro.acceptAll() + +public fun acceptNone(): NavigationContainerFilter = + dev.enro.acceptNone() + + +public fun accept(block: NavigationContainerFilterBuilder.() -> Unit): NavigationContainerFilter = + dev.enro.accept(block) + +public fun doNotAccept(block: NavigationContainerFilterBuilder.() -> Unit): NavigationContainerFilter = + doNotAccept(block) \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/container/toBackstack.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/toBackstack.kt new file mode 100644 index 000000000..2ddde98c6 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/container/toBackstack.kt @@ -0,0 +1,7 @@ +package dev.enro.core.container + +import dev.enro.NavigationBackstack +import dev.enro.NavigationKey +import dev.enro.asBackstack + +public fun List>.toBackstack(): NavigationBackstack = this.asBackstack() \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/containerManager.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/containerManager.kt new file mode 100644 index 000000000..6eef8b291 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/containerManager.kt @@ -0,0 +1,16 @@ +package dev.enro.core + +import androidx.activity.ComponentActivity +import dev.enro.context.ContainerContext +import dev.enro.context.NavigationContext +import dev.enro.platform.navigationContext + +public val ComponentActivity.containerManager: ContainerManager get() { + return ContainerManager(navigationContext) +} + +public class ContainerManager( + private val context: NavigationContext.WithContainerChildren<*> +) { + public val containers: List get() = context.children +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationController.application.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationController.application.kt new file mode 100644 index 000000000..b9625b0e4 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationController.application.kt @@ -0,0 +1,11 @@ +package dev.enro.core.controller + +import android.app.Application +import dev.enro.platform.enroController + +@Deprecated( + message = "Application.navigationController has been renamed to Application.enroController. Please use enroController instead.", + replaceWith = ReplaceWith("enroController", "dev.enro.platform.enroController"), + level = DeprecationLevel.WARNING +) +public val Application.navigationController: NavigationController get() = enroController diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationController.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationController.kt new file mode 100644 index 000000000..2fa41e586 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationController.kt @@ -0,0 +1,10 @@ +package dev.enro.core.controller + +import dev.enro.EnroController + +@Deprecated( + message = "NavigationController has been renamed to EnroController. Please use EnroController instead.", + replaceWith = ReplaceWith("EnroController", "dev.enro.EnroController"), + level = DeprecationLevel.WARNING +) +public typealias NavigationController = EnroController \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationModule.composeEnvironment.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationModule.composeEnvironment.kt new file mode 100644 index 000000000..df8a68bba --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/NavigationModule.composeEnvironment.kt @@ -0,0 +1,17 @@ +package dev.enro.core.controller + +import androidx.compose.runtime.Composable +import dev.enro.controller.NavigationModule +import dev.enro.ui.decorators.navigationDestinationDecorator + +public fun NavigationModule.BuilderScope.composeEnvironment( + block: @Composable (content: @Composable () -> Unit) -> Unit +) { + decorator { + navigationDestinationDecorator { destination -> + block { + destination.Content() + } + } + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/createNavigationModule.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/createNavigationModule.kt new file mode 100644 index 000000000..ad2ed2aee --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/controller/createNavigationModule.kt @@ -0,0 +1,7 @@ +package dev.enro.core.controller + +import dev.enro.controller.NavigationModule + +public fun createNavigationModule(block: NavigationModule.BuilderScope.() -> Unit): NavigationModule { + return dev.enro.controller.createNavigationModule(block) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/onContainer.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/onContainer.kt new file mode 100644 index 000000000..6888dcda2 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/onContainer.kt @@ -0,0 +1,11 @@ +package dev.enro.core + +import dev.enro.NavigationHandle + +@Deprecated(""" + onContainer actions are no longer supported. Instead of using onContainer, you should instead + define a synthetic destination that performs the same action. +""", level = DeprecationLevel.ERROR) +public fun NavigationHandle<*>.onContainer() { + error("") +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/parentContainer.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/parentContainer.kt new file mode 100644 index 000000000..9f6bb9db3 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/parentContainer.kt @@ -0,0 +1,9 @@ +package dev.enro.core + +import androidx.compose.runtime.Composable +import dev.enro.ui.LocalNavigationContainer +import dev.enro.ui.NavigationContainerState + +public val parentContainer: NavigationContainerState? + @Composable + get() = LocalNavigationContainer.currentOrNull diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/plugins/EnroPlugin.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/plugins/EnroPlugin.kt new file mode 100644 index 000000000..52f28d7ab --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/plugins/EnroPlugin.kt @@ -0,0 +1,5 @@ +package dev.enro.core.plugins + +import dev.enro.plugin.NavigationPlugin + +public typealias EnroPlugin = NavigationPlugin \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result.kt new file mode 100644 index 000000000..6979478bd --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result.kt @@ -0,0 +1,46 @@ +package dev.enro.core.result + +import androidx.fragment.app.Fragment +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.core.asPresent +import dev.enro.core.asPush +import dev.enro.result.NavigationResultScope +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass +import dev.enro.result.registerForNavigationResult as fragmentRegisterForNavigationResult + +public fun NavigationHandle>.deliverResultFromPush( + key: dev.enro.core.NavigationKey.SupportsPush.WithResult, +) { + execute(NavigationOperation.CompleteFrom(instance, key.asPush())) +} + +public fun NavigationHandle>.deliverResultFromPresent( + key: dev.enro.core.NavigationKey.SupportsPresent.WithResult +) { + execute(NavigationOperation.CompleteFrom(instance, key.asPresent())) +} + +public inline fun Fragment.registerForNavigationResult( + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onCompleted: NavigationResultScope>.(R) -> Unit, +) : ReadOnlyProperty> { + return registerForNavigationResult(R::class, onClosed, onCompleted) +} + + +public fun Fragment.registerForNavigationResult( + resultType: KClass, + onClosed: NavigationResultScope>.() -> Unit = {}, + onCompleted: NavigationResultScope>.(R) -> Unit, +) : ReadOnlyProperty> { + val channel = fragmentRegisterForNavigationResult(resultType, onClosed, onCompleted) + return ReadOnlyProperty { fragment, prop -> + NavigationResultChannel( + channel.provideDelegate(fragment, prop) + .getValue(fragment, prop) + ) + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/AdvancedResultExtensions.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/AdvancedResultExtensions.kt new file mode 100644 index 000000000..e208ba086 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/AdvancedResultExtensions.kt @@ -0,0 +1,64 @@ +package dev.enro.core.result + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.asInstance +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationInstructionOpen +import dev.enro.core.controller.NavigationController +import dev.enro.core.setNavigationDirection +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel + +@AdvancedEnroApi +public object AdvancedResultExtensions { + + @AdvancedEnroApi + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + public fun getForwardingInstructionId(instruction: NavigationInstructionOpen): String? { + val resultId = instruction.metadata.get(NavigationResultChannel.ResultIdKey) + return resultId?.ownerId?.takeIf { it != instruction.id } + } + + @AdvancedEnroApi + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + public fun getInstructionToForwardResult( + originalInstruction: NavigationInstructionOpen, + direction: T, + navigationKey: NavigationKey.WithResult<*>, + ): NavigationInstructionOpen { + val originalResultId = originalInstruction.metadata.get(NavigationResultChannel.ResultIdKey) + val instruction = navigationKey.asInstance() + instruction.setNavigationDirection(direction) + instruction.metadata.set(NavigationResultChannel.ResultIdKey, originalResultId) + return instruction + } + + @AdvancedEnroApi + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + public fun setResultForInstruction( + navigationController: NavigationController, + instruction: NavigationInstructionOpen, + result: T + ) { + NavigationResultChannel.registerResult( + NavigationResult.Completed( + instruction, + result + ) + ) + } + + @AdvancedEnroApi + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + public fun setClosedResultForInstruction( + navigationController: NavigationController, + instruction: NavigationInstructionOpen, + ) { + NavigationResultChannel.registerResult( + NavigationResult.Closed( + instruction, + ) + ) + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/NavigationResultChannel.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/NavigationResultChannel.kt new file mode 100644 index 000000000..65a02b881 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/NavigationResultChannel.kt @@ -0,0 +1,35 @@ +package dev.enro.core.result + +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationKey +import dev.enro.result.open +import dev.enro.withMetadata + +@Deprecated("Use dev.enro.result.NavigationResultChannel instead") +public class NavigationResultChannel( + private val wrapped: dev.enro.result.NavigationResultChannel +) { + public fun push(key: NavigationKey.SupportsPush.WithResult) { + wrapped.open( + key.withMetadata(NavigationDirection.MetadataKey, NavigationDirection.Push) + ) + } + + public fun push(key: dev.enro.NavigationKey.WithMetadata>) { + wrapped.open( + key.withMetadata(NavigationDirection.MetadataKey, NavigationDirection.Push) + ) + } + + public fun present(key: NavigationKey.SupportsPresent.WithResult) { + wrapped.open( + key.withMetadata(NavigationDirection.MetadataKey, NavigationDirection.Present) + ) + } + + public fun present(key: dev.enro.NavigationKey.WithMetadata>) { + wrapped.open( + key.withMetadata(NavigationDirection.MetadataKey, NavigationDirection.Present) + ) + } +} diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/SyntheticDestinationScope.sendResult.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/SyntheticDestinationScope.sendResult.kt new file mode 100644 index 000000000..582d8b4fa --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/SyntheticDestinationScope.sendResult.kt @@ -0,0 +1,34 @@ +package dev.enro.core.result + +import dev.enro.NavigationKey +import dev.enro.core.NavigationDirection +import dev.enro.ui.destinations.SyntheticDestinationScope +import dev.enro.ui.destinations.complete +import dev.enro.ui.destinations.completeFrom + +@Deprecated( + message = "Use SyntheticDestinationScope.complete(result) instead. The direction concept from Enro 2 is no longer relevant; the new method registers the same Completed result against the synthetic's result channel.", + replaceWith = ReplaceWith( + expression = "complete(result)", + imports = ["dev.enro.ui.destinations.complete"], + ), +) +public fun SyntheticDestinationScope>.sendResult( + result: T, +): Nothing = complete(result) + +@Deprecated( + message = "Use SyntheticDestinationScope.completeFrom(navigationKey) instead. The direction parameter is no longer used in Enro 3 — push/present semantics are now expressed elsewhere.", + replaceWith = ReplaceWith( + expression = "completeFrom(navigationKey)", + imports = ["dev.enro.ui.destinations.completeFrom"], + ), +) +@Suppress("UNUSED_PARAMETER") +public fun SyntheticDestinationScope>.forwardResult( + navigationKey: NavigationKey.WithResult, + direction: NavigationDirection = when (navigationKey) { + is dev.enro.core.NavigationKey.SupportsPresent -> NavigationDirection.Present + else -> NavigationDirection.Push + }, +): Nothing = completeFrom(navigationKey) diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/FlowStepBuilderScope.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/FlowStepBuilderScope.kt new file mode 100644 index 000000000..c6ea19daa --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/FlowStepBuilderScope.kt @@ -0,0 +1,73 @@ +package dev.enro.core.result.flows + +import dev.enro.result.flow.FlowStepOptions + +@Deprecated("This class is no longer used and will be removed in a future release.") +public class FlowStepBuilderScope @PublishedApi internal constructor() { + @PublishedApi + internal var defaultResult: T? = null + @PublishedApi + internal val dependencies: MutableList = mutableListOf() + @PublishedApi + internal val configuration: MutableSet = mutableSetOf() + + /** + * Configure this step to be considered a "transient" step in the flow. This means that the step will be: + * a) skipped when navigating back + * b) skipped when navigating forward if the step already has a result, and the [dependsOn] values have not changed. + * + * This can be useful for displaying confirmation steps as part of the flow. For example, when a user completes a step of + * the flow, you might want to confirm the user's action before proceeding to the next step. The confirmation step can + * be marked as transient, and depend on the result of the previous step. This way, the user will be shown the confirmation + * when they initially set the result, but will skip the confirmation when they navigate backwards through the flow, and + * will also skip the confirmation when navigating forward if the result of the original step has not changed. + * + * Example: + * Given a flow with three destinations, A, B, and C, where B is a transient step: + * 1. When A returns a result, the user will be sent to B, and the backstack will be A -> B + * 2. When B returns a result, the user will be sent to C, but the backstack will become A -> C + * 3. When the user navigates back from C, they will be sent to A, skipping B + * 4. When A returns a result for the second time, B may or may not be skipped, depending on whether or not it has a [dependsOn] + * a. If B has a [dependsOn] value, and the value has not changed, B will be skipped + * b. If B has a [dependsOn] value, and the value has changed, B will be shown + * c. If B does not have a [dependsOn] value, B will be skipped + */ + public fun transient() { + configuration.add(FlowStepOptions.Transient) + } + + /** + * Adds a dependency for this step being executed. This means that if the backstack of the navigation flow is manipulated, + * this step will be re-executed if the dependencies have changed. + * + * Example: + * Given a flow with destinations A, B, C and D, where no steps have any dependencies: + * If the backstack for the flow is A -> B -> C -> D, and the user is moved back to A through manipulating the backstack, + * after the user sets a result for A, both B and C will be skipped and the user will be moved back to D. + * + * Given a flow with destinations A, B, C and D, where B depends on the result of A: + * If the backstack for the flow is A -> B -> C -> D, and the user is moved back to A through manipulating the backstack, + * after the user sets a result for A, B will be re-executed, because it depends on the result of A, but C will be skipped + * and the user will be moved back to D. + */ + public fun dependsOn(vararg any: Any?) { + any.forEach { + dependencies.add(it) + } + } + + /** + * Sets a default result for the step. This means that a result will be returned for this step when the user navigates to + * this step for the first time, which means the step will be added to the backstack, but the user will skip over that step + * and go directly to the next step. If the user then navigates back to this step, the step will not be skipped and they + * will be able to interact with the screen that this step represents. + * + * This can be useful for pre-filling steps in a flow that is built from a form. For example, a user might be offered the + * option to edit some form, where there may or may not be data available for some of the steps. The flow can be launched + * with those steps pre-filled with the data that is available, but if the user was to navigate backwards through the flow, + * or the backstack was manipulated to jump back to any of the previous steps, those steps would be available for editing. + */ + public fun default(result: T) { + defaultResult = result + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/NavigationFlow.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/NavigationFlow.kt new file mode 100644 index 000000000..fdc9aa23e --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/NavigationFlow.kt @@ -0,0 +1,86 @@ +package dev.enro.core.result.flows + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dev.enro.result.flow.registerForFlowResult as realRegisterForFlowResult +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty + + +public typealias NavigationFlow = dev.enro.result.flow.NavigationFlow + + +@Deprecated( + message = "registerForFlowResult has moved to dev.enro.result.flow.registerForFlowResult. Navigation flows have changed and need to be updated following the migration guide.", + // isManuallyStarted is not a valid parameter anymore + level = DeprecationLevel.ERROR +) +public fun ViewModel.registerForFlowResult( + savedStateHandle: SavedStateHandle, + isManuallyStarted: Boolean, + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return realRegisterForFlowResult( + flow = { + val compatScope = NavigationFlowScope(this) + compatScope.flow() + }, + onCompleted = onCompleted + ) +} + +@Deprecated( + message = "registerForFlowResult has moved to dev.enro.result.flow.registerForFlowResult. Navigation flows have changed and need to be updated following the migration guide.", + level = DeprecationLevel.WARNING +) +public fun ViewModel.registerForFlowResult( + savedStateHandle: SavedStateHandle, + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return realRegisterForFlowResult( + flow = { + val compatScope = NavigationFlowScope(this) + compatScope.flow() + }, + onCompleted = onCompleted + ) +} + +@Deprecated( + message = "registerForFlowResult has moved to dev.enro.result.flow.registerForFlowResult. Navigation flows have changed and need to be updated following the migration guide.", + // isManuallyStarted is not a valid parameter anymore + level = DeprecationLevel.ERROR +) +public fun ViewModel.registerForFlowResult( + isManuallyStarted: Boolean, + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return realRegisterForFlowResult( + flow = { + val compatScope = NavigationFlowScope(this) + compatScope.flow() + }, + onCompleted = onCompleted + ) +} + +@Deprecated( + message = "registerForFlowResult has moved to dev.enro.result.flow.registerForFlowResult. Navigation flows have changed and need to be updated following the migration guide.", + level = DeprecationLevel.WARNING +) +public fun ViewModel.registerForFlowResult( + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return realRegisterForFlowResult( + flow = { + val compatScope = NavigationFlowScope(this) + compatScope.flow() + }, + onCompleted = onCompleted + ) +} + diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/NavigationFlowScopeCompat.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/NavigationFlowScopeCompat.kt new file mode 100644 index 000000000..1da0e8378 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/flows/NavigationFlowScopeCompat.kt @@ -0,0 +1,109 @@ +package dev.enro.core.result.flows + +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationKey +import dev.enro.result.flow.FlowStepOptions +import dev.enro.result.flow.NavigationFlowScope +import dev.enro.result.flow.default +import dev.enro.withMetadata + +public class NavigationFlowScope( + @PublishedApi + internal val wrapped: NavigationFlowScope +) { + public inline fun push( + noinline block: FlowStepBuilderScope.() -> NavigationKey.SupportsPush.WithResult, + ): T { + val builder = FlowStepBuilderScope() + val key = builder.block() + return wrapped.open(key) { + builder.dependencies.forEach { + dependsOn(it) + } + builder.defaultResult?.let { default(it) } + if (builder.configuration.contains(FlowStepOptions.Transient)) { + transient() + } + } + } + + public inline fun pushWithExtras( + noinline block: FlowStepBuilderScope.() -> dev.enro.NavigationKey.WithMetadata>, + ): T { + val builder = FlowStepBuilderScope() + val key = builder.block() + return wrapped.open(key) { + builder.dependencies.forEach { + dependsOn(it) + } + builder.defaultResult?.let { default(it) } + if (builder.configuration.contains(FlowStepOptions.Transient)) { + transient() + } + } + } + + public inline fun present( + noinline block: FlowStepBuilderScope.() -> NavigationKey.SupportsPresent.WithResult, + ): T { + val builder = FlowStepBuilderScope() + val key = builder.block().withMetadata( + NavigationDirection.MetadataKey, + NavigationDirection.Present, + ) + return wrapped.open(key) { + builder.dependencies.forEach { + dependsOn(it) + } + builder.defaultResult?.let { default(it) } + if (builder.configuration.contains(FlowStepOptions.Transient)) { + transient() + } + } + } + + public inline fun presentWithExtras( + noinline block: FlowStepBuilderScope.() -> dev.enro.NavigationKey.WithMetadata>, + ): T { + val builder = FlowStepBuilderScope() + val key = builder.block().withMetadata( + NavigationDirection.MetadataKey, + NavigationDirection.Present, + ) + return wrapped.open(key) { + builder.dependencies.forEach { + dependsOn(it) + } + builder.defaultResult?.let { default(it) } + if (builder.configuration.contains(FlowStepOptions.Transient)) { + transient() + } + } + } + + /** + * See documentation on the other [async] function for more information on how this function works. + */ + @Suppress("NOTHING_TO_INLINE") // required for using block's name as an identifier + public inline fun async( + vararg dependsOn: Any?, + noinline block: suspend () -> T, + ): T { + if (dependsOn.size == 1 && dependsOn[0] is List<*>) { + return async(dependsOn = dependsOn[0] as List, block = block) + } + return async(dependsOn.toList(), block) + } + + @Suppress("NOTHING_TO_INLINE") // required for using block's name as an identifier + public inline fun async( + dependsOn: List = emptyList(), + noinline block: suspend () -> T, + ): T { + return wrapped.async(dependsOn, block) + } + + public fun escape(): Nothing { + wrapped.escape() + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/result/registerForNavigationResult.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/registerForNavigationResult.kt new file mode 100644 index 000000000..14de6c735 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/registerForNavigationResult.kt @@ -0,0 +1,23 @@ +package dev.enro.core.result + +import androidx.lifecycle.ViewModel +import dev.enro.NavigationKey +import dev.enro.result.NavigationResultScope +import kotlin.properties.ReadOnlyProperty +import dev.enro.result.registerForNavigationResult as realRegisterForNavigationResult + +public inline fun ViewModel.registerForNavigationResult( + noinline onClosed: NavigationResultScope.() -> Unit = {}, + noinline onResult: NavigationResultScope.(R) -> Unit, +) : ReadOnlyProperty> { + val channel = realRegisterForNavigationResult( + onClosed = onClosed, + onCompleted = onResult, + ) + return ReadOnlyProperty> { viewModel, property -> + NavigationResultChannel( + wrapped = channel.provideDelegate(viewModel, property).getValue(viewModel, property) + ) + } +} + diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/rootContext.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/rootContext.kt new file mode 100644 index 000000000..ae5db6783 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/rootContext.kt @@ -0,0 +1,9 @@ +package dev.enro.core + +import dev.enro.context.AnyNavigationContext +import dev.enro.context.RootContext +import dev.enro.context.root + +public fun AnyNavigationContext.rootContext(): RootContext { + return this.root() +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/core/synthetic/syntheticDestination.kt b/enro-compat/src/androidMain/kotlin/dev/enro/core/synthetic/syntheticDestination.kt new file mode 100644 index 000000000..747315b18 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/synthetic/syntheticDestination.kt @@ -0,0 +1,11 @@ +package dev.enro.core.synthetic + +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.destinations.SyntheticDestinationScope + +public fun syntheticDestination( + block: SyntheticDestinationScope.() -> Unit +): NavigationDestinationProvider { + return dev.enro.ui.destinations.syntheticDestination({ }, block) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/EmbeddedNavigationDestination.kt b/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/EmbeddedNavigationDestination.kt new file mode 100644 index 000000000..e0b9eb680 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/EmbeddedNavigationDestination.kt @@ -0,0 +1,77 @@ +package dev.enro.destination.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.asInstance +import dev.enro.backstackOf +import dev.enro.interceptor.builder.navigationInterceptor +import dev.enro.ui.NavigationDisplay +import dev.enro.ui.rememberNavigationContainer + +@Composable +@ExperimentalEnroApi +public fun EmbeddedNavigationDestination( + navigationKey: NavigationKey, + onClosed: (() -> Unit), + modifier: Modifier = Modifier, +) { + val rememberedOnClosed = rememberUpdatedState(onClosed) + + val container = rememberNavigationContainer( + backstack = backstackOf(navigationKey.asInstance()), + interceptor = navigationInterceptor { + onClosed { + if (instance.key != navigationKey) continueWithClose() + cancelAnd { + rememberedOnClosed.value.invoke() + } + } + onCompleted { + if (instance.key != navigationKey) continueWithComplete() + cancelAnd { + rememberedOnClosed.value.invoke() + } + } + } + ) + Box(modifier = modifier) { + NavigationDisplay(container) + } +} + +@Composable +@ExperimentalEnroApi +public inline fun EmbeddedNavigationDestination( + navigationKey: NavigationKey.WithResult, + noinline onClosed: (() -> Unit), + modifier: Modifier = Modifier, + noinline onResult: (T) -> Unit = {}, +) { + val rememberedOnClosed = rememberUpdatedState(onClosed) + val rememberedOnResult = rememberUpdatedState(onResult) + + val container = rememberNavigationContainer( + backstack = backstackOf(navigationKey.asInstance()), + interceptor = navigationInterceptor { + onClosed { + if (instance.key != navigationKey) continueWithClose() + cancelAnd { + rememberedOnClosed.value.invoke() + } + } + onCompleted> { + if (instance.key != navigationKey) continueWithComplete() + cancelAnd { + rememberedOnResult.value.invoke(result) + } + } + } + ) + Box(modifier = modifier) { + NavigationDisplay(container) + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/OverrideNavigationAnimations.kt b/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/OverrideNavigationAnimations.kt new file mode 100644 index 000000000..00947926c --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/OverrideNavigationAnimations.kt @@ -0,0 +1,50 @@ +package dev.enro.destination.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode +import dev.enro.annotations.AdvancedEnroApi + + +@Composable +@AdvancedEnroApi +@Deprecated( + message = "Use the OverrideNavigationAnimations function that takes a content block instead; this function does not work correctly in some situations", + level = DeprecationLevel.ERROR, +) +public fun OverrideNavigationAnimations( + enter: EnterTransition, + exit: ExitTransition, +) { + error("INVALID") +} + +/** + * Override the navigation animations for a particular destination, and also provide a content block that will be animated + * using AnimatedVisibility, providing a AnimatedVisibilityScope which can be used to animate different parts of the screen + * at different times, or to use in shared element transitions (when that is released in Compose). + */ +@Composable +@AdvancedEnroApi +public fun OverrideNavigationAnimations( + enter: EnterTransition, + exit: ExitTransition, + content: @Composable AnimatedVisibilityScope.() -> Unit +) { + // If we are in inspection mode, we need to ignore this call, as it relies on items like navigationContext + // which are only available in actual running applications + val isInspection = LocalInspectionMode.current + if (isInspection) return + + navigationTransition.AnimatedVisibility( + visible = { it == EnterExitState.Visible }, + enter = enter, + exit = exit, + ) { + content() + } +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/navigationTransition.kt b/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/navigationTransition.kt new file mode 100644 index 000000000..0c6b8b5d4 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/destination/compose/navigationTransition.kt @@ -0,0 +1,14 @@ +package dev.enro.destination.compose + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition +import androidx.compose.runtime.Composable +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.ui.LocalNavigationAnimatedVisibilityScope + +@AdvancedEnroApi +public val navigationTransition: Transition + @Composable + get() { + return LocalNavigationAnimatedVisibilityScope.current.transition + } \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/destination/synthetic/syntheticDestination.kt b/enro-compat/src/androidMain/kotlin/dev/enro/destination/synthetic/syntheticDestination.kt new file mode 100644 index 000000000..9f76c5bfd --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/destination/synthetic/syntheticDestination.kt @@ -0,0 +1,12 @@ +package dev.enro.destination.synthetic + +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.destinations.SyntheticDestinationScope + + +public fun syntheticDestination( + block: SyntheticDestinationScope.() -> Unit +): NavigationDestinationProvider { + return dev.enro.ui.destinations.syntheticDestination({ }, block) +} \ No newline at end of file diff --git a/enro-compat/src/androidMain/kotlin/dev/enro/viewmodel/navigationHandle.kt b/enro-compat/src/androidMain/kotlin/dev/enro/viewmodel/navigationHandle.kt new file mode 100644 index 000000000..a4dd7ec61 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/viewmodel/navigationHandle.kt @@ -0,0 +1,19 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import dev.enro.NavigationHandle +import dev.enro.NavigationHandleConfiguration +import dev.enro.NavigationKey +import kotlin.properties.ReadOnlyProperty +import dev.enro.navigationHandle as realNavigationHandle + + +public inline fun ViewModel.navigationHandle( + noinline config: (NavigationHandleConfiguration.() -> Unit)? = null, +): ReadOnlyProperty> { + return realNavigationHandle(config) +} + +public inline fun ViewModel.navigationHandle(): ReadOnlyProperty> { + return navigationHandle(config = null) +} \ No newline at end of file diff --git a/enro-core/build.gradle b/enro-core/build.gradle deleted file mode 100644 index 43d51aa9f..000000000 --- a/enro-core/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -androidLibrary() -useCompose() -apply plugin: 'kotlin-kapt' -apply plugin: 'dagger.hilt.android.plugin' - -publishAndroidModule("dev.enro", "enro-core") - -dependencies { - implementation deps.androidx.core - implementation deps.androidx.appcompat - implementation deps.androidx.fragment - implementation deps.androidx.activity - implementation deps.androidx.recyclerview - - compileOnly deps.hilt.android - kapt deps.hilt.compiler - kapt deps.hilt.androidCompiler -} \ No newline at end of file diff --git a/enro-core/consumer-rules.pro b/enro-core/consumer-rules.pro deleted file mode 100644 index 3d4a2d6ff..000000000 --- a/enro-core/consumer-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ --keep class * extends dev.enro.core.controller.NavigationComponentBuilderCommand --keep class * extends dev.enro.core.NavigationKey \ No newline at end of file diff --git a/enro-core/src/androidTest/AndroidManifest.xml b/enro-core/src/androidTest/AndroidManifest.xml deleted file mode 100644 index 71b354cd4..000000000 --- a/enro-core/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/enro-core/src/main/AndroidManifest.xml b/enro-core/src/main/AndroidManifest.xml deleted file mode 100644 index 5b0f6092f..000000000 --- a/enro-core/src/main/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/enro-core/src/main/java/androidx/lifecycle/SetNavigationHandle.kt b/enro-core/src/main/java/androidx/lifecycle/SetNavigationHandle.kt deleted file mode 100644 index 24d4a3e69..000000000 --- a/enro-core/src/main/java/androidx/lifecycle/SetNavigationHandle.kt +++ /dev/null @@ -1,19 +0,0 @@ -package androidx.lifecycle - -import dev.enro.core.NavigationHandle - - -internal const val NAVIGATION_HANDLE_KEY = "dev.enro.viemodel.NAVIGATION_HANDLE_KEY" - -internal fun ViewModel.setNavigationHandleTag(navigationHandle: NavigationHandle) { - setTagIfAbsent( - NAVIGATION_HANDLE_KEY, - navigationHandle - ) -} - -internal fun ViewModel.getNavigationHandleTag(): NavigationHandle? { - return getTag( - NAVIGATION_HANDLE_KEY - ) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt b/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt deleted file mode 100644 index e756e4608..000000000 --- a/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.enro.core - -abstract class EnroException( - private val inputMessage: String, cause: Throwable? = null -) : RuntimeException(cause) { - override val message: String? - get() = "${inputMessage.trim().removeSuffix(".")}. See https://github.com/isaac-udy/Enro/blob/main/docs/troubleshooting.md#${this::class.java.simpleName} for troubleshooting help" - - class NoAttachedNavigationHandle(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class CouldNotCreateEnroViewModel(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class ViewModelCouldNotGetNavigationHandle(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class MissingNavigator(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class IncorrectlyTypedNavigationHandle(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class InvalidViewForNavigationHandle(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class DestinationIsNotDialogDestination(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class EnroResultIsNotInstalled(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class ResultChannelIsNotInitialised(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class ReceivedIncorrectlyTypedResult(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class NavigationControllerIsNotAttached(message: String, cause: Throwable? = null) : EnroException(message, cause) - - class UnreachableState : EnroException("This state is expected to be unreachable. If you are seeing this exception, please report an issue (with the stacktrace included) at https://github.com/isaac-udy/Enro/issues") - - class ComposePreviewException(message: String) : EnroException(message) - -} diff --git a/enro-core/src/main/java/dev/enro/core/NavigationAnimations.kt b/enro-core/src/main/java/dev/enro/core/NavigationAnimations.kt deleted file mode 100644 index 358734098..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationAnimations.kt +++ /dev/null @@ -1,131 +0,0 @@ -package dev.enro.core - -import android.content.res.Resources -import android.os.Parcelable -import dev.enro.core.compose.AbstractComposeFragmentHost -import dev.enro.core.compose.AbstractComposeFragmentHostKey -import dev.enro.core.controller.navigationController -import dev.enro.core.fragment.internal.AbstractSingleFragmentActivity -import dev.enro.core.fragment.internal.AbstractSingleFragmentKey -import dev.enro.core.internal.getAttributeResourceId -import kotlinx.parcelize.Parcelize - -sealed class AnimationPair : Parcelable { - abstract val enter: Int - abstract val exit: Int - - @Parcelize - class Resource( - override val enter: Int, - override val exit: Int - ) : AnimationPair() - - @Parcelize - class Attr( - override val enter: Int, - override val exit: Int - ) : AnimationPair() - - fun asResource(theme: Resources.Theme) = when (this) { - is Resource -> this - is Attr -> Resource( - theme.getAttributeResourceId(enter), - theme.getAttributeResourceId(exit) - ) - } -} - -object DefaultAnimations { - val forward = AnimationPair.Attr( - enter = android.R.attr.activityOpenEnterAnimation, - exit = android.R.attr.activityOpenExitAnimation - ) - - val replace = AnimationPair.Attr( - enter = android.R.attr.activityOpenEnterAnimation, - exit = android.R.attr.activityOpenExitAnimation - ) - - val replaceRoot = AnimationPair.Attr( - enter = android.R.attr.taskOpenEnterAnimation, - exit = android.R.attr.taskOpenExitAnimation - ) - - val close = AnimationPair.Attr( - enter = android.R.attr.activityCloseEnterAnimation, - exit = android.R.attr.activityCloseExitAnimation - ) - - val none = AnimationPair.Resource( - enter = 0, - exit = R.anim.enro_no_op_animation - ) -} - -fun animationsFor( - context: NavigationContext<*>, - navigationInstruction: NavigationInstruction -): AnimationPair.Resource { - if (navigationInstruction is NavigationInstruction.Open && navigationInstruction.children.isNotEmpty()) { - return AnimationPair.Resource(0, 0) - } - - if (navigationInstruction is NavigationInstruction.Open && context.contextReference is AbstractSingleFragmentActivity) { - val singleFragmentKey = context.getNavigationHandleViewModel().key as AbstractSingleFragmentKey - if (navigationInstruction.instructionId == singleFragmentKey.instruction.instructionId) { - return AnimationPair.Resource(0, 0) - } - } - - if (navigationInstruction is NavigationInstruction.Open && context.contextReference is AbstractComposeFragmentHost) { - val composeHostKey = context.getNavigationHandleViewModel().key as AbstractComposeFragmentHostKey - if (navigationInstruction.instructionId == composeHostKey.instruction.instructionId) { - return AnimationPair.Resource(0, 0) - } - } - - return when (navigationInstruction) { - is NavigationInstruction.Open -> animationsForOpen(context, navigationInstruction) - is NavigationInstruction.Close -> animationsForClose(context) - is NavigationInstruction.RequestClose -> animationsForClose(context) - } -} - -private fun animationsForOpen( - context: NavigationContext<*>, - navigationInstruction: NavigationInstruction.Open -): AnimationPair.Resource { - val theme = context.activity.theme - - val instructionForAnimation = when ( - val navigationKey = navigationInstruction.navigationKey - ) { - is AbstractComposeFragmentHostKey -> navigationKey.instruction - else -> navigationInstruction - } - - val executor = context.activity.application.navigationController.executorForOpen( - context, - instructionForAnimation - ) - return executor.executor.animation(navigationInstruction).asResource(theme) -} - -private fun animationsForClose( - context: NavigationContext<*> -): AnimationPair.Resource { - val theme = context.activity.theme - - val contextForAnimation = when (context.contextReference) { - is AbstractComposeFragmentHost -> { - context.childComposableManager.containers - .firstOrNull() - ?.activeContext - ?: context - } - else -> context - } - - val executor = context.activity.application.navigationController.executorForClose(contextForAnimation) - return executor.closeAnimation(context).asResource(theme) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationContext.kt b/enro-core/src/main/java/dev/enro/core/NavigationContext.kt deleted file mode 100644 index 23c814c65..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationContext.kt +++ /dev/null @@ -1,177 +0,0 @@ -package dev.enro.core - -import android.os.Bundle -import android.os.Looper -import androidx.core.os.bundleOf -import androidx.fragment.app.* -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.lifecycleScope -import androidx.savedstate.SavedStateRegistryOwner -import dev.enro.core.activity.ActivityNavigator -import dev.enro.core.compose.ComposableDestination -import dev.enro.core.compose.EnroComposableManager -import dev.enro.core.compose.composableManger -import dev.enro.core.controller.NavigationController -import dev.enro.core.controller.navigationController -import dev.enro.core.fragment.FragmentNavigator -import dev.enro.core.internal.handle.NavigationHandleViewModel -import dev.enro.core.internal.handle.getNavigationHandleViewModel - -sealed class NavigationContext( - val contextReference: ContextType -) { - abstract val controller: NavigationController - abstract val lifecycle: Lifecycle - abstract val childFragmentManager: FragmentManager - abstract val childComposableManager: EnroComposableManager - abstract val arguments: Bundle - abstract val viewModelStoreOwner: ViewModelStoreOwner - abstract val savedStateRegistryOwner: SavedStateRegistryOwner - abstract val lifecycleOwner: LifecycleOwner - - internal open val navigator: Navigator<*, ContextType>? by lazy { - controller.navigatorForContextType(contextReference::class) as? Navigator<*, ContextType> - } -} - -internal class ActivityContext( - contextReference: ContextType, -) : NavigationContext(contextReference) { - override val controller get() = contextReference.application.navigationController - override val lifecycle get() = contextReference.lifecycle - override val navigator get() = super.navigator as? ActivityNavigator<*, ContextType> - override val childFragmentManager get() = contextReference.supportFragmentManager - override val childComposableManager: EnroComposableManager get() = contextReference.composableManger - override val arguments: Bundle by lazy { contextReference.intent.extras ?: Bundle() } - - override val viewModelStoreOwner: ViewModelStoreOwner get() = contextReference - override val savedStateRegistryOwner: SavedStateRegistryOwner get() = contextReference - override val lifecycleOwner: LifecycleOwner get() = contextReference -} - -internal class FragmentContext( - contextReference: ContextType, -) : NavigationContext(contextReference) { - override val controller get() = contextReference.requireActivity().application.navigationController - override val lifecycle get() = contextReference.lifecycle - override val navigator get() = super.navigator as? FragmentNavigator<*, ContextType> - override val childFragmentManager get() = contextReference.childFragmentManager - override val childComposableManager: EnroComposableManager get() = contextReference.composableManger - override val arguments: Bundle by lazy { contextReference.arguments ?: Bundle() } - - override val viewModelStoreOwner: ViewModelStoreOwner get() = contextReference - override val savedStateRegistryOwner: SavedStateRegistryOwner get() = contextReference - override val lifecycleOwner: LifecycleOwner get() = contextReference -} - -internal class ComposeContext( - contextReference: ContextType -) : NavigationContext(contextReference) { - override val controller: NavigationController get() = contextReference.contextReference.activity.application.navigationController - override val lifecycle: Lifecycle get() = contextReference.contextReference.lifecycle - override val childFragmentManager: FragmentManager get() = contextReference.contextReference.activity.supportFragmentManager - override val childComposableManager: EnroComposableManager get() = contextReference.contextReference.composableManger - override val arguments: Bundle by lazy { bundleOf(OPEN_ARG to contextReference.contextReference.instruction) } - - override val viewModelStoreOwner: ViewModelStoreOwner get() = contextReference - override val savedStateRegistryOwner: SavedStateRegistryOwner get() = contextReference - override val lifecycleOwner: LifecycleOwner get() = contextReference -} - -val NavigationContext.fragment get() = contextReference - -val NavigationContext<*>.activity: FragmentActivity - get() = when (contextReference) { - is FragmentActivity -> contextReference - is Fragment -> contextReference.requireActivity() - is ComposableDestination -> contextReference.contextReference.activity - else -> throw EnroException.UnreachableState() - } - -@Suppress("UNCHECKED_CAST") // Higher level logic dictates this cast will pass -internal val T.navigationContext: ActivityContext - get() = getNavigationHandleViewModel().navigationContext as ActivityContext - -@Suppress("UNCHECKED_CAST") // Higher level logic dictates this cast will pass -internal val T.navigationContext: FragmentContext - get() = getNavigationHandleViewModel().navigationContext as FragmentContext - -@Suppress("UNCHECKED_CAST") // Higher level logic dictates this cast will pass -internal val T.navigationContext: ComposeContext - get() = getNavigationHandleViewModel().navigationContext as ComposeContext - -fun NavigationContext<*>.rootContext(): NavigationContext<*> { - var parent = this - while (true) { - val currentContext = parent - parent = parent.parentContext() ?: return currentContext - } -} - -fun NavigationContext<*>.parentContext(): NavigationContext<*>? { - return when (this) { - is ActivityContext -> null - is FragmentContext -> - when (val parentFragment = fragment.parentFragment) { - null -> fragment.requireActivity().navigationContext - else -> parentFragment.navigationContext - } - is ComposeContext -> contextReference.contextReference.requireParentContainer().navigationContext - } -} - -fun NavigationContext<*>.leafContext(): NavigationContext<*> { - return when(this) { - is ActivityContext, - is FragmentContext -> { - val primaryNavigationFragment = childFragmentManager.primaryNavigationFragment - ?: return childComposableManager.activeContainer?.activeContext?.leafContext() ?: this - primaryNavigationFragment.view ?: return this - primaryNavigationFragment.navigationContext.leafContext() - } - is ComposeContext<*> -> childComposableManager.activeContainer?.activeContext?.leafContext() ?: this - } -} - -internal fun NavigationContext<*>.getNavigationHandleViewModel(): NavigationHandleViewModel { - return when (this) { - is FragmentContext -> fragment.getNavigationHandle() - is ActivityContext -> activity.getNavigationHandle() - is ComposeContext -> contextReference.contextReference.getNavigationHandleViewModel() - } as NavigationHandleViewModel -} - -internal fun NavigationContext<*>.runWhenContextActive(block: () -> Unit) { - val isMainThread = Looper.getMainLooper() == Looper.myLooper() - when(this) { - is FragmentContext -> { - if(isMainThread && !fragment.isStateSaved && fragment.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - block() - } else { - fragment.lifecycleScope.launchWhenStarted { - block() - } - } - } - is ActivityContext -> { - if(isMainThread && contextReference.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { - block() - } else { - contextReference.lifecycleScope.launchWhenStarted { - block() - } - } - } - is ComposeContext -> { - if(isMainThread && contextReference.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { - block() - } else { - contextReference.lifecycleScope.launchWhenStarted { - block() - } - } - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationExecutor.kt b/enro-core/src/main/java/dev/enro/core/NavigationExecutor.kt deleted file mode 100644 index 3be57e0cc..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationExecutor.kt +++ /dev/null @@ -1,194 +0,0 @@ -package dev.enro.core - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import dev.enro.core.activity.ActivityNavigator -import dev.enro.core.activity.DefaultActivityExecutor -import dev.enro.core.compose.ComposableDestination -import dev.enro.core.compose.ComposableNavigator -import dev.enro.core.compose.DefaultComposableExecutor -import dev.enro.core.fragment.DefaultFragmentExecutor -import dev.enro.core.fragment.FragmentNavigator -import dev.enro.core.synthetic.DefaultSyntheticExecutor -import dev.enro.core.synthetic.SyntheticDestination -import dev.enro.core.synthetic.SyntheticNavigator -import kotlin.reflect.KClass - -// This class is used primarily to simplify the lambda signature of NavigationExecutor.open -class ExecutorArgs( - val fromContext: NavigationContext, - val navigator: Navigator, - val key: KeyType, - val instruction: NavigationInstruction.Open -) - -abstract class NavigationExecutor( - val fromType: KClass, - val opensType: KClass, - val keyType: KClass -) { - open fun animation(instruction: NavigationInstruction.Open): AnimationPair { - return when(instruction.navigationDirection) { - NavigationDirection.FORWARD -> DefaultAnimations.forward - NavigationDirection.REPLACE -> DefaultAnimations.replace - NavigationDirection.REPLACE_ROOT -> DefaultAnimations.replaceRoot - } - } - - open fun closeAnimation(context: NavigationContext): AnimationPair { - return DefaultAnimations.close - } - - open fun preOpened( - context: NavigationContext - ) {} - - abstract fun open( - args: ExecutorArgs - ) - - open fun postOpened( - context: NavigationContext - ) {} - - open fun preClosed( - context: NavigationContext - ) {} - - abstract fun close( - context: NavigationContext - ) -} - -class NavigationExecutorBuilder @PublishedApi internal constructor( - private val fromType: KClass, - private val opensType: KClass, - private val keyType: KClass -) { - - private var animationFunc: ((instruction: NavigationInstruction.Open) -> AnimationPair)? = null - private var closeAnimationFunc: ((context: NavigationContext) -> AnimationPair)? = null - private var preOpenedFunc: (( context: NavigationContext) -> Unit)? = null - private var openedFunc: ((args: ExecutorArgs) -> Unit)? = null - private var postOpenedFunc: ((context: NavigationContext) -> Unit)? = null - private var preClosedFunc: ((context: NavigationContext) -> Unit)? = null - private var closedFunc: ((context: NavigationContext) -> Unit)? = null - - @Suppress("UNCHECKED_CAST") - fun defaultOpened(args: ExecutorArgs) { - when(args.navigator) { - is ActivityNavigator -> - DefaultActivityExecutor::open as ((ExecutorArgs) -> Unit) - - is FragmentNavigator -> - DefaultFragmentExecutor::open as ((ExecutorArgs) -> Unit) - - is SyntheticNavigator -> - DefaultSyntheticExecutor::open as ((ExecutorArgs) -> Unit) - - is ComposableNavigator -> - DefaultComposableExecutor::open as ((ExecutorArgs) -> Unit) - - else -> throw IllegalArgumentException("No default launch executor found for ${opensType.java}") - }.invoke(args) - } - - @Suppress("UNCHECKED_CAST") - fun defaultClosed(context: NavigationContext) { - when(context.navigator) { - is ActivityNavigator -> - DefaultActivityExecutor::close as (NavigationContext) -> Unit - - is FragmentNavigator -> - DefaultFragmentExecutor::close as (NavigationContext) -> Unit - - is ComposableNavigator -> - DefaultComposableExecutor::close as (NavigationContext) -> Unit - - else -> throw IllegalArgumentException("No default close executor found for ${opensType.java}") - }.invoke(context) - } - - fun animation(block: (instruction: NavigationInstruction.Open) -> AnimationPair) { - if(animationFunc != null) throw IllegalStateException("Value is already set!") - animationFunc = block - } - - fun closeAnimation(block: ( context: NavigationContext) -> AnimationPair) { - if(closeAnimationFunc != null) throw IllegalStateException("Value is already set!") - closeAnimationFunc = block - } - - fun preOpened(block: ( context: NavigationContext) -> Unit) { - if(preOpenedFunc != null) throw IllegalStateException("Value is already set!") - preOpenedFunc = block - } - - fun opened(block: (args: ExecutorArgs) -> Unit) { - if(openedFunc != null) throw IllegalStateException("Value is already set!") - openedFunc = block - } - - fun postOpened(block: (context: NavigationContext) -> Unit) { - if(postOpenedFunc != null) throw IllegalStateException("Value is already set!") - postOpenedFunc = block - } - - fun preClosed(block: (context: NavigationContext) -> Unit) { - if(preClosedFunc != null) throw IllegalStateException("Value is already set!") - preClosedFunc = block - } - - fun closed(block: (context: NavigationContext) -> Unit) { - if(closedFunc != null) throw IllegalStateException("Value is already set!") - closedFunc = block - } - - internal fun build() = object : NavigationExecutor( - fromType, - opensType, - keyType - ) { - override fun animation(instruction: NavigationInstruction.Open): AnimationPair { - return animationFunc?.invoke(instruction) ?: super.animation(instruction) - } - - override fun closeAnimation(context: NavigationContext): AnimationPair { - return closeAnimationFunc?.invoke(context) ?: super.closeAnimation(context) - } - - override fun preOpened(context: NavigationContext) { - preOpenedFunc?.invoke(context) - } - - override fun open(args: ExecutorArgs) { - openedFunc?.invoke(args) ?: defaultOpened(args) - } - - override fun postOpened(context: NavigationContext) { - postOpenedFunc?.invoke(context) - } - - override fun preClosed(context: NavigationContext) { - preClosedFunc?.invoke(context) - } - - override fun close(context: NavigationContext) { - closedFunc?.invoke(context) ?: defaultClosed(context) - } - } -} - -fun createOverride( - fromClass: KClass, - opensClass: KClass, - block: NavigationExecutorBuilder.() -> Unit -): NavigationExecutor = - NavigationExecutorBuilder(fromClass, opensClass, NavigationKey::class) - .apply(block) - .build() - -inline fun createOverride( - noinline block: NavigationExecutorBuilder.() -> Unit -): NavigationExecutor = - createOverride(From::class, Opens::class, block) diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt b/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt deleted file mode 100644 index 797f85e1b..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt +++ /dev/null @@ -1,89 +0,0 @@ -package dev.enro.core - -import android.os.Bundle -import android.os.Looper -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import dev.enro.core.controller.NavigationController -import kotlin.reflect.KClass - -interface NavigationHandle : LifecycleOwner { - val id: String - val controller: NavigationController - val additionalData: Bundle - val key: NavigationKey - val instruction: NavigationInstruction.Open - fun executeInstruction(navigationInstruction: NavigationInstruction) -} - -interface TypedNavigationHandle : NavigationHandle { - override val key: T -} - -@PublishedApi -internal class TypedNavigationHandleImpl( - internal val navigationHandle: NavigationHandle, - private val type: Class -): TypedNavigationHandle { - override val id: String get() = navigationHandle.id - override val controller: NavigationController get() = navigationHandle.controller - override val additionalData: Bundle get() = navigationHandle.additionalData - override val instruction: NavigationInstruction.Open = navigationHandle.instruction - - @Suppress("UNCHECKED_CAST") - override val key: T get() = navigationHandle.key as? T - ?: throw EnroException.IncorrectlyTypedNavigationHandle("TypedNavigationHandle failed to cast key of type ${navigationHandle.key::class.java.simpleName} to ${type.simpleName}") - - override fun getLifecycle(): Lifecycle = navigationHandle.lifecycle - - override fun executeInstruction(navigationInstruction: NavigationInstruction) = navigationHandle.executeInstruction(navigationInstruction) -} - -fun NavigationHandle.asTyped(type: KClass): TypedNavigationHandle { - val keyType = key::class - val isValidType = type.java.isAssignableFrom(keyType.java) - if(!isValidType) { - throw EnroException.IncorrectlyTypedNavigationHandle("Failed to cast NavigationHandle with key of type ${keyType.java.simpleName} to TypedNavigationHandle<${type.simpleName}>") - } - - @Suppress("UNCHECKED_CAST") - if(this is TypedNavigationHandleImpl<*>) return this as TypedNavigationHandle - return TypedNavigationHandleImpl(this, type.java) -} - -inline fun NavigationHandle.asTyped(): TypedNavigationHandle { - if(key !is T) { - throw EnroException.IncorrectlyTypedNavigationHandle("Failed to cast NavigationHandle with key of type ${key::class.java.simpleName} to TypedNavigationHandle<${T::class.java.simpleName}>") - } - return TypedNavigationHandleImpl(this, T::class.java) -} - -fun NavigationHandle.forward(key: NavigationKey, vararg childKeys: NavigationKey) = - executeInstruction(NavigationInstruction.Forward(key, childKeys.toList())) - -fun NavigationHandle.replace(key: NavigationKey, vararg childKeys: NavigationKey) = - executeInstruction(NavigationInstruction.Replace(key, childKeys.toList())) - -fun NavigationHandle.replaceRoot(key: NavigationKey, vararg childKeys: NavigationKey) = - executeInstruction(NavigationInstruction.ReplaceRoot(key, childKeys.toList())) - -fun NavigationHandle.close() = - executeInstruction(NavigationInstruction.Close) - -fun NavigationHandle.requestClose() = - executeInstruction(NavigationInstruction.RequestClose) - -internal fun NavigationHandle.runWhenHandleActive(block: () -> Unit) { - val isMainThread = runCatching { - Looper.getMainLooper() == Looper.myLooper() - }.getOrElse { controller.isInTest } // if the controller is in a Jvm only test, the block above may fail to run - - if(isMainThread && lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { - block() - } else { - lifecycleScope.launchWhenCreated { - block() - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt b/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt deleted file mode 100644 index 82103deb3..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt +++ /dev/null @@ -1,79 +0,0 @@ -package dev.enro.core - -import androidx.annotation.IdRes -import dev.enro.core.compose.AbstractComposeFragmentHostKey -import dev.enro.core.internal.handle.NavigationHandleViewModel -import kotlin.reflect.KClass - -internal class ChildContainer( - @IdRes val containerId: Int, - private val accept: (NavigationKey) -> Boolean -) { - fun accept(key: NavigationKey): Boolean { - if (key is AbstractComposeFragmentHostKey && accept.invoke(key.instruction.navigationKey)) return true - return accept.invoke(key) - } -} - -// TODO Move this to being a "Builder" and add data class for configuration? -class NavigationHandleConfiguration @PublishedApi internal constructor( - private val keyType: KClass -) { - internal var childContainers: List = listOf() - private set - - internal var defaultKey: T? = null - private set - - internal var onCloseRequested: (TypedNavigationHandle.() -> Unit)? = null - private set - - fun container(@IdRes containerId: Int, accept: (NavigationKey) -> Boolean = { true }) { - childContainers = childContainers + ChildContainer(containerId, accept) - } - - fun defaultKey(navigationKey: T) { - defaultKey = navigationKey - } - - fun onCloseRequested(block: TypedNavigationHandle.() -> Unit) { - onCloseRequested = block - } - - // TODO Store these properties ON the navigation handle? Rather than set individual fields? - internal fun applyTo(navigationHandleViewModel: NavigationHandleViewModel) { - navigationHandleViewModel.childContainers = childContainers - - val onCloseRequested = onCloseRequested ?: return - navigationHandleViewModel.internalOnCloseRequested = { onCloseRequested(navigationHandleViewModel.asTyped(keyType)) } - } -} - -class LazyNavigationHandleConfiguration( - private val keyType: KClass -) { - - private var onCloseRequested: (TypedNavigationHandle.() -> Unit)? = null - - fun onCloseRequested(block: TypedNavigationHandle.() -> Unit) { - onCloseRequested = block - } - - fun configure(navigationHandle: NavigationHandle) { - val handle = if (navigationHandle is TypedNavigationHandleImpl<*>) { - navigationHandle.navigationHandle - } else navigationHandle - - val onCloseRequested = onCloseRequested ?: return - - if (handle is NavigationHandleViewModel) { - handle.internalOnCloseRequested = { onCloseRequested(navigationHandle.asTyped(keyType)) } - } else if (handle.controller.isInTest) { - val field = handle::class.java.declaredFields - .firstOrNull { it.name.startsWith("internalOnCloseRequested") } - ?: return - field.isAccessible = true - field.set(handle, { onCloseRequested(navigationHandle.asTyped(keyType)) }) - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHandleProperty.kt b/enro-core/src/main/java/dev/enro/core/NavigationHandleProperty.kt deleted file mode 100644 index d3a36b6b5..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationHandleProperty.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.enro.core - -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.findViewTreeViewModelStoreOwner -import dev.enro.core.internal.handle.getNavigationHandleViewModel -import java.lang.ref.WeakReference -import kotlin.collections.set -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KClass -import kotlin.reflect.KProperty - - -class NavigationHandleProperty @PublishedApi internal constructor( - private val lifecycleOwner: LifecycleOwner, - private val viewModelStoreOwner: ViewModelStoreOwner, - private val configBuilder: NavigationHandleConfiguration.() -> Unit = {}, - private val keyType: KClass -) : ReadOnlyProperty> { - - private val config = NavigationHandleConfiguration(keyType).apply(configBuilder) - - private val navigationHandle: TypedNavigationHandle by lazy { - val navigationHandle = viewModelStoreOwner.getNavigationHandleViewModel() - return@lazy TypedNavigationHandleImpl(navigationHandle, keyType.java) - } - - init { - pendingProperties[lifecycleOwner.hashCode()] = WeakReference(this) - } - - override fun getValue(thisRef: Any, property: KProperty<*>): TypedNavigationHandle { - return navigationHandle - } - - companion object { - internal val pendingProperties = mutableMapOf>>() - - fun getPendingConfig(navigationContext: NavigationContext<*>): NavigationHandleConfiguration<*>? { - val pending = pendingProperties[navigationContext.contextReference.hashCode()] ?: return null - val config = pending.get()?.config - pendingProperties.remove(navigationContext.contextReference.hashCode()) - return config - } - } -} - -inline fun FragmentActivity.navigationHandle( - noinline config: NavigationHandleConfiguration.() -> Unit = {} -): NavigationHandleProperty = NavigationHandleProperty( - lifecycleOwner = this, - viewModelStoreOwner = this, - configBuilder = config, - keyType = T::class -) - -inline fun Fragment.navigationHandle( - noinline config: NavigationHandleConfiguration.() -> Unit = {} -): NavigationHandleProperty = NavigationHandleProperty( - lifecycleOwner = this, - viewModelStoreOwner = this, - configBuilder = config, - keyType = T::class -) - -fun NavigationContext<*>.getNavigationHandle(): NavigationHandle = getNavigationHandleViewModel() - -fun FragmentActivity.getNavigationHandle(): NavigationHandle = getNavigationHandleViewModel() - -fun Fragment.getNavigationHandle(): NavigationHandle = getNavigationHandleViewModel() - -fun View.getNavigationHandle(): NavigationHandle? = findViewTreeViewModelStoreOwner()?.getNavigationHandleViewModel() - -fun View.requireNavigationHandle(): NavigationHandle { - if(!isAttachedToWindow) { - throw EnroException.InvalidViewForNavigationHandle("$this is not attached to any Window, which is required to retrieve a NavigationHandle") - } - val viewModelStoreOwner = findViewTreeViewModelStoreOwner() - ?: throw EnroException.InvalidViewForNavigationHandle("Could not find ViewTreeViewModelStoreOwner for $this, which is required to retrieve a NavigationHandle") - return viewModelStoreOwner.getNavigationHandleViewModel() -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationInstruction.kt b/enro-core/src/main/java/dev/enro/core/NavigationInstruction.kt deleted file mode 100644 index ff068e3ce..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationInstruction.kt +++ /dev/null @@ -1,99 +0,0 @@ -package dev.enro.core - -import android.content.Intent -import android.os.Bundle -import android.os.Parcelable -import androidx.fragment.app.Fragment -import dev.enro.core.result.internal.ResultChannelId -import kotlinx.parcelize.Parcelize -import java.util.* - -enum class NavigationDirection { - FORWARD, - REPLACE, - REPLACE_ROOT -} - -internal const val OPEN_ARG = "dev.enro.core.OPEN_ARG" - -sealed class NavigationInstruction { - sealed class Open : NavigationInstruction(), Parcelable { - abstract val navigationDirection: NavigationDirection - abstract val navigationKey: NavigationKey - abstract val children: List - abstract val additionalData: Bundle - abstract val instructionId: String - - internal val internal by lazy { this as OpenInternal } - - @Parcelize - internal data class OpenInternal constructor( - override val navigationDirection: NavigationDirection, - override val navigationKey: NavigationKey, - override val children: List = emptyList(), - override val additionalData: Bundle = Bundle(), - val parentInstruction: OpenInternal? = null, - val previouslyActiveId: String? = null, - val executorContext: Class? = null, - val resultId: ResultChannelId? = null, - override val instructionId: String = UUID.randomUUID().toString() - ) : NavigationInstruction.Open() - } - - object Close : NavigationInstruction() - object RequestClose : NavigationInstruction() - - companion object { - @Suppress("FunctionName") - fun Forward( - navigationKey: NavigationKey, - children: List = emptyList() - ): Open = Open.OpenInternal( - navigationDirection = NavigationDirection.FORWARD, - navigationKey = navigationKey, - children = children - ) - - @Suppress("FunctionName") - fun Replace( - navigationKey: NavigationKey, - children: List = emptyList() - ): Open = Open.OpenInternal( - navigationDirection = NavigationDirection.REPLACE, - navigationKey = navigationKey, - children = children - ) - - @Suppress("FunctionName") - fun ReplaceRoot( - navigationKey: NavigationKey, - children: List = emptyList() - ): Open = Open.OpenInternal( - navigationDirection = NavigationDirection.REPLACE_ROOT, - navigationKey = navigationKey, - children = children - ) - } -} - - -fun Intent.addOpenInstruction(instruction: NavigationInstruction.Open): Intent { - putExtra(OPEN_ARG, instruction.internal) - return this -} - -fun Bundle.addOpenInstruction(instruction: NavigationInstruction.Open): Bundle { - putParcelable(OPEN_ARG, instruction.internal) - return this -} - -fun Fragment.addOpenInstruction(instruction: NavigationInstruction.Open): Fragment { - arguments = (arguments ?: Bundle()).apply { - putParcelable(OPEN_ARG, instruction.internal) - } - return this -} - -fun Bundle.readOpenInstruction(): NavigationInstruction.Open? { - return getParcelable(OPEN_ARG) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/NavigationKey.kt b/enro-core/src/main/java/dev/enro/core/NavigationKey.kt deleted file mode 100644 index 40f3d345c..000000000 --- a/enro-core/src/main/java/dev/enro/core/NavigationKey.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.enro.core - -import android.os.Parcelable - -interface NavigationKey : Parcelable { - interface WithResult : NavigationKey -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/Navigator.kt b/enro-core/src/main/java/dev/enro/core/Navigator.kt deleted file mode 100644 index af85d953e..000000000 --- a/enro-core/src/main/java/dev/enro/core/Navigator.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.enro.core - -import kotlin.reflect.KClass - -interface Navigator { - val keyType: KClass - val contextType: KClass -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/activity/ActivityNavigator.kt b/enro-core/src/main/java/dev/enro/core/activity/ActivityNavigator.kt deleted file mode 100644 index 5f26ecbd8..000000000 --- a/enro-core/src/main/java/dev/enro/core/activity/ActivityNavigator.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.enro.core.activity - -import androidx.fragment.app.FragmentActivity -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator -import kotlin.reflect.KClass - -class ActivityNavigator @PublishedApi internal constructor( - override val keyType: KClass, - override val contextType: KClass, -) : Navigator - -fun createActivityNavigator( - keyType: Class, - activityType: Class -): Navigator = ActivityNavigator( - keyType = keyType.kotlin, - contextType = activityType.kotlin, -) - -inline fun createActivityNavigator(): Navigator = - createActivityNavigator( - keyType = KeyType::class.java, - activityType = ActivityType::class.java, - ) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/activity/DefaultActivityExecutor.kt b/enro-core/src/main/java/dev/enro/core/activity/DefaultActivityExecutor.kt deleted file mode 100644 index 7bf2cc519..000000000 --- a/enro-core/src/main/java/dev/enro/core/activity/DefaultActivityExecutor.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.enro.core.activity - -import android.content.Intent -import androidx.fragment.app.FragmentActivity -import dev.enro.core.* - -object DefaultActivityExecutor : NavigationExecutor( - fromType = Any::class, - opensType = FragmentActivity::class, - keyType = NavigationKey::class -) { - override fun open(args: ExecutorArgs) { - val fromContext = args.fromContext - val navigator = args.navigator - val instruction = args.instruction - - navigator as ActivityNavigator - - val intent = createIntent(args) - - if (instruction.navigationDirection == NavigationDirection.REPLACE_ROOT) { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - } - - val activity = fromContext.activity - if (instruction.navigationDirection == NavigationDirection.REPLACE || instruction.navigationDirection == NavigationDirection.REPLACE_ROOT) { - activity.finish() - } - val animations = animationsFor(fromContext, instruction) - - activity.startActivity(intent) - if (instruction.children.isEmpty()) { - activity.overridePendingTransition(animations.enter, animations.exit) - } else { - activity.overridePendingTransition(0, 0) - } - } - - override fun close(context: NavigationContext) { - context.activity.supportFinishAfterTransition() - context.navigator ?: return - - val animations = animationsFor(context, NavigationInstruction.Close) - context.activity.overridePendingTransition(animations.enter, animations.exit) - } - - fun createIntent(args: ExecutorArgs) = - Intent(args.fromContext.activity, args.navigator.contextType.java) - .addOpenInstruction(args.instruction) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableAnimationConversions.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableAnimationConversions.kt deleted file mode 100644 index a89b5956f..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableAnimationConversions.kt +++ /dev/null @@ -1,151 +0,0 @@ -package dev.enro.core.compose - -import android.animation.AnimatorInflater -import android.content.Context -import android.os.Parcelable -import android.util.AttributeSet -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.view.animation.AnimationUtils -import android.view.animation.Transformation -import androidx.compose.runtime.* -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.IntSize -import kotlinx.coroutines.delay -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue - -private class AnimatorView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr) { - override fun onTouchEvent(event: MotionEvent?): Boolean { - return false - } -} - -@Parcelize -internal data class AnimationResourceState( - val alpha: Float = 1.0f, - val scaleX: Float = 1.0f, - val scaleY: Float = 1.0f, - val translationX: Float = 0.0f, - val translationY: Float = 0.0f, - val rotationX: Float = 0.0f, - val rotationY: Float = 0.0f, - val transformOrigin: @RawValue TransformOrigin = TransformOrigin.Center, - - val playTime: Long = 0, - val isActive: Boolean = false -) : Parcelable - -@Composable -internal fun getAnimationResourceState( - animOrAnimator: Int, - size: IntSize -): AnimationResourceState { - val state = - remember(animOrAnimator) { mutableStateOf(AnimationResourceState(isActive = true)) } - if (animOrAnimator == 0) return state.value - - updateAnimationResourceStateFromAnim(state, animOrAnimator, size) - updateAnimationResourceStateFromAnimator(state, animOrAnimator, size) - - LaunchedEffect(animOrAnimator) { - val start = System.currentTimeMillis() - while (state.value.isActive) { - state.value = state.value.copy(playTime = System.currentTimeMillis() - start) - delay(8) - } - } - return state.value -} - -@Composable -private fun updateAnimationResourceStateFromAnim( - state: MutableState, - animOrAnimator: Int, - size: IntSize -) { - val context = LocalContext.current - val isAnim = - remember(animOrAnimator) { context.resources.getResourceTypeName(animOrAnimator) == "anim" } - if (!isAnim) return - if(size.width == 0 && size.height == 0) { - state.value = AnimationResourceState( - alpha = 0f, - isActive = true - ) - return - } - - val anim = remember(animOrAnimator, size) { - AnimationUtils.loadAnimation(context, animOrAnimator).apply { - initialize( - size.width, - size.height, - size.width, - size.height - ) - } - } - val transformation = Transformation() - anim.getTransformation(System.currentTimeMillis(), transformation) - - val v = FloatArray(9) - transformation.matrix.getValues(v) - state.value = AnimationResourceState( - alpha = transformation.alpha, - scaleX = v[android.graphics.Matrix.MSCALE_X], - scaleY = v[android.graphics.Matrix.MSCALE_Y], - translationX = v[android.graphics.Matrix.MTRANS_X], - translationY = v[android.graphics.Matrix.MTRANS_Y], - rotationX = 0.0f, - rotationY = 0.0f, - transformOrigin = TransformOrigin(0f, 0f), - - isActive = state.value.isActive && state.value.playTime < anim.duration, - playTime = state.value.playTime, - ) -} - -@Composable -private fun updateAnimationResourceStateFromAnimator( - state: MutableState, - animOrAnimator: Int, - size: IntSize -) { - val context = LocalContext.current - val isAnimator = - remember(animOrAnimator) { context.resources.getResourceTypeName(animOrAnimator) == "animator" } - if (!isAnimator) return - - val animator = remember(animOrAnimator, size) { - state.value = AnimationResourceState( - alpha = 0.0f, - isActive = true - ) - AnimatorInflater.loadAnimator(context, animOrAnimator) - } - val animatorView = remember(animOrAnimator, size) { - AnimatorView(context).apply { - layoutParams = ViewGroup.LayoutParams(size.width, size.height) - animator.setTarget(this) - animator.start() - } - } - - state.value = AnimationResourceState( - alpha = animatorView.alpha, - scaleX = animatorView.scaleX, - scaleY = animatorView.scaleY, - translationX = animatorView.translationX, - translationY = animatorView.translationY, - rotationX = animatorView.rotationX, - rotationY = animatorView.rotationY, - - isActive = state.value.isActive && animator.isRunning, - playTime = state.value.playTime - ) -} diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableContainer.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableContainer.kt deleted file mode 100644 index 02a22833c..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableContainer.kt +++ /dev/null @@ -1,228 +0,0 @@ -package dev.enro.core.compose - -import android.annotation.SuppressLint -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.SaveableStateHolder -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import dev.enro.core.NavigationContext -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.close -import dev.enro.core.internal.handle.NavigationHandleViewModel -import dev.enro.core.internal.handle.getNavigationHandleViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.util.* - -internal class EnroDestinationStorage : ViewModel() { - val destinations = mutableMapOf>() - - override fun onCleared() { - destinations.values - .flatMap { it.values } - .forEach { it.viewModelStore.clear() } - - super.onCleared() - } -} - -sealed class EmptyBehavior { - /** - * When this container is about to become empty, allow this container to become empty - */ - object AllowEmpty : EmptyBehavior() - - /** - * When this container is about to become empty, do not close the NavigationDestination in the - * container, but instead close the parent NavigationDestination (i.e. the owner of this container) - */ - object CloseParent : EmptyBehavior() - - /** - * When this container is about to become empty, execute an action. If the result of the action function is - * "true", then the action is considered to have consumed the request to become empty, and the container - * will not close the last navigation destination. When the action function returns "false", the default - * behaviour will happen, and the container will become empty. - */ - class Action( - val onEmpty: () -> Boolean - ) : EmptyBehavior() -} - -@Composable -fun rememberEnroContainerController( - initialState: List = emptyList(), - emptyBehavior: EmptyBehavior = EmptyBehavior.AllowEmpty, - accept: (NavigationKey) -> Boolean = { true }, -): EnroContainerController { - val viewModelStoreOwner = LocalViewModelStoreOwner.current!! - val destinationStorage = viewModel() - - val id = rememberSaveable { - UUID.randomUUID().toString() - } - - val saveableStateHolder = rememberSaveableStateHolder() - val controller = remember { - EnroContainerController( - id = id, - navigationHandle = viewModelStoreOwner.getNavigationHandleViewModel(), - accept = accept, - destinationStorage = destinationStorage, - emptyBehavior = emptyBehavior, - saveableStateHolder = saveableStateHolder - ) - } - - val savedBackstack = rememberSaveable( - key = id, - saver = createEnroContainerBackstackStateSaver { - controller.backstack.value - } - ) { - EnroContainerBackstackState( - backstackEntries = initialState.map { EnroContainerBackstackEntry(it, null) }, - exiting = null, - exitingIndex = -1, - lastInstruction = initialState.lastOrNull() ?: NavigationInstruction.Close, - skipAnimations = true - ) - } - - localComposableManager.registerState(controller) - return remember { - controller.setInitialBackstack(savedBackstack) - controller - } -} - -class EnroContainerController internal constructor( - val id: String, - val accept: (NavigationKey) -> Boolean, - internal val navigationHandle: NavigationHandleViewModel, - private val destinationStorage: EnroDestinationStorage, - private val emptyBehavior: EmptyBehavior, - internal val saveableStateHolder: SaveableStateHolder, -) { - private lateinit var mutableBackstack: MutableStateFlow - val backstack: StateFlow get() = mutableBackstack - - internal val navigationContext: NavigationContext<*> get() = navigationHandle.navigationContext!! - - private val destinationContexts = destinationStorage.destinations.getOrPut(id) { mutableMapOf() } - private val currentDestination get() = mutableBackstack.value.backstack - .mapNotNull { destinationContexts[it.instructionId] } - .lastOrNull { - it.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) - } - - val activeContext: NavigationContext<*>? get() = currentDestination?.getNavigationHandleViewModel()?.navigationContext - - internal fun setInitialBackstack(initialBackstack: EnroContainerBackstackState) { - if(::mutableBackstack.isInitialized) throw IllegalStateException() - mutableBackstack = MutableStateFlow(initialBackstack) - } - - fun push(instruction: NavigationInstruction.Open) { - mutableBackstack.value = mutableBackstack.value.push( - instruction, - navigationContext.childComposableManager.activeContainer?.id - ) - navigationContext.childComposableManager.setActiveContainerById(id) - } - - fun close() { - currentDestination ?: return - val closedState = mutableBackstack.value.close() - if(closedState.backstack.isEmpty()) { - when(emptyBehavior) { - EmptyBehavior.AllowEmpty -> { - /* If allow empty, pass through to default behavior */ - } - EmptyBehavior.CloseParent -> { - navigationContext.childComposableManager.setActiveContainerById(null) - navigationHandle.close() - return - } - is EmptyBehavior.Action -> { - val consumed = emptyBehavior.onEmpty() - if (consumed) { - return - } - } - } - } - navigationContext.childComposableManager.setActiveContainerById(mutableBackstack.value.backstackEntries.lastOrNull()?.previouslyActiveContainerId) - mutableBackstack.value = closedState - } - - internal fun onInstructionDisposed(instruction: NavigationInstruction.Open) { - if (mutableBackstack.value.exiting == instruction) { - mutableBackstack.value = mutableBackstack.value.copy( - exiting = null, - exitingIndex = -1 - ) - } - } - - internal fun getDestinationContext(instruction: NavigationInstruction.Open): ComposableDestinationContextReference { - val destinationContextReference = destinationContexts.getOrPut(instruction.instructionId) { - val controller = navigationContext.controller - val composeKey = instruction.navigationKey - val destination = controller.navigatorForKeyType(composeKey::class)!!.contextType.java - .newInstance() as ComposableDestination - - return@getOrPut getComposableDestinationContext( - instruction = instruction, - destination = destination, - parentContainer = this - ) - } - destinationContextReference.parentContainer = this@EnroContainerController - return destinationContextReference - } - - @SuppressLint("ComposableNaming") - @Composable - internal fun bindDestination(instruction: NavigationInstruction.Open) { - DisposableEffect(true) { - onDispose { - if(!mutableBackstack.value.backstack.contains(instruction)) { - destinationContexts.remove(instruction.instructionId) - } - } - } - } -} - -@OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class) -@Composable -fun EnroContainer( - modifier: Modifier = Modifier, - controller: EnroContainerController = rememberEnroContainerController(), -) { - key(controller.id) { - controller.saveableStateHolder.SaveableStateProvider(controller.id) { - val backstackState by controller.backstack.collectAsState() - - Box(modifier = modifier) { - backstackState.renderable.forEach { - key(it.instructionId) { - controller.getDestinationContext(it).Render() - controller.bindDestination(it) - } - } - } - } - } -} - diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableDestination.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableDestination.kt deleted file mode 100644 index 05ab046c8..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableDestination.kt +++ /dev/null @@ -1,246 +0,0 @@ -package dev.enro.core.compose - -import android.annotation.SuppressLint -import android.os.Bundle -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalSavedStateRegistryOwner -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.* -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.savedstate.SavedStateRegistry -import androidx.savedstate.SavedStateRegistryController -import androidx.savedstate.SavedStateRegistryOwner -import dagger.hilt.android.internal.lifecycle.HiltViewModelFactory -import dagger.hilt.internal.GeneratedComponentManagerHolder -import dev.enro.core.* -import dev.enro.core.controller.application -import dev.enro.core.internal.handle.getNavigationHandleViewModel -import dev.enro.viewmodel.EnroViewModelFactory - - -internal class ComposableDestinationContextReference( - val instruction: NavigationInstruction.Open, - val destination: ComposableDestination, - internal var parentContainer: EnroContainerController? -) : ViewModel(), - LifecycleOwner, - ViewModelStoreOwner, - HasDefaultViewModelProviderFactory, - SavedStateRegistryOwner { - - private val navigationController get() = requireParentContainer().navigationContext.controller - private val parentViewModelStoreOwner get() = requireParentContainer().navigationContext.viewModelStoreOwner - private val parentSavedStateRegistry get() = requireParentContainer().navigationContext.savedStateRegistryOwner.savedStateRegistry - internal val activity: FragmentActivity get() = requireParentContainer().navigationContext.activity - - private val arguments by lazy { Bundle().addOpenInstruction(instruction) } - private val savedState: Bundle? = - parentSavedStateRegistry.consumeRestoredStateForKey(instruction.instructionId) - private val savedStateController = SavedStateRegistryController.create(this) - private val viewModelStore: ViewModelStore = ViewModelStore() - - - @SuppressLint("StaticFieldLeak") - private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) - - private var defaultViewModelFactory: Pair = - 0 to ViewModelProvider.NewInstanceFactory() - - init { - destination.contextReference = this - destination.enableSavedStateHandles() - - savedStateController.performRestore(savedState) - lifecycleRegistry.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - when (event) { - Lifecycle.Event.ON_CREATE -> { - parentSavedStateRegistry.registerSavedStateProvider(instruction.instructionId) { - val outState = Bundle() - navigationController.onComposeContextSaved( - destination, - outState - ) - savedStateController.performSave(outState) - outState - } - navigationController.onComposeDestinationAttached( - destination, - savedState - ) - } - Lifecycle.Event.ON_DESTROY -> { - parentSavedStateRegistry.unregisterSavedStateProvider(instruction.instructionId) - viewModelStore.clear() - lifecycleRegistry.removeObserver(this) - } - else -> { - } - } - } - }) - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } - - override fun getLifecycle(): Lifecycle { - return lifecycleRegistry - } - - override fun getViewModelStore(): ViewModelStore { - return viewModelStore - } - - override fun getDefaultViewModelProviderFactory(): ViewModelProvider.Factory { - return defaultViewModelFactory.second - } - - override fun getDefaultViewModelCreationExtras(): CreationExtras { - return MutableCreationExtras().apply { - set(ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY, navigationController.application) - set(SAVED_STATE_REGISTRY_OWNER_KEY, this@ComposableDestinationContextReference) - set(VIEW_MODEL_STORE_OWNER_KEY, this@ComposableDestinationContextReference) - } - } - - - override val savedStateRegistry: SavedStateRegistry get() = - savedStateController.savedStateRegistry - - internal fun requireParentContainer(): EnroContainerController = parentContainer!! - - @Composable - private fun rememberDefaultViewModelFactory(navigationHandle: NavigationHandle): Pair { - return remember(parentViewModelStoreOwner.hashCode()) { - if (parentViewModelStoreOwner.hashCode() == defaultViewModelFactory.first) return@remember defaultViewModelFactory - - val generatedComponentManagerHolderClass = kotlin.runCatching { - GeneratedComponentManagerHolder::class.java - }.getOrNull() - - val factory = if (generatedComponentManagerHolderClass != null && activity is GeneratedComponentManagerHolder) { - HiltViewModelFactory.createInternal( - activity, - this, - arguments, - SavedStateViewModelFactory(activity.application, this, savedState) - ) - } else { - SavedStateViewModelFactory(activity.application, this, savedState) - } - - return@remember parentViewModelStoreOwner.hashCode() to EnroViewModelFactory( - navigationHandle, - factory - ) - } - } - - @Composable - fun Render() { - val saveableStateHolder = rememberSaveableStateHolder() - if (!lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.CREATED)) return - - val navigationHandle = remember { getNavigationHandleViewModel() } - val backstackState by requireParentContainer().backstack.collectAsState() - DisposableEffect(true) { - onDispose { - if (!backstackState.backstack.contains(instruction)) { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - } - } - } - - val isVisible = instruction == backstackState.visible - val animations = remember(isVisible) { - if (backstackState.skipAnimations) return@remember DefaultAnimations.none - animationsFor( - navigationHandle.navigationContext ?: return@remember DefaultAnimations.none, - backstackState.lastInstruction - ) - } - - EnroAnimatedVisibility( - visible = isVisible, - animations = animations - ) { - DisposableEffect(isVisible) { - if (isVisible) { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - } else { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) - } - onDispose { - if (isVisible) { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) - } else { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) - } - } - } - - defaultViewModelFactory = rememberDefaultViewModelFactory(navigationHandle) - - CompositionLocalProvider( - LocalLifecycleOwner provides this, - LocalViewModelStoreOwner provides this, - LocalSavedStateRegistryOwner provides this, - LocalNavigationHandle provides navigationHandle - ) { - saveableStateHolder.SaveableStateProvider(key = instruction.instructionId) { - destination.Render() - } - } - - DisposableEffect(true) { - onDispose { - requireParentContainer().onInstructionDisposed(instruction) - } - } - } - } -} - -internal fun getComposableDestinationContext( - instruction: NavigationInstruction.Open, - destination: ComposableDestination, - parentContainer: EnroContainerController? -): ComposableDestinationContextReference { - return ComposableDestinationContextReference( - instruction = instruction, - destination = destination, - parentContainer = parentContainer - ) -} - -abstract class ComposableDestination: LifecycleOwner, - ViewModelStoreOwner, - SavedStateRegistryOwner, - HasDefaultViewModelProviderFactory { - internal lateinit var contextReference: ComposableDestinationContextReference - - override val savedStateRegistry: SavedStateRegistry - get() = contextReference.savedStateRegistry - - override fun getLifecycle(): Lifecycle { - return contextReference.lifecycle - } - - override fun getViewModelStore(): ViewModelStore { - return contextReference.viewModelStore - } - - override fun getDefaultViewModelProviderFactory(): ViewModelProvider.Factory { - return contextReference.defaultViewModelProviderFactory - } - - override fun getDefaultViewModelCreationExtras(): CreationExtras { - return contextReference.defaultViewModelCreationExtras - } - - @Composable - abstract fun Render() -} diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableManager.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableManager.kt deleted file mode 100644 index f6492c85b..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableManager.kt +++ /dev/null @@ -1,90 +0,0 @@ -package dev.enro.core.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelLazy -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import dev.enro.core.NavigationContext -import dev.enro.core.NavigationKey -import dev.enro.core.parentContext - -class EnroComposableManager : ViewModel() { - val containers: MutableSet = mutableSetOf() - - private val activeContainerState: MutableState = mutableStateOf(null) - val activeContainer: EnroContainerController? get() = activeContainerState.value - - internal fun setActiveContainerById(id: String?) { - activeContainerState.value = containers.firstOrNull { it.id == id } - } - - fun setActiveContainer(containerController: EnroContainerController?) { - if(containerController == null) { - activeContainerState.value = null - return - } - val selectedContainer = containers.firstOrNull { it.id == containerController.id } - ?: throw IllegalStateException("EnroContainerController with id ${containerController.id} is not registered with this EnroComposableManager") - activeContainerState.value = selectedContainer - } - - @Composable - internal fun registerState(controller: EnroContainerController): Boolean { - DisposableEffect(controller) { - containers += controller - if(activeContainer == null) { - activeContainerState.value = controller - } - onDispose { - containers -= controller - if(activeContainer == controller) { - activeContainerState.value = null - } - } - } - rememberSaveable(controller, saver = Saver( - save = { _ -> - (activeContainer?.id == controller.id) - }, - restore = { value -> - if(value) { - activeContainerState.value = controller - } - } - )) {} - return true - } -} - -val localComposableManager @Composable get() = LocalViewModelStoreOwner.current!!.composableManger - -val ViewModelStoreOwner.composableManger: EnroComposableManager get() { - return ViewModelLazy( - viewModelClass = EnroComposableManager::class, - storeProducer = { viewModelStore }, - factoryProducer = { ViewModelProvider.NewInstanceFactory() } - ).value -} - -internal class ComposableHost( - internal val containerController: EnroContainerController -) - -internal fun NavigationContext<*>.composeHostFor(key: NavigationKey): ComposableHost? { - val primary = childComposableManager.activeContainer - if(primary?.accept?.invoke(key) == true) return ComposableHost(primary) - - val secondary = childComposableManager.containers.firstOrNull { - it.accept(key) - } - - return secondary?.let { ComposableHost(it) } - ?: parentContext()?.composeHostFor(key) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigationHandle.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigationHandle.kt deleted file mode 100644 index 90750c457..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigationHandle.kt +++ /dev/null @@ -1,48 +0,0 @@ -package dev.enro.core.compose - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import dev.enro.core.* -import dev.enro.core.internal.handle.getNavigationHandleViewModel - -@Composable -inline fun navigationHandle(): TypedNavigationHandle { - val navigationHandle = navigationHandle() - return remember { - navigationHandle.asTyped() - } -} - -@Composable -fun navigationHandle(): NavigationHandle { - val localNavigationHandle = LocalNavigationHandle.current - val localViewModelStoreOwner = LocalViewModelStoreOwner.current - - return remember { - localNavigationHandle ?: localViewModelStoreOwner!!.getNavigationHandleViewModel() - } -} - -@SuppressLint("ComposableNaming") -@Composable -fun NavigationHandle.configure(configuration: LazyNavigationHandleConfiguration.() -> Unit = {}) { - remember { - LazyNavigationHandleConfiguration(NavigationKey::class) - .apply(configuration) - .configure(this) - true - } -} - -@SuppressLint("ComposableNaming") -@Composable -inline fun TypedNavigationHandle.configure(configuration: LazyNavigationHandleConfiguration.() -> Unit = {}) { - remember { - LazyNavigationHandleConfiguration(T::class) - .apply(configuration) - .configure(this) - true - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigationResult.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigationResult.kt deleted file mode 100644 index d80635743..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigationResult.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.enro.core.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisallowComposableCalls -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import dev.enro.core.NavigationKey -import dev.enro.core.result.EnroResultChannel -import dev.enro.core.result.internal.ResultChannelImpl -import java.util.* - - -@Composable -inline fun registerForNavigationResult( - // Sometimes, particularly when interoperating between Compose and the legacy View system, - // it may be required to provide an id explicitly. This should not be required when using - // registerForNavigationResult from an entirely Compose-based screen. - // Remember a random UUID that will be used to uniquely identify this result channel - // within the composition. This is important to ensure that results are delivered if a Composable - // is used multiple times within the same composition (such as within a list). - // See ComposableListResultTests - id: String = rememberSaveable { - UUID.randomUUID().toString() - }, - noinline onResult: @DisallowComposableCalls (T) -> Unit -): EnroResultChannel> { - val navigationHandle = navigationHandle() - - val resultChannel = remember(onResult) { - ResultChannelImpl( - navigationHandle = navigationHandle, - resultType = T::class.java, - onResult = onResult, - additionalResultId = id - ) - } - - DisposableEffect(true) { - resultChannel.attach() - onDispose { - resultChannel.detach() - } - } - return resultChannel -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigator.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigator.kt deleted file mode 100644 index 0a36bf49e..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposableNavigator.kt +++ /dev/null @@ -1,57 +0,0 @@ -package dev.enro.core.compose - -import androidx.compose.runtime.Composable -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator -import kotlin.reflect.KClass - -class ComposableNavigator @PublishedApi internal constructor( - override val keyType: KClass, - override val contextType: KClass -) : Navigator - -fun createComposableNavigator( - keyType: Class, - composableType: Class -): Navigator = ComposableNavigator( - keyType = keyType.kotlin, - contextType = composableType.kotlin -) - -inline fun createComposableNavigator( - crossinline content: @Composable () -> Unit -): Navigator{ - val destination = object : ComposableDestination() { - @Composable - override fun Render() { - content() - } - } - return ComposableNavigator( - keyType = KeyType::class, - contextType = destination::class - ) as Navigator -} - - -fun createComposableNavigator( - keyType: Class, - content: @Composable () -> Unit -): Navigator{ - val destination = object : ComposableDestination() { - @Composable - override fun Render() { - content() - } - } - return ComposableNavigator( - keyType = keyType.kotlin, - contextType = destination::class - ) as Navigator -} - -inline fun createComposableNavigator() = - createComposableNavigator( - KeyType::class.java, - ComposableType::class.java - ) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/ComposeFragmentHost.kt b/enro-core/src/main/java/dev/enro/core/compose/ComposeFragmentHost.kt deleted file mode 100644 index 91798598f..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/ComposeFragmentHost.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.enro.core.compose - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import dagger.hilt.android.AndroidEntryPoint -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.fragment.internal.fragmentHostFrom -import dev.enro.core.navigationHandle -import kotlinx.parcelize.Parcelize - -internal abstract class AbstractComposeFragmentHostKey : NavigationKey { - abstract val instruction: NavigationInstruction.Open - abstract val fragmentContainerId: Int? -} - -@Parcelize -internal data class ComposeFragmentHostKey( - override val instruction: NavigationInstruction.Open, - override val fragmentContainerId: Int? -) : AbstractComposeFragmentHostKey() - -@Parcelize -internal data class HiltComposeFragmentHostKey( - override val instruction: NavigationInstruction.Open, - override val fragmentContainerId: Int? -) : AbstractComposeFragmentHostKey() - -abstract class AbstractComposeFragmentHost : Fragment() { - private val navigationHandle by navigationHandle() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val fragmentHost = container?.let { fragmentHostFrom(it) } - - return ComposeView(requireContext()).apply { - setContent { - val state = rememberEnroContainerController( - initialState = listOf(navigationHandle.key.instruction), - accept = fragmentHost?.accept ?: { true }, - emptyBehavior = EmptyBehavior.CloseParent - ) - - EnroContainer(controller = state) - } - } - } -} - -class ComposeFragmentHost : AbstractComposeFragmentHost() - -@AndroidEntryPoint -class HiltComposeFragmentHost : AbstractComposeFragmentHost() \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/DefaultComposableExecutor.kt b/enro-core/src/main/java/dev/enro/core/compose/DefaultComposableExecutor.kt deleted file mode 100644 index e0a94e099..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/DefaultComposableExecutor.kt +++ /dev/null @@ -1,51 +0,0 @@ -package dev.enro.core.compose - -import androidx.compose.material.ExperimentalMaterialApi -import dev.enro.core.* -import dev.enro.core.compose.dialog.BottomSheetDestination -import dev.enro.core.compose.dialog.ComposeDialogFragmentHostKey -import dev.enro.core.compose.dialog.DialogDestination -import dev.enro.core.fragment.internal.fragmentHostFor - -object DefaultComposableExecutor : NavigationExecutor( - fromType = Any::class, - opensType = ComposableDestination::class, - keyType = NavigationKey::class -) { - @OptIn(ExperimentalMaterialApi::class) - override fun open(args: ExecutorArgs) { - val host = args.fromContext.composeHostFor(args.key) - - val isDialog = DialogDestination::class.java.isAssignableFrom(args.navigator.contextType.java) - || BottomSheetDestination::class.java.isAssignableFrom(args.navigator.contextType.java) - - if(isDialog) { - args.fromContext.controller.open( - args.fromContext, - NavigationInstruction.Open.OpenInternal( - args.instruction.navigationDirection, - ComposeDialogFragmentHostKey(args.instruction) - ) - ) - return - } - - if(host == null || args.instruction.navigationDirection == NavigationDirection.REPLACE_ROOT) { - val fragmentHost = if(args.instruction.navigationDirection == NavigationDirection.REPLACE_ROOT) null else args.fromContext.fragmentHostFor(args.key) - args.fromContext.controller.open( - args.fromContext, - NavigationInstruction.Open.OpenInternal( - args.instruction.navigationDirection, - ComposeFragmentHostKey(args.instruction, fragmentHost?.containerId) - ) - ) - return - } - - host.containerController.push(args.instruction) - } - - override fun close(context: NavigationContext) { - context.contextReference.contextReference.requireParentContainer().close() - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/EnroAnimatedVisibility.kt b/enro-core/src/main/java/dev/enro/core/compose/EnroAnimatedVisibility.kt deleted file mode 100644 index 1761d3e12..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/EnroAnimatedVisibility.kt +++ /dev/null @@ -1,75 +0,0 @@ -package dev.enro.core.compose - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.unit.IntSize -import dev.enro.core.AnimationPair - -@OptIn(ExperimentalAnimationApi::class, ExperimentalComposeUiApi::class) -@Composable -internal fun EnroAnimatedVisibility( - visible: Boolean, - animations: AnimationPair, - content: @Composable () -> Unit -) { - val context = localActivity - val resourceAnimations = remember(animations) { - animations.asResource(context.theme) - } - - val size = remember { mutableStateOf(IntSize(0, 0)) } - val animationStateValues = getAnimationResourceState(if(visible) resourceAnimations.enter else resourceAnimations.exit, size.value) - val currentVisibility = remember { - mutableStateOf(false) - } - AnimatedVisibility( - modifier = Modifier - .onGloballyPositioned { - size.value = it.size - }, - visible = currentVisibility.value || animationStateValues.isActive, - enter = fadeIn( - animationSpec = tween(1), - initialAlpha = 1.0f - ), - exit = fadeOut( - animationSpec = tween(1), - targetAlpha = 1.0f - ), - ) { - Box( - modifier = Modifier - .graphicsLayer( - alpha = animationStateValues.alpha, - scaleX = animationStateValues.scaleX, - scaleY = animationStateValues.scaleY, - rotationX = animationStateValues.rotationX, - rotationY = animationStateValues.rotationY, - translationX = animationStateValues.translationX, - translationY = animationStateValues.translationY, - transformOrigin = animationStateValues.transformOrigin - ) - .pointerInteropFilter { _ -> - !visible - } - ) { - content() - } - } - SideEffect { - currentVisibility.value = visible - } -} diff --git a/enro-core/src/main/java/dev/enro/core/compose/EnroContainerBackstackState.kt b/enro-core/src/main/java/dev/enro/core/compose/EnroContainerBackstackState.kt deleted file mode 100644 index d87ef642e..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/EnroContainerBackstackState.kt +++ /dev/null @@ -1,107 +0,0 @@ -package dev.enro.core.compose - -import android.os.Parcelable -import androidx.compose.runtime.saveable.Saver -import dev.enro.core.NavigationDirection -import dev.enro.core.NavigationInstruction -import kotlinx.parcelize.Parcelize - -@Parcelize -data class EnroContainerBackstackEntry( - val instruction: NavigationInstruction.Open, - val previouslyActiveContainerId: String? -) : Parcelable - -data class EnroContainerBackstackState( - val lastInstruction: NavigationInstruction, - val backstackEntries: List, - val exiting: NavigationInstruction.Open?, - val exitingIndex: Int, - val skipAnimations: Boolean -) { - val backstack = backstackEntries.map { it.instruction } - val visible: NavigationInstruction.Open? = backstack.lastOrNull() - val renderable: List = run { - if(exiting == null) return@run backstack - if(backstack.contains(exiting)) return@run backstack - if(exitingIndex > backstack.lastIndex) return@run backstack + exiting - return@run backstack.flatMapIndexed { index, open -> - if(exitingIndex == index) return@flatMapIndexed listOf(exiting, open) - return@flatMapIndexed listOf(open) - } - } - - internal fun push( - instruction: NavigationInstruction.Open, - activeContainerId: String? - ): EnroContainerBackstackState { - return when (instruction.navigationDirection) { - NavigationDirection.FORWARD -> { - copy( - backstackEntries = backstackEntries + EnroContainerBackstackEntry( - instruction, - activeContainerId - ), - exiting = visible, - exitingIndex = backstack.lastIndex, - lastInstruction = instruction, - skipAnimations = false - ) - } - NavigationDirection.REPLACE -> { - copy( - backstackEntries = backstackEntries.dropLast(1) + EnroContainerBackstackEntry( - instruction, - activeContainerId - ), - exiting = visible, - exitingIndex = backstack.lastIndex, - lastInstruction = instruction, - skipAnimations = false - ) - } - NavigationDirection.REPLACE_ROOT -> { - copy( - backstackEntries = listOf( - EnroContainerBackstackEntry( - instruction, - activeContainerId - ) - ), - exiting = visible, - exitingIndex = 0, - lastInstruction = instruction, - skipAnimations = false - ) - } - } - } - - internal fun close(): EnroContainerBackstackState { - return copy( - backstackEntries = backstackEntries.dropLast(1), - exiting = visible, - exitingIndex = backstack.lastIndex, - lastInstruction = NavigationInstruction.Close, - skipAnimations = false - ) - } -} - -fun createEnroContainerBackstackStateSaver( - getCurrentState: () -> EnroContainerBackstackState? -) = Saver> ( - save = { value -> - val entries = getCurrentState()?.backstackEntries ?: value.backstackEntries - return@Saver ArrayList(entries) - }, - restore = { value -> - return@Saver EnroContainerBackstackState( - backstackEntries = value, - exiting = null, - exitingIndex = -1, - lastInstruction = value.lastOrNull()?.instruction ?: NavigationInstruction.Close, - skipAnimations = true - ) - } -) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/LocalActivity.kt b/enro-core/src/main/java/dev/enro/core/compose/LocalActivity.kt deleted file mode 100644 index 2f20f8412..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/LocalActivity.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.enro.core.compose - -import android.app.Activity -import android.content.ContextWrapper -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -internal val localActivity @Composable get() = LocalContext.current.let { - var ctx = it - while (ctx is ContextWrapper) { - if (ctx is Activity) { - return@let ctx - } - ctx = ctx.baseContext - } - throw IllegalStateException("Could not find Activity up from $it") -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/LocalNavigationHandle.kt b/enro-core/src/main/java/dev/enro/core/compose/LocalNavigationHandle.kt deleted file mode 100644 index b44acb3b2..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/LocalNavigationHandle.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.enro.core.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.TypedNavigationHandle -import dev.enro.core.asTyped -import dev.enro.core.internal.handle.getNavigationHandleViewModel - -val LocalNavigationHandle = compositionLocalOf { - null -} diff --git a/enro-core/src/main/java/dev/enro/core/compose/dialog/BottomSheetDestination.kt b/enro-core/src/main/java/dev/enro/core/compose/dialog/BottomSheetDestination.kt deleted file mode 100644 index 23cf25c32..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/dialog/BottomSheetDestination.kt +++ /dev/null @@ -1,134 +0,0 @@ -package dev.enro.core.compose.dialog - -import android.annotation.SuppressLint -import android.view.Window -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import dev.enro.core.AnimationPair -import dev.enro.core.DefaultAnimations -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.EnroContainerController -import dev.enro.core.getNavigationHandle -import dev.enro.core.requestClose - -@ExperimentalMaterialApi -class BottomSheetConfiguration : DialogConfiguration() { - internal var animatesToInitialState: Boolean = true - internal var animatesToHiddenOnClose: Boolean = true - internal var skipHalfExpanded: Boolean = false - internal lateinit var bottomSheetState: ModalBottomSheetState - - init { - animations = DefaultAnimations.none - } - - class Builder internal constructor( - private val bottomSheetConfiguration: BottomSheetConfiguration - ) { - fun setAnimatesToInitialState(animatesToInitialState: Boolean) { - bottomSheetConfiguration.animatesToInitialState = animatesToInitialState - } - - fun setAnimatesToHiddenOnClose(animatesToHidden: Boolean) { - bottomSheetConfiguration.animatesToHiddenOnClose = animatesToHidden - } - - fun setSkipHalfExpanded(skipHalfExpanded: Boolean) { - bottomSheetConfiguration.skipHalfExpanded = skipHalfExpanded - } - - fun setScrimColor(color: Color) { - bottomSheetConfiguration.scrimColor = color - } - - fun setAnimations(animations: AnimationPair) { - bottomSheetConfiguration.animations = animations - } - - @Deprecated("Use 'configureWindow' and set the soft input mode on the window directly") - fun setWindowInputMode(mode: WindowInputMode) { - bottomSheetConfiguration.softInputMode = mode - } - - fun configureWindow(block: (window: Window) -> Unit) { - bottomSheetConfiguration.configureWindow.value = block - } - } -} - -@ExperimentalMaterialApi -interface BottomSheetDestination { - val bottomSheetConfiguration: BottomSheetConfiguration -} - -@ExperimentalMaterialApi -val BottomSheetDestination.bottomSheetState get() = bottomSheetConfiguration.bottomSheetState - -@ExperimentalMaterialApi -@SuppressLint("ComposableNaming") -@Composable -fun BottomSheetDestination.configureBottomSheet(block: BottomSheetConfiguration.Builder.() -> Unit) { - remember { - BottomSheetConfiguration.Builder(bottomSheetConfiguration) - .apply(block) - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -internal fun EnroBottomSheetContainer( - controller: EnroContainerController, - destination: BottomSheetDestination -) { - val state = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - confirmStateChange = remember(Unit) { - fun(it: ModalBottomSheetValue): Boolean { - val isHidden = it == ModalBottomSheetValue.Hidden - val isHalfExpandedAndSkipped = it == ModalBottomSheetValue.HalfExpanded - && destination.bottomSheetConfiguration.skipHalfExpanded - val isDismissed = destination.bottomSheetConfiguration.isDismissed.value - - if (!isDismissed && (isHidden || isHalfExpandedAndSkipped)) { - controller.activeContext?.getNavigationHandle()?.requestClose() - return destination.bottomSheetConfiguration.isDismissed.value - } - return true - } - } - ) - destination.bottomSheetConfiguration.bottomSheetState = state - LaunchedEffect(destination.bottomSheetConfiguration.isDismissed.value) { - if(destination.bottomSheetConfiguration.isDismissed.value && destination.bottomSheetConfiguration.animatesToHiddenOnClose) { - state.hide() - } - } - - ModalBottomSheetLayout( - sheetState = state, - sheetContent = { - EnroContainer( - controller = controller, - modifier = Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 0.5.dp) - ) - }, - content = {} - ) - - LaunchedEffect(true) { - if (destination.bottomSheetConfiguration.animatesToInitialState) { - state.show() - } else { - state.snapTo(ModalBottomSheetValue.Expanded) - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/dialog/ComposeDialogFragmentHost.kt b/enro-core/src/main/java/dev/enro/core/compose/dialog/ComposeDialogFragmentHost.kt deleted file mode 100644 index 91dedfbd7..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/dialog/ComposeDialogFragmentHost.kt +++ /dev/null @@ -1,233 +0,0 @@ -package dev.enro.core.compose.dialog - -import android.animation.AnimatorInflater -import android.app.Dialog -import android.content.DialogInterface -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.view.* -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import android.widget.FrameLayout -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.ComposeView -import androidx.core.animation.addListener -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment -import dagger.hilt.android.AndroidEntryPoint -import dev.enro.core.* -import dev.enro.core.compose.EmptyBehavior -import dev.enro.core.compose.rememberEnroContainerController -import kotlinx.parcelize.Parcelize - - -internal abstract class AbstractComposeDialogFragmentHostKey : NavigationKey { - abstract val instruction: NavigationInstruction.Open -} - -@Parcelize -internal data class ComposeDialogFragmentHostKey( - override val instruction: NavigationInstruction.Open -) : AbstractComposeDialogFragmentHostKey() - -@Parcelize -internal data class HiltComposeDialogFragmentHostKey( - override val instruction: NavigationInstruction.Open -) : AbstractComposeDialogFragmentHostKey() - - -abstract class AbstractComposeDialogFragmentHost : DialogFragment() { - private val navigationHandle by navigationHandle() - - private lateinit var dialogConfiguration: DialogConfiguration - - private val composeViewId = View.generateViewId() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - setStyle( - STYLE_NO_FRAME, - requireActivity().packageManager.getActivityInfo( - requireActivity().componentName, - 0 - ).themeResource - ) - return super.onCreateDialog(savedInstanceState) - } - - override fun onDismiss(dialog: DialogInterface) { - if (dialog is Dialog) { - dialog.setOnKeyListener { _, _, _ -> - false - } - } - super.onDismiss(dialog) - } - - @OptIn(ExperimentalMaterialApi::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val composeView = ComposeView(requireContext()).apply { - id = composeViewId - setContent { - val controller = rememberEnroContainerController( - initialState = listOf(navigationHandle.key.instruction), - accept = { false }, - emptyBehavior = EmptyBehavior.CloseParent - ) - - val destination = controller.getDestinationContext(navigationHandle.key.instruction).destination - dialogConfiguration = when(destination) { - is BottomSheetDestination -> { - EnroBottomSheetContainer(controller, destination) - destination.bottomSheetConfiguration - } - is DialogDestination -> { - EnroDialogContainer(controller, destination) - destination.dialogConfiguration - } - else -> throw EnroException.DestinationIsNotDialogDestination("The @Composable destination for ${navigationHandle.key::class.java.simpleName} must be a DialogDestination or a BottomSheetDestination") - } - - DisposableEffect(dialogConfiguration.configureWindow.value) { - dialog?.window?.let { - it.setSoftInputMode(dialogConfiguration.softInputMode.mode) - dialogConfiguration.configureWindow.value.invoke(it) - } - onDispose { } - } - - DisposableEffect(true) { - enter() - onDispose { } - } - } - } - - return FrameLayout(requireContext()).apply { - isVisible = false - addView(composeView) - } - } - - private fun enter() { - val activity = activity ?: return - val dialogView = view ?: return - val composeView = view?.findViewById(composeViewId) ?: return - - dialogView.isVisible = true - dialogView.clearAnimation() - dialogView.animateToColor(dialogConfiguration.scrimColor) - composeView.animate( - dialogConfiguration.animations.asResource(activity.theme).enter, - ) - } - - override fun dismiss() { - val view = view ?: run { - super.dismiss() - return - } - val composeView = view.findViewById(composeViewId) ?: run { - super.dismiss() - return - } - dialogConfiguration.isDismissed.value = true - view.isVisible = true - view.clearAnimation() - view.animateToColor(Color.Transparent) - composeView.animate( - dialogConfiguration.animations.asResource(requireActivity().theme).exit, - onAnimationEnd = { - super.dismiss() - } - ) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - dialog!!.apply { - window!!.apply { - setOnKeyListener { _, keyCode, event -> - if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - navigationContext.leafContext().getNavigationHandleViewModel() - .requestClose() - return@setOnKeyListener true - } - return@setOnKeyListener false - } - - setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - setBackgroundDrawableResource(android.R.color.transparent) - setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - if(::dialogConfiguration.isInitialized) { - setSoftInputMode(dialogConfiguration.softInputMode.mode) - dialogConfiguration.configureWindow.value.invoke(this) - } - } - } - } -} - -class ComposeDialogFragmentHost : AbstractComposeDialogFragmentHost() - -@AndroidEntryPoint -class HiltComposeDialogFragmentHost : AbstractComposeDialogFragmentHost() - -internal fun View.animateToColor(color: Color) { - val backgroundColorInt = if (background is ColorDrawable) (background as ColorDrawable).color else 0 - val backgroundColor = Color(backgroundColorInt) - - animate() - .setDuration(225) - .setInterpolator(AccelerateDecelerateInterpolator()) - .setUpdateListener { - setBackgroundColor(lerp(backgroundColor, color, it.animatedFraction).toArgb()) - } - .start() -} - -internal fun View.animate( - animOrAnimator: Int, - onAnimationEnd: () -> Unit = {} -) { - clearAnimation() - if (animOrAnimator == 0) { - onAnimationEnd() - return - } - val isAnimation = runCatching { context.resources.getResourceTypeName(animOrAnimator) == "anim" }.getOrElse { false } - val isAnimator = !isAnimation && runCatching { context.resources.getResourceTypeName(animOrAnimator) == "animator" }.getOrElse { false } - - when { - isAnimator -> { - val animator = AnimatorInflater.loadAnimator(context, animOrAnimator) - animator.setTarget(this) - animator.addListener( - onEnd = { onAnimationEnd() } - ) - animator.start() - } - isAnimation -> { - val animation = AnimationUtils.loadAnimation(context, animOrAnimator) - animation.setAnimationListener(object: Animation.AnimationListener { - override fun onAnimationRepeat(animation: Animation?) {} - override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - onAnimationEnd() - } - }) - startAnimation(animation) - } - else -> { - onAnimationEnd() - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/dialog/DialogDestination.kt b/enro-core/src/main/java/dev/enro/core/compose/dialog/DialogDestination.kt deleted file mode 100644 index dc3c4392f..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/dialog/DialogDestination.kt +++ /dev/null @@ -1,78 +0,0 @@ -package dev.enro.core.compose.dialog - -import android.annotation.SuppressLint -import android.view.Window -import android.view.WindowManager -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import dev.enro.core.AnimationPair -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.EnroContainerController - -@Deprecated("Use 'configureWindow' and set the soft input mode on the window directly") -enum class WindowInputMode(internal val mode: Int) { - NOTHING(mode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING), - PAN(mode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN), - @Deprecated("See WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE") - RESIZE(mode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE), -} - -open class DialogConfiguration { - internal var isDismissed = mutableStateOf(false) - - internal var scrimColor: Color = Color.Transparent - internal var animations: AnimationPair = AnimationPair.Resource( - enter = 0, - exit = 0 - ) - - internal var softInputMode = WindowInputMode.RESIZE - internal var configureWindow = mutableStateOf<(window: Window) -> Unit>({}) - - class Builder internal constructor( - private val dialogConfiguration: DialogConfiguration - ) { - fun setScrimColor(color: Color) { - dialogConfiguration.scrimColor = color - } - - fun setAnimations(animations: AnimationPair) { - dialogConfiguration.animations = animations - } - - @Deprecated("Use 'configureWindow' and set the soft input mode on the window directly") - fun setWindowInputMode(mode: WindowInputMode) { - dialogConfiguration.softInputMode = mode - } - - fun configureWindow(block: (window: Window) -> Unit) { - dialogConfiguration.configureWindow.value = block - } - } -} - -interface DialogDestination { - val dialogConfiguration: DialogConfiguration -} - -val DialogDestination.isDismissed: Boolean - @Composable get() = dialogConfiguration.isDismissed.value - -@SuppressLint("ComposableNaming") -@Composable -fun DialogDestination.configureDialog(block: DialogConfiguration.Builder.() -> Unit) { - remember { - DialogConfiguration.Builder(dialogConfiguration) - .apply(block) - } -} - -@Composable -internal fun EnroDialogContainer( - controller: EnroContainerController, - destination: DialogDestination -) { - EnroContainer(controller = controller) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/compose/preview/PreviewNavigationHandle.kt b/enro-core/src/main/java/dev/enro/core/compose/preview/PreviewNavigationHandle.kt deleted file mode 100644 index e74bab76a..000000000 --- a/enro-core/src/main/java/dev/enro/core/compose/preview/PreviewNavigationHandle.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.enro.core.compose.preview - -import android.os.Bundle -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleRegistry -import dev.enro.core.EnroException -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.compose.LocalNavigationHandle -import dev.enro.core.controller.NavigationController - -internal class PreviewNavigationHandle( - override val instruction: NavigationInstruction.Open -) : NavigationHandle { - override val id: String = instruction.instructionId - override val key: NavigationKey = instruction.navigationKey - - override val controller: NavigationController = NavigationController() - override val additionalData: Bundle = Bundle.EMPTY - - private val lifecycleRegistry by lazy { - LifecycleRegistry(this).apply { - handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - } - } - - override fun executeInstruction(navigationInstruction: NavigationInstruction) { - - } - - override fun getLifecycle(): Lifecycle { - return lifecycleRegistry - } -} - -@Composable -fun EnroPreview( - navigationKey: T, - content: @Composable () -> Unit -) { - val isValidPreview = LocalInspectionMode.current && LocalNavigationHandle.current == null - if (!isValidPreview) { - throw EnroException.ComposePreviewException( - "EnroPreview can only be used when LocalInspectionMode.current is true (i.e. inside of an @Preview function) and when there is no LocalNavigationHandle already" - ) - } - CompositionLocalProvider( - LocalNavigationHandle provides PreviewNavigationHandle(NavigationInstruction.Forward(navigationKey)) - ) { - content() - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/DefaultComponent.kt b/enro-core/src/main/java/dev/enro/core/controller/DefaultComponent.kt deleted file mode 100644 index 01950e38b..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/DefaultComponent.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.enro.core.controller - -import dev.enro.core.activity.createActivityNavigator -import dev.enro.core.compose.ComposeFragmentHost -import dev.enro.core.compose.ComposeFragmentHostKey -import dev.enro.core.compose.HiltComposeFragmentHost -import dev.enro.core.compose.HiltComposeFragmentHostKey -import dev.enro.core.compose.dialog.ComposeDialogFragmentHost -import dev.enro.core.compose.dialog.ComposeDialogFragmentHostKey -import dev.enro.core.compose.dialog.HiltComposeDialogFragmentHost -import dev.enro.core.compose.dialog.HiltComposeDialogFragmentHostKey -import dev.enro.core.controller.interceptor.HiltInstructionInterceptor -import dev.enro.core.controller.interceptor.InstructionParentInterceptor -import dev.enro.core.fragment.createFragmentNavigator -import dev.enro.core.fragment.internal.HiltSingleFragmentActivity -import dev.enro.core.fragment.internal.HiltSingleFragmentKey -import dev.enro.core.fragment.internal.SingleFragmentActivity -import dev.enro.core.fragment.internal.SingleFragmentKey -import dev.enro.core.internal.NoKeyNavigator -import dev.enro.core.result.EnroResult - -internal val defaultComponent = createNavigationComponent { - plugin(EnroResult()) - - interceptor(InstructionParentInterceptor()) - interceptor(HiltInstructionInterceptor()) - - navigator(createActivityNavigator()) - navigator(NoKeyNavigator()) - navigator(createFragmentNavigator()) - navigator(createFragmentNavigator()) - - // These Hilt based navigators will fail to be created if Hilt is not on the class path, - // which is acceptable/allowed, so we'll attempt to add them, but not worry if they fail to be added - runCatching { - navigator(createActivityNavigator()) - } - - runCatching { - navigator(createFragmentNavigator()) - } - - runCatching { - navigator(createFragmentNavigator()) - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/NavigationApplication.kt b/enro-core/src/main/java/dev/enro/core/controller/NavigationApplication.kt deleted file mode 100644 index 397866661..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/NavigationApplication.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.enro.core.controller - -interface NavigationApplication { - val navigationController: NavigationController -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/NavigationComponentBuilder.kt b/enro-core/src/main/java/dev/enro/core/controller/NavigationComponentBuilder.kt deleted file mode 100644 index 07328d8c4..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/NavigationComponentBuilder.kt +++ /dev/null @@ -1,88 +0,0 @@ - package dev.enro.core.controller - -import android.app.Application -import dev.enro.core.* -import dev.enro.core.controller.interceptor.NavigationInstructionInterceptor -import dev.enro.core.plugins.EnroPlugin - -// TODO get rid of this, or give it a better name -interface NavigationComponentBuilderCommand { - fun execute(builder: NavigationComponentBuilder) -} - -class NavigationComponentBuilder { - @PublishedApi - internal val navigators: MutableList> = mutableListOf() - @PublishedApi - internal val overrides: MutableList> = mutableListOf() - @PublishedApi - internal val plugins: MutableList = mutableListOf() - @PublishedApi - internal val interceptors: MutableList = mutableListOf() - - fun navigator(navigator: Navigator<*, *>) { - navigators.add(navigator) - } - - fun override(override: NavigationExecutor<*, *, *>) { - overrides.add(override) - } - - inline fun override( - noinline block: NavigationExecutorBuilder.() -> Unit - ) { - overrides.add(createOverride(From::class, Opens::class, block)) - } - - fun plugin(enroPlugin: EnroPlugin) { - plugins.add(enroPlugin) - } - - fun interceptor(interceptor: NavigationInstructionInterceptor) { - interceptors.add(interceptor) - } - - fun component(builder: NavigationComponentBuilder) { - navigators.addAll(builder.navigators) - overrides.addAll(builder.overrides) - plugins.addAll(builder.plugins) - interceptors.addAll(builder.interceptors) - } - - internal fun build(): NavigationController { - return NavigationController().apply { - addComponent(this@NavigationComponentBuilder) - } - } -} - -/** - * Create a NavigationController from the NavigationControllerDefinition/DSL, and immediately attach it - * to the NavigationApplication from which this function was called. - */ -fun NavigationApplication.navigationController(block: NavigationComponentBuilder.() -> Unit = {}): NavigationController { - if(this !is Application) - throw IllegalArgumentException("A NavigationApplication must extend android.app.Application") - - return NavigationComponentBuilder() - .apply { generatedComponent?.execute(this) } - .apply(block) - .build() - .apply { install(this@navigationController) } -} - -private val NavigationApplication.generatedComponent get(): NavigationComponentBuilderCommand? = - runCatching { - Class.forName(this::class.java.name + "Navigation") - .newInstance() as NavigationComponentBuilderCommand - }.getOrNull() - -/** - * Create a NavigationControllerBuilder, without attaching it to a NavigationApplication. - * - * This method is primarily used for composing several builder definitions together in a final NavigationControllerBuilder. - */ -fun createNavigationComponent(block: NavigationComponentBuilder.() -> Unit): NavigationComponentBuilder { - return NavigationComponentBuilder() - .apply(block) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt b/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt deleted file mode 100644 index bbd1a4f97..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt +++ /dev/null @@ -1,169 +0,0 @@ -package dev.enro.core.controller - -import android.app.Application -import android.os.Bundle -import androidx.annotation.Keep -import dev.enro.core.* -import dev.enro.core.compose.ComposableDestination -import dev.enro.core.controller.container.ExecutorContainer -import dev.enro.core.controller.container.NavigatorContainer -import dev.enro.core.controller.container.PluginContainer -import dev.enro.core.controller.interceptor.InstructionInterceptorContainer -import dev.enro.core.controller.lifecycle.NavigationLifecycleController -import dev.enro.core.internal.handle.NavigationHandleViewModel -import kotlin.reflect.KClass - -class NavigationController internal constructor() { - internal var isInTest = false - - private val pluginContainer: PluginContainer = PluginContainer() - private val navigatorContainer: NavigatorContainer = NavigatorContainer() - private val executorContainer: ExecutorContainer = ExecutorContainer() - private val interceptorContainer: InstructionInterceptorContainer = InstructionInterceptorContainer() - private val contextController: NavigationLifecycleController = NavigationLifecycleController(executorContainer, pluginContainer) - - init { - addComponent(defaultComponent) - } - - fun addComponent(component: NavigationComponentBuilder) { - pluginContainer.addPlugins(component.plugins) - navigatorContainer.addNavigators(component.navigators) - executorContainer.addOverrides(component.overrides) - interceptorContainer.addInterceptors(component.interceptors) - } - - internal fun open( - navigationContext: NavigationContext, - instruction: NavigationInstruction.Open - ) { - val navigator = navigatorForKeyType(instruction.navigationKey::class) - ?: throw EnroException.MissingNavigator("Attempted to execute $instruction but could not find a valid navigator for the key type on this instruction") - - val executor = executorContainer.executorForOpen(navigationContext, navigator) - - val processedInstruction = interceptorContainer.intercept( - instruction, executor.context, navigator - ) - - if (processedInstruction.navigationKey::class != navigator.keyType) { - open(navigationContext, processedInstruction) - return - } - - val args = ExecutorArgs( - executor.context, - navigator, - processedInstruction.navigationKey, - processedInstruction - ) - - executor.executor.preOpened(executor.context) - executor.executor.open(args) - } - - internal fun close( - navigationContext: NavigationContext - ) { - val executor = executorContainer.executorForClose(navigationContext) - executor.preClosed(navigationContext) - executor.close(navigationContext) - } - - fun navigatorForContextType( - contextType: KClass<*> - ): Navigator<*, *>? { - return navigatorContainer.navigatorForContextType(contextType) - } - - fun navigatorForKeyType( - keyType: KClass - ): Navigator<*, *>? { - return navigatorContainer.navigatorForKeyType(keyType) - } - - internal fun executorForOpen( - fromContext: NavigationContext<*>, - instruction: NavigationInstruction.Open - ) = executorContainer.executorForOpen( - fromContext, - navigatorForKeyType(instruction.navigationKey::class) ?: throw IllegalStateException() - ) - - internal fun executorForClose(navigationContext: NavigationContext<*>) = - executorContainer.executorForClose(navigationContext) - - fun addOverride(navigationExecutor: NavigationExecutor<*, *, *>) { - executorContainer.addTemporaryOverride(navigationExecutor) - } - - fun removeOverride(navigationExecutor: NavigationExecutor<*, *, *>) { - executorContainer.removeTemporaryOverride(navigationExecutor) - } - - fun install(application: Application) { - navigationControllerBindings[application] = this - contextController.install(application) - pluginContainer.onAttached(this) - } - - @Keep - // This method is called reflectively by the test module to install/uninstall Enro from test applications - private fun installForJvmTests() { - pluginContainer.onAttached(this) - } - - @Keep - // This method is called reflectively by the test module to install/uninstall Enro from test applications - private fun uninstall(application: Application) { - navigationControllerBindings.remove(application) - contextController.uninstall(application) - } - - internal fun onComposeDestinationAttached( - destination: ComposableDestination, - savedInstanceState: Bundle? - ): NavigationHandleViewModel { - return contextController.onContextCreated( - ComposeContext(destination), - savedInstanceState - ) - } - - internal fun onComposeContextSaved(destination: ComposableDestination, outState: Bundle) { - contextController.onContextSaved( - ComposeContext(destination), - outState - ) - } - - companion object { - internal val navigationControllerBindings = - mutableMapOf() - } -} - -val Application.navigationController: NavigationController - get() { - synchronized(this) { - if (this is NavigationApplication) return navigationController - val bound = NavigationController.navigationControllerBindings[this] - if (bound == null) { - val navigationController = NavigationController() - NavigationController.navigationControllerBindings[this] = NavigationController() - navigationController.install(this) - return navigationController - } - return bound - } - } - -internal val NavigationController.application: Application - get() { - return NavigationController.navigationControllerBindings.entries - .firstOrNull { - it.value == this - } - ?.key - ?: throw EnroException.NavigationControllerIsNotAttached("NavigationController is not attached to an Application") - } \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/container/ExecutorContainer.kt b/enro-core/src/main/java/dev/enro/core/controller/container/ExecutorContainer.kt deleted file mode 100644 index 59ea8b708..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/container/ExecutorContainer.kt +++ /dev/null @@ -1,126 +0,0 @@ -package dev.enro.core.controller.container - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import dev.enro.core.* -import dev.enro.core.activity.ActivityNavigator -import dev.enro.core.activity.DefaultActivityExecutor -import dev.enro.core.compose.ComposableDestination -import dev.enro.core.compose.ComposableNavigator -import dev.enro.core.compose.DefaultComposableExecutor -import dev.enro.core.fragment.DefaultFragmentExecutor -import dev.enro.core.fragment.FragmentNavigator -import dev.enro.core.synthetic.DefaultSyntheticExecutor -import dev.enro.core.synthetic.SyntheticDestination -import dev.enro.core.synthetic.SyntheticNavigator -import kotlin.reflect.KClass - -internal class ExecutorContainer() { - private val overrides: MutableMap, KClass>, NavigationExecutor<*,*,*>> = mutableMapOf() - private val temporaryOverrides = mutableMapOf, KClass>, NavigationExecutor<*, *, *>>() - - fun addOverrides(executors: List>) { - executors.forEach { navigationExecutor -> - overrides[navigationExecutor.fromType to navigationExecutor.opensType] = navigationExecutor - } - } - - fun addTemporaryOverride(navigationExecutor: NavigationExecutor<*, *, *>) { - temporaryOverrides[navigationExecutor.fromType to navigationExecutor.opensType] = navigationExecutor - } - - fun removeTemporaryOverride(navigationExecutor: NavigationExecutor<*, *, *>) { - temporaryOverrides.remove(navigationExecutor.fromType to navigationExecutor.opensType) - } - - private fun overrideFor(types: Pair, KClass>): NavigationExecutor? { - return temporaryOverrides[types] ?: overrides[types] - } - - internal fun executorForOpen(fromContext: NavigationContext, navigator: Navigator<*, *>): OpenExecutorPair { - val opensContext = navigator.contextType - val opensContextIsActivity = navigator is ActivityNavigator - val opensContextIsFragment = navigator is FragmentNavigator - val opensContextIsComposable = navigator is ComposableNavigator - val opensContextIsSynthetic = navigator is SyntheticNavigator - - fun getOverrideExecutor(overrideContext: NavigationContext): OpenExecutorPair? { - val override = overrideFor(overrideContext.contextReference::class to opensContext) - ?: when (overrideContext.contextReference) { - is FragmentActivity -> overrideFor(FragmentActivity::class to opensContext) - is Fragment -> overrideFor(Fragment::class to opensContext) - is ComposableDestination -> overrideFor(ComposableDestination::class to opensContext) - else -> null - } - ?: overrideFor(Any::class to opensContext) - ?: when { - opensContextIsActivity -> overrideFor(overrideContext.contextReference::class to FragmentActivity::class) - opensContextIsFragment -> overrideFor(overrideContext.contextReference::class to Fragment::class) - opensContextIsComposable -> overrideFor(overrideContext.contextReference::class to ComposableDestination::class) - else -> null - } - ?: overrideFor(overrideContext.contextReference::class to Any::class) - - val parentContext = overrideContext.parentContext() - return when { - override != null -> OpenExecutorPair(overrideContext, override) - parentContext != null -> getOverrideExecutor(parentContext) - else -> null - } - } - - val override = getOverrideExecutor(fromContext) - return override ?: when { - opensContextIsActivity -> OpenExecutorPair(fromContext, DefaultActivityExecutor) - opensContextIsFragment -> OpenExecutorPair(fromContext, DefaultFragmentExecutor) - opensContextIsComposable -> OpenExecutorPair(fromContext, DefaultComposableExecutor) - opensContextIsSynthetic -> OpenExecutorPair(fromContext, DefaultSyntheticExecutor) - else -> throw EnroException.UnreachableState() - } - } - - @Suppress("UNCHECKED_CAST") - internal fun executorForClose(navigationContext: NavigationContext): NavigationExecutor { - val parentContextType = navigationContext.getNavigationHandleViewModel().instruction.internal.executorContext?.kotlin - val contextType = navigationContext.contextReference::class - - val override = parentContextType?.let { parentContext -> - val parentNavigator = navigationContext.controller.navigatorForContextType(parentContext) - - val parentContextIsActivity = parentNavigator is ActivityNavigator - val parentContextIsFragment = parentNavigator is FragmentNavigator - val parentContextIsComposable = parentNavigator is ComposableNavigator - - overrideFor(parentContext to contextType) - ?: when { - parentContextIsActivity -> overrideFor(FragmentActivity::class to contextType) - parentContextIsFragment -> overrideFor(Fragment::class to contextType) - parentContextIsComposable -> overrideFor(ComposableDestination::class to contextType) - else -> null - } - ?: overrideFor(Any::class to contextType) - ?: when(navigationContext.contextReference) { - is FragmentActivity -> overrideFor(parentContext to FragmentActivity::class) - is Fragment -> overrideFor(parentContext to Fragment::class) - is ComposableDestination -> overrideFor(parentContext to ComposableDestination::class) - else -> null - } - ?: overrideFor(parentContext to Any::class) - } as? NavigationExecutor - - return override ?: when (navigationContext) { - is ActivityContext -> DefaultActivityExecutor as NavigationExecutor - is FragmentContext -> DefaultFragmentExecutor as NavigationExecutor - is ComposeContext -> DefaultComposableExecutor as NavigationExecutor - } - } -} - -@Suppress("UNCHECKED_CAST") -class OpenExecutorPair( - context: NavigationContext, - executor: NavigationExecutor -) { - val context = context as NavigationContext - val executor = executor as NavigationExecutor -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/container/NavigatorContainer.kt b/enro-core/src/main/java/dev/enro/core/controller/container/NavigatorContainer.kt deleted file mode 100644 index 3c9911c43..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/container/NavigatorContainer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package dev.enro.core.controller.container - -import androidx.annotation.Keep -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator -import dev.enro.core.activity.createActivityNavigator -import dev.enro.core.compose.* -import dev.enro.core.compose.ComposeFragmentHostKey -import dev.enro.core.compose.dialog.HiltComposeDialogFragmentHostKey -import dev.enro.core.compose.HiltComposeFragmentHostKey -import dev.enro.core.compose.dialog.ComposeDialogFragmentHost -import dev.enro.core.compose.dialog.ComposeDialogFragmentHostKey -import dev.enro.core.compose.dialog.HiltComposeDialogFragmentHost -import dev.enro.core.fragment.createFragmentNavigator -import dev.enro.core.fragment.internal.HiltSingleFragmentActivity -import dev.enro.core.fragment.internal.HiltSingleFragmentKey -import dev.enro.core.fragment.internal.SingleFragmentActivity -import dev.enro.core.fragment.internal.SingleFragmentKey -import dev.enro.core.internal.NoKeyNavigator -import kotlin.reflect.KClass - -internal class NavigatorContainer { - private val navigatorsByKeyType = mutableMapOf, Navigator<*, *>>() - private val navigatorsByContextType = mutableMapOf, Navigator<*, *>>() - - fun addNavigators(navigators: List>) { - navigatorsByKeyType += navigators.associateBy { it.keyType } - navigatorsByContextType += navigators.associateBy { it.contextType } - - navigators.forEach { - require(navigatorsByKeyType[it.keyType] == it) { - "Found duplicated navigator binding! ${it.keyType.java.name} has been bound to multiple destinations." - } - } - } - - fun navigatorForContextType( - contextType: KClass<*> - ): Navigator<*, *>? { - return navigatorsByContextType[contextType] - } - - fun navigatorForKeyType( - keyType: KClass - ): Navigator<*, *>? { - return navigatorsByKeyType[keyType] - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/container/PluginContainer.kt b/enro-core/src/main/java/dev/enro/core/controller/container/PluginContainer.kt deleted file mode 100644 index f169aae5a..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/container/PluginContainer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.enro.core.controller.container - -import dev.enro.core.NavigationHandle -import dev.enro.core.controller.NavigationController -import dev.enro.core.plugins.EnroPlugin -import dev.enro.core.result.EnroResult - -internal class PluginContainer { - private val plugins: MutableList = mutableListOf() - private var attachedController: NavigationController? = null - - fun addPlugins( - plugins: List - ) { - this.plugins += plugins - attachedController?.let { attachedController -> - plugins.forEach { it.onAttached(attachedController) } - } - } - - fun hasPlugin(block: (EnroPlugin) -> Boolean): Boolean { - return plugins.any(block) - } - - internal fun onAttached(navigationController: NavigationController) { - require(attachedController == null) { - "This PluginContainer is already attached to a NavigationController!" - } - attachedController = navigationController - plugins.forEach { it.onAttached(navigationController) } - } - - internal fun onOpened(navigationHandle: NavigationHandle) { - plugins.forEach { it.onOpened(navigationHandle) } - } - - internal fun onActive(navigationHandle: NavigationHandle) { - plugins.forEach { it.onActive(navigationHandle) } - } - - internal fun onClosed(navigationHandle: NavigationHandle) { - plugins.forEach { it.onClosed(navigationHandle) } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/HiltInstructionInterceptor.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/HiltInstructionInterceptor.kt deleted file mode 100644 index cd91965a5..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/HiltInstructionInterceptor.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.enro.core.controller.interceptor - -import dagger.hilt.internal.GeneratedComponentManager -import dagger.hilt.internal.GeneratedComponentManagerHolder -import dev.enro.core.* -import dev.enro.core.compose.dialog.ComposeDialogFragmentHostKey -import dev.enro.core.compose.ComposeFragmentHostKey -import dev.enro.core.compose.dialog.HiltComposeDialogFragmentHostKey -import dev.enro.core.compose.HiltComposeFragmentHostKey -import dev.enro.core.fragment.internal.HiltSingleFragmentKey -import dev.enro.core.fragment.internal.SingleFragmentKey - -class HiltInstructionInterceptor : NavigationInstructionInterceptor { - - val generatedComponentManagerClass = kotlin.runCatching { - GeneratedComponentManager::class.java - }.getOrNull() - - val generatedComponentManagerHolderClass = kotlin.runCatching { - GeneratedComponentManagerHolder::class.java - }.getOrNull() - - override fun intercept( - instruction: NavigationInstruction.Open, - parentContext: NavigationContext<*>, - navigator: Navigator - ): NavigationInstruction.Open { - - val isHiltApplication = if(generatedComponentManagerClass != null) { - parentContext.activity.application is GeneratedComponentManager<*> - } else false - - val isHiltActivity = if(generatedComponentManagerHolderClass != null) { - parentContext.activity is GeneratedComponentManagerHolder - } else false - - val navigationKey = instruction.navigationKey - - if(navigationKey is SingleFragmentKey && isHiltApplication) { - return instruction.internal.copy( - navigationKey = HiltSingleFragmentKey( - instruction = navigationKey.instruction - ) - ) - } - - if(navigationKey is ComposeFragmentHostKey && isHiltActivity) { - return instruction.internal.copy( - navigationKey = HiltComposeFragmentHostKey( - instruction = navigationKey.instruction, - fragmentContainerId = navigationKey.fragmentContainerId - ) - ) - } - - if(navigationKey is ComposeDialogFragmentHostKey && isHiltActivity) { - return instruction.internal.copy( - navigationKey = HiltComposeDialogFragmentHostKey( - instruction = navigationKey.instruction, - ) - ) - } - - return instruction - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/InstructionInterceptorContainer.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/InstructionInterceptorContainer.kt deleted file mode 100644 index cde108687..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/InstructionInterceptorContainer.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.enro.core.controller.interceptor - -import dev.enro.core.NavigationContext -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator - -class InstructionInterceptorContainer { - - private val interceptors: MutableList = mutableListOf() - - fun addInterceptors(interceptors: List) { - this.interceptors.addAll(interceptors) - } - - fun intercept( - instruction: NavigationInstruction.Open, - parentContext: NavigationContext<*>, - navigator: Navigator - ): NavigationInstruction.Open { - return interceptors.fold(instruction) { acc, interceptor -> - interceptor.intercept(acc, parentContext, navigator) - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/InstructionParentInterceptor.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/InstructionParentInterceptor.kt deleted file mode 100644 index 7cc86f1f4..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/InstructionParentInterceptor.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.enro.core.controller.interceptor - -import dev.enro.core.* -import dev.enro.core.activity.ActivityNavigator -import dev.enro.core.compose.ComposableNavigator -import dev.enro.core.controller.container.NavigatorContainer -import dev.enro.core.fragment.FragmentNavigator -import dev.enro.core.fragment.internal.SingleFragmentActivity -import dev.enro.core.internal.NoKeyNavigator - -internal class InstructionParentInterceptor : NavigationInstructionInterceptor{ - - override fun intercept( - instruction: NavigationInstruction.Open, - parentContext: NavigationContext<*>, - navigator: Navigator - ): NavigationInstruction.Open { - return instruction - .setParentInstruction(parentContext, navigator) - .setExecutorContext(parentContext) - .setPreviouslyActiveId(parentContext) - } - - private fun NavigationInstruction.Open.setParentInstruction( - parentContext: NavigationContext<*>, - navigator: Navigator - ): NavigationInstruction.Open { - if (internal.parentInstruction != null) return this - - fun findCorrectParentInstructionFor(instruction: NavigationInstruction.Open?): NavigationInstruction.Open? { - if (navigator is FragmentNavigator) { - return instruction - } - if (navigator is ComposableNavigator) { - return instruction - } - - if (instruction == null) return null - val keyType = instruction.navigationKey::class - val parentNavigator = parentContext.controller.navigatorForKeyType(keyType) - if (parentNavigator is ActivityNavigator) return instruction - if (parentNavigator is NoKeyNavigator) return instruction - return findCorrectParentInstructionFor(instruction.internal.parentInstruction) - } - - val parentInstruction = when (navigationDirection) { - NavigationDirection.FORWARD -> findCorrectParentInstructionFor(parentContext.getNavigationHandleViewModel().instruction) - NavigationDirection.REPLACE -> findCorrectParentInstructionFor(parentContext.getNavigationHandleViewModel().instruction)?.internal?.parentInstruction - NavigationDirection.REPLACE_ROOT -> null - } - - return internal.copy(parentInstruction = parentInstruction?.internal) - } - - private fun NavigationInstruction.Open.setExecutorContext( - parentContext: NavigationContext<*> - ): NavigationInstruction.Open { - if(parentContext.contextReference is SingleFragmentActivity) { - return internal.copy(executorContext = parentContext.getNavigationHandleViewModel().instruction.internal.executorContext) - } - return internal.copy(executorContext = parentContext.contextReference::class.java) - } - - private fun NavigationInstruction.Open.setPreviouslyActiveId( - parentContext: NavigationContext<*> - ): NavigationInstruction.Open { - if(internal.previouslyActiveId != null) return this - return internal.copy( - previouslyActiveId = parentContext.childFragmentManager.primaryNavigationFragment?.getNavigationHandle()?.id - ) - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/NavigationInstructionInterceptor.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/NavigationInstructionInterceptor.kt deleted file mode 100644 index ad1ef0790..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/NavigationInstructionInterceptor.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.enro.core.controller.interceptor - -import dev.enro.core.NavigationContext -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator - -interface NavigationInstructionInterceptor { - fun intercept( - instruction: NavigationInstruction.Open, - parentContext: NavigationContext<*>, - navigator: Navigator - ): NavigationInstruction.Open -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/lifecycle/NavigationContextLifecycleCallbacks.kt b/enro-core/src/main/java/dev/enro/core/controller/lifecycle/NavigationContextLifecycleCallbacks.kt deleted file mode 100644 index 7ee54d52f..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/lifecycle/NavigationContextLifecycleCallbacks.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.enro.core.controller.lifecycle - -import android.app.Activity -import android.app.Application -import android.os.Bundle -import androidx.compose.ui.platform.compositionContext -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import dev.enro.core.ActivityContext -import dev.enro.core.FragmentContext -import dev.enro.core.navigationContext - -internal class NavigationContextLifecycleCallbacks ( - private val lifecycleController: NavigationLifecycleController -) { - - private val fragmentCallbacks = FragmentCallbacks() - private val activityCallbacks = ActivityCallbacks() - - fun install(application: Application) { - application.registerActivityLifecycleCallbacks(activityCallbacks) - } - - internal fun uninstall(application: Application) { - application.registerActivityLifecycleCallbacks(activityCallbacks) - } - - inner class ActivityCallbacks : Application.ActivityLifecycleCallbacks { - override fun onActivityCreated( - activity: Activity, - savedInstanceState: Bundle? - ) { - activity.window.decorView.compositionContext = null - if(activity !is FragmentActivity) return - activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true) - lifecycleController.onContextCreated(ActivityContext(activity), savedInstanceState) - } - - override fun onActivitySaveInstanceState( - activity: Activity, - outState: Bundle - ) { - if(activity !is FragmentActivity) return - lifecycleController.onContextSaved(activity.navigationContext, outState) - } - - override fun onActivityStarted(activity: Activity) {} - override fun onActivityResumed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) {} - override fun onActivityDestroyed(activity: Activity) {} - } - - inner class FragmentCallbacks : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentPreCreated( - fm: FragmentManager, - fragment: Fragment, - savedInstanceState: Bundle? - ) { - lifecycleController.onContextCreated(FragmentContext(fragment), savedInstanceState) - } - - override fun onFragmentSaveInstanceState( - fm: FragmentManager, - fragment: Fragment, - outState: Bundle - ) { - lifecycleController.onContextSaved(fragment.navigationContext, outState) - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/controller/lifecycle/NavigationLifecycleController.kt b/enro-core/src/main/java/dev/enro/core/controller/lifecycle/NavigationLifecycleController.kt deleted file mode 100644 index c6ec466b9..000000000 --- a/enro-core/src/main/java/dev/enro/core/controller/lifecycle/NavigationLifecycleController.kt +++ /dev/null @@ -1,130 +0,0 @@ -package dev.enro.core.controller.lifecycle - -import android.app.Application -import android.os.Bundle -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModelStoreOwner -import dev.enro.core.* -import dev.enro.core.compose.composableManger -import dev.enro.core.controller.container.ExecutorContainer -import dev.enro.core.controller.container.PluginContainer -import dev.enro.core.internal.NoNavigationKey -import dev.enro.core.internal.handle.NavigationHandleViewModel -import dev.enro.core.internal.handle.createNavigationHandleViewModel -import java.lang.ref.WeakReference -import java.util.* - -internal const val CONTEXT_ID_ARG = "dev.enro.core.ContextController.CONTEXT_ID" - -internal class NavigationLifecycleController( - private val executorContainer: ExecutorContainer, - private val pluginContainer: PluginContainer -) { - private val callbacks = NavigationContextLifecycleCallbacks(this) - - fun install(application: Application) { - callbacks.install(application) - } - - internal fun uninstall(application: Application) { - callbacks.uninstall(application) - } - - fun onContextCreated(context: NavigationContext<*>, savedInstanceState: Bundle?): NavigationHandleViewModel { - if (context is ActivityContext) { - context.activity.theme.applyStyle(android.R.style.Animation_Activity, false) - } - - val instruction = context.arguments.readOpenInstruction() - val contextId = instruction?.internal?.instructionId - ?: savedInstanceState?.getString(CONTEXT_ID_ARG) - ?: UUID.randomUUID().toString() - - val config = NavigationHandleProperty.getPendingConfig(context) - val defaultInstruction = NavigationInstruction - .Forward( - navigationKey = config?.defaultKey - ?: NoNavigationKey(context.contextReference::class.java, context.arguments) - ) - .internal - .copy(instructionId = contextId) - - val viewModelStoreOwner = context.contextReference as ViewModelStoreOwner - val handle = viewModelStoreOwner.createNavigationHandleViewModel( - context.controller, - instruction ?: defaultInstruction - ) - - // ensure the composable manager is created - val composableManager = viewModelStoreOwner.composableManger - - config?.applyTo(handle) - handle.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (!handle.hasKey) return - if (event == Lifecycle.Event.ON_CREATE) pluginContainer.onOpened(handle) - if (event == Lifecycle.Event.ON_DESTROY) pluginContainer.onClosed(handle) - - handle.navigationContext?.let { - updateActiveNavigationContext(it) - } - } - }) - handle.navigationContext = context - if (savedInstanceState == null) { - context.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == Lifecycle.Event.ON_START) { - executorContainer.executorForClose(context).postOpened(context) - context.lifecycle.removeObserver(this) - } - } - }) - } - if (savedInstanceState == null) handle.executeDeeplink() - return handle - } - - fun onContextSaved(context: NavigationContext<*>, outState: Bundle) { - outState.putString(CONTEXT_ID_ARG, context.getNavigationHandleViewModel().id) - } - - private fun updateActiveNavigationContext(context: NavigationContext<*>) { - if (!context.lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) return - - // Sometimes the context will be in an invalid state to correctly update, and will throw, - // in which case, we just ignore the exception - runCatching { - val root = context.rootContext() - val fragmentManager = when (context) { - is FragmentContext -> context.fragment.parentFragmentManager - else -> root.childFragmentManager - } - - fragmentManager.beginTransaction() - .runOnCommit { - runCatching { - activeNavigationHandle = WeakReference(root.leafContext().getNavigationHandleViewModel()) - } - } - .commitAllowingStateLoss() - } - } - - private var activeNavigationHandle: WeakReference = WeakReference(null) - set(value) { - if (value.get() == field.get()) return - field = value - - val active = value.get() - if (active != null) { - if (active is NavigationHandleViewModel && !active.hasKey) { - field = WeakReference(null) - return - } - pluginContainer.onActive(active) - } - } -} diff --git a/enro-core/src/main/java/dev/enro/core/fragment/DefaultFragmentExecutor.kt b/enro-core/src/main/java/dev/enro/core/fragment/DefaultFragmentExecutor.kt deleted file mode 100644 index 880b27c01..000000000 --- a/enro-core/src/main/java/dev/enro/core/fragment/DefaultFragmentExecutor.kt +++ /dev/null @@ -1,285 +0,0 @@ -package dev.enro.core.fragment - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.View -import androidx.core.view.ViewCompat -import androidx.fragment.app.* -import dev.enro.core.* -import dev.enro.core.compose.ComposableDestination -import dev.enro.core.compose.ComposableNavigator -import dev.enro.core.fragment.internal.AbstractSingleFragmentActivity -import dev.enro.core.fragment.internal.SingleFragmentKey -import dev.enro.core.fragment.internal.fragmentHostFor - -object DefaultFragmentExecutor : NavigationExecutor( - fromType = Any::class, - opensType = Fragment::class, - keyType = NavigationKey::class -) { - private val mainThreadHandler = Handler(Looper.getMainLooper()) - - override fun open(args: ExecutorArgs) { - val fromContext = args.fromContext - val navigator = args.navigator - val instruction = args.instruction - - navigator as FragmentNavigator<*, *> - - if (instruction.navigationDirection == NavigationDirection.REPLACE_ROOT) { - openFragmentAsActivity(fromContext, instruction) - return - } - - if (instruction.navigationDirection == NavigationDirection.REPLACE && fromContext.contextReference is FragmentActivity) { - openFragmentAsActivity(fromContext, instruction) - return - } - - if(instruction.navigationDirection == NavigationDirection.REPLACE && fromContext.contextReference is ComposableDestination) { - fromContext.contextReference.contextReference.requireParentContainer().close() - } - - if (!tryExecutePendingTransitions(fromContext, instruction)) return - if (fromContext is FragmentContext && !fromContext.fragment.isAdded) return - val fragment = createFragment( - fromContext.childFragmentManager, - navigator, - instruction - ) - - if(fragment is DialogFragment) { - if(fromContext.contextReference is DialogFragment) { - if (instruction.navigationDirection == NavigationDirection.REPLACE) { - fromContext.contextReference.dismiss() - } - - fragment.show( - fromContext.contextReference.parentFragmentManager, - instruction.instructionId - ) - } - else { - fragment.show(fromContext.childFragmentManager, instruction.instructionId) - } - return - } - - val host = fromContext.fragmentHostFor(instruction.navigationKey) - if (host == null) { - openFragmentAsActivity(fromContext, instruction) - return - } - - val activeFragment = host.fragmentManager.findFragmentById(host.containerId) - activeFragment?.view?.let { - ViewCompat.setZ(it, -1.0f) - } - - val animations = animationsFor(fromContext, instruction) - - host.fragmentManager.commitNow { - setCustomAnimations(animations.enter, animations.exit) - - if(fromContext.contextReference is DialogFragment && instruction.navigationDirection == NavigationDirection.REPLACE) { - fromContext.contextReference.dismiss() - } - - val isSafeToRetain = if(fromContext.contextReference is ComposableDestination) { - fromContext.contextReference.contextReference.requireParentContainer().backstack.value.backstack.isNotEmpty() - } else (activeFragment?.tag == instruction.internal.parentInstruction?.instructionId) - - if(activeFragment != null - && activeFragment.tag != null - && activeFragment.tag == activeFragment.navigationContext.getNavigationHandleViewModel().id - && isSafeToRetain - ){ - detach(activeFragment) - } - - replace(host.containerId, fragment, instruction.instructionId) - setPrimaryNavigationFragment(fragment) - } - } - - override fun close(context: NavigationContext) { - if(!tryExecutePendingTransitions(context.fragment.parentFragmentManager)) { - mainThreadHandler.post { - /* - * There are some cases where a Fragment's FragmentManager can be removed from the Fragment. - * There is (as far as I am aware) no easy way to check for the FragmentManager being removed from the - * Fragment, other than attempting to catch the exception that is thrown in the case of a missing - * parentFragmentManager. - * - * If a Fragment's parentFragmentManager has been destroyed or removed, there's very little we can - * do to resolve the problem, and the most likely case is if - * - * The most common case where this can occur is if a DialogFragment is closed in response - * to a nested Fragment closing with a result - this causes the DialogFragment to close, - * and then for the nested Fragment to attempt to close immediately afterwards, which fails because - * the nested Fragment is no longer attached to any fragment manager (and won't be again). - * - * see ResultTests.whenResultFlowIsLaunchedInDialogFragment_andCompletesThroughTwoNestedFragments_thenResultIsDelivered - */ - runCatching { context.fragment.parentFragmentManager } - .getOrElse { return@post } - context.controller.close(context) - } - return - } - - if (context.contextReference is DialogFragment) { - context.contextReference.dismiss() - context.fragment.parentFragmentManager.executePendingTransactions() - return - } - - val previousFragment = context.getPreviousFragment() - if (previousFragment == null && context.activity is AbstractSingleFragmentActivity) { - context.controller.close(context.activity.navigationContext) - return - } - - val animations = animationsFor(context, NavigationInstruction.Close) - // Checking for non-null context seems to be the best way to make sure parentFragmentManager will - // not throw an IllegalStateException when there is no parent fragment manager - val differentFragmentManagers = previousFragment?.context != null && previousFragment.parentFragmentManager != context.fragment.parentFragmentManager - if(differentFragmentManagers && previousFragment != null && !tryExecutePendingTransitions(previousFragment.parentFragmentManager)) { - mainThreadHandler.post { context.controller.close(context) } - return - } - - context.fragment.parentFragmentManager.commitNow { - setCustomAnimations(animations.enter, animations.exit) - remove(context.fragment) - - if (previousFragment != null && !differentFragmentManagers) { - when { - previousFragment.isDetached -> attach(previousFragment) - !previousFragment.isAdded -> add(context.contextReference.getContainerId(), previousFragment) - } - } - if(!differentFragmentManagers && context.fragment == context.fragment.parentFragmentManager.primaryNavigationFragment){ - setPrimaryNavigationFragment(previousFragment) - } - } - - if(previousFragment != null && differentFragmentManagers) { - previousFragment.parentFragmentManager.commitNow { - setPrimaryNavigationFragment(previousFragment) - } - } - } - - fun createFragment( - fragmentManager: FragmentManager, - navigator: Navigator<*, *>, - instruction: NavigationInstruction.Open - ): Fragment { - val fragment = fragmentManager.fragmentFactory.instantiate( - navigator.contextType.java.classLoader!!, - navigator.contextType.java.name - ) - - fragment.arguments = Bundle() - .addOpenInstruction(instruction) - - return fragment - } - - private fun tryExecutePendingTransitions( - fromContext: NavigationContext, - instruction: NavigationInstruction.Open - ): Boolean { - try { - fromContext.fragmentHostFor(instruction.navigationKey)?.fragmentManager?.executePendingTransactions() - return true - } catch (ex: IllegalStateException) { - mainThreadHandler.post { - if (fromContext is FragmentContext && !fromContext.fragment.isAdded) return@post - fromContext.getNavigationHandle().executeInstruction( - instruction - ) - } - return false - } - } - - private fun tryExecutePendingTransitions( - fragmentManager: FragmentManager - ): Boolean { - try { - fragmentManager.executePendingTransactions() - if(fragmentManager.isStateSaved) throw IllegalStateException() - return true - } catch (ex: IllegalStateException) { - return false - } - } - - private fun openFragmentAsActivity( - fromContext: NavigationContext, - instruction: NavigationInstruction.Open - ) { - if(fromContext.contextReference is DialogFragment && instruction.navigationDirection == NavigationDirection.REPLACE) { - // If we attempt to openFragmentAsActivity into a DialogFragment using the REPLACE direction, - // the Activity hosting the DialogFragment will be closed/replaced - // Instead, we close the fromContext's DialogFragment and call openFragmentAsActivity with the instruction changed to a forward direction - openFragmentAsActivity(fromContext, instruction.internal.copy(navigationDirection = NavigationDirection.FORWARD)) - fromContext.contextReference.dismiss() - return - } - - fromContext.controller.open( - fromContext, - NavigationInstruction.Open.OpenInternal( - navigationDirection = instruction.navigationDirection, - navigationKey = SingleFragmentKey(instruction.internal.copy( - navigationDirection = NavigationDirection.FORWARD, - parentInstruction = null - )) - ) - ) - } -} - -private fun NavigationContext.getPreviousFragment(): Fragment? { - val previouslyActiveFragment = getNavigationHandleViewModel().instruction.internal.previouslyActiveId - ?.let { previouslyActiveId -> - fragment.parentFragmentManager.fragments.firstOrNull { - it.getNavigationHandle().id == previouslyActiveId && it.isVisible - } - } - - val containerView = contextReference.getContainerId() - val parentInstruction = getNavigationHandleViewModel().instruction.internal.parentInstruction - parentInstruction ?: return previouslyActiveFragment - - val previousNavigator = controller.navigatorForKeyType(parentInstruction.navigationKey::class) - if (previousNavigator is ComposableNavigator) { - return fragment.parentFragmentManager.findFragmentByTag(getNavigationHandleViewModel().instruction.internal.previouslyActiveId) - } - if(previousNavigator !is FragmentNavigator) return previouslyActiveFragment - val previousHost = fragmentHostFor(parentInstruction.navigationKey) - val previousFragment = previousHost?.fragmentManager?.findFragmentByTag(parentInstruction.instructionId) - - return when { - previousFragment != null -> previousFragment - previousHost?.containerId == containerView -> previousHost.fragmentManager.fragmentFactory - .instantiate( - previousNavigator.contextType.java.classLoader!!, - previousNavigator.contextType.java.name - ) - .apply { - arguments = Bundle().addOpenInstruction( - parentInstruction.copy( - children = emptyList() - ) - ) - } - else -> previousHost?.fragmentManager?.findFragmentById(previousHost.containerId) - } ?: previouslyActiveFragment -} - -private fun Fragment.getContainerId() = (requireView().parent as View).id \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/fragment/FragmentNavigator.kt b/enro-core/src/main/java/dev/enro/core/fragment/FragmentNavigator.kt deleted file mode 100644 index acbc4be55..000000000 --- a/enro-core/src/main/java/dev/enro/core/fragment/FragmentNavigator.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.enro.core.fragment - -import androidx.fragment.app.Fragment -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator -import kotlin.reflect.KClass - -class FragmentNavigator @PublishedApi internal constructor( - override val keyType: KClass, - override val contextType: KClass, -) : Navigator - -fun createFragmentNavigator( - keyType: Class, - fragmentType: Class -): Navigator = FragmentNavigator( - keyType = keyType.kotlin, - contextType = fragmentType.kotlin, -) - -inline fun createFragmentNavigator(): Navigator = - createFragmentNavigator( - keyType = KeyType::class.java, - fragmentType = FragmentType::class.java, - ) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/fragment/internal/FragmentHost.kt b/enro-core/src/main/java/dev/enro/core/fragment/internal/FragmentHost.kt deleted file mode 100644 index 65b5511bf..000000000 --- a/enro-core/src/main/java/dev/enro/core/fragment/internal/FragmentHost.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.enro.core.fragment.internal - -import android.view.View -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import dev.enro.core.NavigationContext -import dev.enro.core.NavigationKey -import dev.enro.core.getNavigationHandleViewModel -import dev.enro.core.internal.handle.getNavigationHandleViewModel -import dev.enro.core.parentContext - -internal class FragmentHost( - internal val containerId: Int, - internal val fragmentManager: FragmentManager, - internal val accept: (NavigationKey) -> Boolean -) - -internal fun NavigationContext<*>.fragmentHostFor(key: NavigationKey): FragmentHost? { - val primaryFragment = childFragmentManager.primaryNavigationFragment - val activeContainerId = (primaryFragment?.view?.parent as? View)?.id - - val visibleContainers = getNavigationHandleViewModel().childContainers.filter { - when (contextReference) { - is FragmentActivity -> contextReference.findViewById(it.containerId).isVisible - is Fragment -> contextReference.requireView() - .findViewById(it.containerId).isVisible - else -> false - } - } - - val primaryDefinition = visibleContainers.firstOrNull { - it.containerId == activeContainerId && it.accept(key) - } - val definition = primaryDefinition - ?: visibleContainers.firstOrNull { it.accept(key) } - - return definition?.let { - FragmentHost( - containerId = it.containerId, - fragmentManager = childFragmentManager, - accept = it::accept - ) - } ?: parentContext()?.fragmentHostFor(key) -} - -internal fun Fragment.fragmentHostFrom(container: View): FragmentHost? { - return getNavigationHandleViewModel() - .navigationContext!! - .parentContext()!! - .getNavigationHandleViewModel() - .childContainers - .filter { - container.id == it.containerId - } - .firstOrNull() - ?.let { - FragmentHost( - containerId = it.containerId, - fragmentManager = childFragmentManager, - accept = it::accept - ) - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/fragment/internal/SingleFragmentActivity.kt b/enro-core/src/main/java/dev/enro/core/fragment/internal/SingleFragmentActivity.kt deleted file mode 100644 index 10c3d7fb5..000000000 --- a/enro-core/src/main/java/dev/enro/core/fragment/internal/SingleFragmentActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.enro.core.fragment.internal - -import android.os.Bundle -import android.widget.FrameLayout -import androidx.appcompat.app.AppCompatActivity -import dagger.hilt.android.AndroidEntryPoint -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.R -import dev.enro.core.navigationHandle -import kotlinx.parcelize.Parcelize - -internal abstract class AbstractSingleFragmentKey : NavigationKey { - abstract val instruction: NavigationInstruction.Open -} - -@Parcelize -internal data class SingleFragmentKey( - override val instruction: NavigationInstruction.Open -) : AbstractSingleFragmentKey() - -@Parcelize -internal data class HiltSingleFragmentKey( - override val instruction: NavigationInstruction.Open -) : AbstractSingleFragmentKey() - -internal abstract class AbstractSingleFragmentActivity : AppCompatActivity() { - private val handle by navigationHandle { - container(R.id.enro_internal_single_fragment_frame_layout) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(FrameLayout(this).apply { - id = R.id.enro_internal_single_fragment_frame_layout - }) - - if(savedInstanceState == null) { - handle.executeInstruction(handle.key.instruction) - } - } -} - -internal class SingleFragmentActivity : AbstractSingleFragmentActivity() - -@AndroidEntryPoint -internal class HiltSingleFragmentActivity : AbstractSingleFragmentActivity() \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/internal/Extensions.kt b/enro-core/src/main/java/dev/enro/core/internal/Extensions.kt deleted file mode 100644 index 507635447..000000000 --- a/enro-core/src/main/java/dev/enro/core/internal/Extensions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.enro.core.internal - -import android.content.res.Resources -import android.util.TypedValue - -internal fun Resources.Theme.getAttributeResourceId(attr: Int) = TypedValue().let { - resolveAttribute(attr, it, true) - it.resourceId -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/internal/NoNavigationKey.kt b/enro-core/src/main/java/dev/enro/core/internal/NoNavigationKey.kt deleted file mode 100644 index a80d01caf..000000000 --- a/enro-core/src/main/java/dev/enro/core/internal/NoNavigationKey.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.enro.core.internal - -import android.os.Bundle -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator -import kotlinx.parcelize.Parcelize -import kotlin.reflect.KClass - -@Parcelize -internal class NoNavigationKey( - val contextType: Class<*>, - val arguments: Bundle? -) : NavigationKey - -internal class NoKeyNavigator: Navigator { - override val keyType: KClass = NoNavigationKey::class - override val contextType: KClass = Nothing::class -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModel.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModel.kt deleted file mode 100644 index 0d29359d6..000000000 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModel.kt +++ /dev/null @@ -1,130 +0,0 @@ -package dev.enro.core.internal.handle - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.activity.OnBackPressedCallback -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.* -import dev.enro.core.* -import dev.enro.core.controller.NavigationController -import dev.enro.core.internal.NoNavigationKey - -internal open class NavigationHandleViewModel( - override val controller: NavigationController, - override val instruction: NavigationInstruction.Open -) : ViewModel(), NavigationHandle { - - private var pendingInstruction: NavigationInstruction? = null - - internal val hasKey get() = instruction.navigationKey !is NoNavigationKey - - override val key: NavigationKey get() { - if(instruction.navigationKey is NoNavigationKey) throw IllegalStateException( - "The navigation handle for the context ${navigationContext?.contextReference} has no NavigationKey" - ) - return instruction.navigationKey - } - override val id: String get() = instruction.instructionId - override val additionalData: Bundle get() = instruction.additionalData - - internal var childContainers = listOf() - internal var internalOnCloseRequested: () -> Unit = { close() } - - private val lifecycle = LifecycleRegistry(this) - - override fun getLifecycle(): Lifecycle { - return lifecycle - } - - internal var navigationContext: NavigationContext<*>? = null - set(value) { - field = value - if (value == null) return - registerLifecycleObservers(value) - registerOnBackPressedListener(value) - executePendingInstruction() - - if (lifecycle.currentState == Lifecycle.State.INITIALIZED) { - lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } - } - - private fun registerLifecycleObservers(context: NavigationContext) { - context.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == Lifecycle.Event.ON_DESTROY || event == Lifecycle.Event.ON_CREATE) return - lifecycle.handleLifecycleEvent(event) - } - }) - context.lifecycle.onEvent(Lifecycle.Event.ON_DESTROY) { - if (context == navigationContext) navigationContext = null - } - } - - private fun registerOnBackPressedListener(context: NavigationContext) { - if (context is ActivityContext) { - context.activity.addOnBackPressedListener { - context.leafContext().getNavigationHandleViewModel().requestClose() - } - } - } - - override fun executeInstruction(navigationInstruction: NavigationInstruction) { - pendingInstruction = navigationInstruction - executePendingInstruction() - } - - private fun executePendingInstruction() { - val context = navigationContext ?: return - val instruction = pendingInstruction ?: return - - pendingInstruction = null - context.runWhenContextActive { - when (instruction) { - is NavigationInstruction.Open -> { - context.controller.open(context, instruction) - } - NavigationInstruction.RequestClose -> { - internalOnCloseRequested() - } - NavigationInstruction.Close -> context.controller.close(context) - } - } - } - - internal fun executeDeeplink() { - if (instruction.children.isEmpty()) return - executeInstruction( - NavigationInstruction.Forward( - navigationKey = instruction.children.first(), - children = instruction.children.drop(1) - ) - ) - } - - override fun onCleared() { - lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - } -} - - -private fun Lifecycle.onEvent(on: Lifecycle.Event, block: () -> Unit) { - addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if(on == event) { - block() - } - } - }) -} - -private fun FragmentActivity.addOnBackPressedListener(block: () -> Unit) { - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - block() - } - }) -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt deleted file mode 100644 index 20b46be5a..000000000 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.enro.core.internal.handle - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelLazy -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.CreationExtras -import dev.enro.core.EnroException -import dev.enro.core.NavigationInstruction -import dev.enro.core.controller.NavigationController - -internal class NavigationHandleViewModelFactory( - private val navigationController: NavigationController, - private val instruction: NavigationInstruction.Open -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return create(modelClass, CreationExtras.Empty) - } - - override fun create(modelClass: Class, extras: CreationExtras): T { - if(navigationController.isInTest) { - return TestNavigationHandleViewModel( - navigationController, - instruction - ) as T - } - - return NavigationHandleViewModel( - navigationController, - instruction - ) as T - } -} - -internal fun ViewModelStoreOwner.createNavigationHandleViewModel( - navigationController: NavigationController, - instruction: NavigationInstruction.Open -): NavigationHandleViewModel { - return ViewModelLazy( - viewModelClass = NavigationHandleViewModel::class, - storeProducer = { viewModelStore }, - factoryProducer = { NavigationHandleViewModelFactory(navigationController, instruction) } - ).value -} - -internal class ExpectExistingNavigationHandleViewModelFactory( - private val viewModelStoreOwner: ViewModelStoreOwner -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - val name = viewModelStoreOwner::class.java.simpleName - throw EnroException.NoAttachedNavigationHandle( - "Attempted to get the NavigationHandle for $name, but $name not have a NavigationHandle attached." - ) - } - - override fun create(modelClass: Class, extras: CreationExtras): T { - val name = viewModelStoreOwner::class.java.simpleName - throw EnroException.NoAttachedNavigationHandle( - "Attempted to get the NavigationHandle for $name, but $name not have a NavigationHandle attached." - ) - } -} - -internal fun ViewModelStoreOwner.getNavigationHandleViewModel(): NavigationHandleViewModel { - return ViewModelLazy( - viewModelClass = NavigationHandleViewModel::class, - storeProducer = { viewModelStore }, - factoryProducer = { - ExpectExistingNavigationHandleViewModelFactory(this) - } - ).value -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt deleted file mode 100644 index af723d9f7..000000000 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.enro.core.internal.handle - -import dev.enro.core.NavigationInstruction -import dev.enro.core.controller.NavigationController - -internal class TestNavigationHandleViewModel( - controller: NavigationController, - instruction: NavigationInstruction.Open -) : NavigationHandleViewModel(controller, instruction) { - - private val instructions = mutableListOf() - - override fun executeInstruction(navigationInstruction: NavigationInstruction) { - instructions.add(navigationInstruction) - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/plugins/EnroLogger.kt b/enro-core/src/main/java/dev/enro/core/plugins/EnroLogger.kt deleted file mode 100644 index df69e36d9..000000000 --- a/enro-core/src/main/java/dev/enro/core/plugins/EnroLogger.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.enro.core.plugins - -import android.util.Log -import dev.enro.core.NavigationHandle - -class EnroLogger : EnroPlugin() { - override fun onOpened(navigationHandle: NavigationHandle) { - Log.d("Enro", "Opened: ${navigationHandle.key}") - } - - override fun onActive(navigationHandle: NavigationHandle) { - Log.d("Enro", "Active: ${navigationHandle.key}") - } - - override fun onClosed(navigationHandle: NavigationHandle) { - Log.d("Enro", "Closed: ${navigationHandle.key}") - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/plugins/EnroPlugin.kt b/enro-core/src/main/java/dev/enro/core/plugins/EnroPlugin.kt deleted file mode 100644 index 4ac4512a7..000000000 --- a/enro-core/src/main/java/dev/enro/core/plugins/EnroPlugin.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.enro.core.plugins - -import dev.enro.core.NavigationHandle -import dev.enro.core.controller.NavigationController - -abstract class EnroPlugin { - open fun onAttached(navigationController: NavigationController) {} - open fun onOpened(navigationHandle: NavigationHandle) {} - open fun onActive(navigationHandle: NavigationHandle) {} - open fun onClosed(navigationHandle: NavigationHandle) {} -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/result/EnroResult.kt b/enro-core/src/main/java/dev/enro/core/result/EnroResult.kt deleted file mode 100644 index 92d9334a2..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/EnroResult.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.enro.core.result - -import dev.enro.core.EnroException -import dev.enro.core.NavigationHandle -import dev.enro.core.controller.NavigationController -import dev.enro.core.plugins.EnroPlugin -import dev.enro.core.result.internal.PendingResult -import dev.enro.core.result.internal.ResultChannelId -import dev.enro.core.result.internal.ResultChannelImpl - -@PublishedApi -internal class EnroResult: EnroPlugin() { - private val channels = mutableMapOf>() - private val pendingResults = mutableMapOf() - - override fun onAttached(navigationController: NavigationController) { - controllerBindings[navigationController] = this - } - - override fun onActive(navigationHandle: NavigationHandle) { - channels.values - .filter { channel -> - pendingResults.any { it.key == channel.id } - } - .forEach { - val result = consumePendingResult(it.id) ?: return@forEach - it.consumeResult(result.result) - } - } - - internal fun addPendingResult(result: PendingResult) { - val channel = channels[result.resultChannelId] - if(channel != null) { - channel.consumeResult(result.result) - } - else { - pendingResults[result.resultChannelId] = result - } - } - - private fun consumePendingResult(resultChannelId: ResultChannelId): PendingResult? { - val result = pendingResults[resultChannelId] ?: return null - if(resultChannelId.resultId != result.resultChannelId.resultId) return null - pendingResults.remove(resultChannelId) - return result - } - - @PublishedApi - internal fun registerChannel(channel: ResultChannelImpl<*, *>) { - channels[channel.id] = channel - val result = consumePendingResult(channel.id) ?: return - channel.consumeResult(result.result) - } - - @PublishedApi - internal fun deregisterChannel(channel: ResultChannelImpl<*, *>) { - channels.remove(channel.id) - } - - companion object { - private val controllerBindings = mutableMapOf() - - @JvmStatic - fun from(navigationController: NavigationController): EnroResult { - return controllerBindings[navigationController] - ?: throw EnroException.EnroResultIsNotInstalled("EnroResult is not installed") - } - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/result/EnroResultChannel.kt b/enro-core/src/main/java/dev/enro/core/result/EnroResultChannel.kt deleted file mode 100644 index c9bdc3ff6..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/EnroResultChannel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package dev.enro.core.result - -import dev.enro.core.NavigationKey - -interface EnroResultChannel> { - fun open(key: Key) -} - -/** - * An UnmanagedEnroResultChannel is an EnroResultChannel that does not manage its own lifecycle. - * - * An UnmanagedEnroResultChannel will always be destroyed when the NavigationHandle that was used to - * create it is destroyed (unless the UnmanagedEnroResultChannel has been destroyed before this). - * - * An EnroResultChannel is usually tied to the lifecycle of some UI component, such as a Fragment, - * Activity, or Composable function. When this UI component is not visible, the EnroResultChannel - * will enter a detached state, which means it will not receive updates until the UI component is - * visible again. When the UI component becomes visible, it will be attached again and will receive - * any pending results, as well as being ready to receive any other results that are sent. - * - * An UnmanagedEnroResult channel allows you to manage the attach, detach, and destroy events of the - * EnroResultChannel yourself. - * - * This is primarily useful when a component wants to maintain a lifecycle that is shorter than - * the regular Activity/Fragment/ViewModel lifecycles. For example, in the ViewHolder for a RecyclerView, - * result channels should be destroyed when their associated View is detached/recycled, otherwise you - * could end up with thousands of active result channels. Similarly, if a custom View maintains a - * result channel, it may be useful to tie the UnmanagedEnroResultChannel's attach/detach to the - * View's onAttachedToWindow/onDetachedFromWindow, so that the View does not receive results while it - * is not attached to a window. - * - * There are extension functions available to manage an UnmanagedEnroResultChannel with a Lifecycle, - * View, or ViewHolder. - * - * @see managedByLifecycle - * @see managedByView - * @see managedByViewHolderItem - */ -interface UnmanagedEnroResultChannel> : EnroResultChannel { - fun attach() - fun detach() - fun destroy() -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/result/EnroResultExtensions.kt b/enro-core/src/main/java/dev/enro/core/result/EnroResultExtensions.kt deleted file mode 100644 index 479d397fc..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/EnroResultExtensions.kt +++ /dev/null @@ -1,295 +0,0 @@ -package dev.enro.core.result - -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.ViewModel -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.recyclerview.widget.RecyclerView -import dev.enro.core.* -import dev.enro.core.result.internal.LazyResultChannelProperty -import dev.enro.core.result.internal.PendingResult -import dev.enro.core.result.internal.ResultChannelId -import dev.enro.core.result.internal.ResultChannelImpl -import dev.enro.core.synthetic.SyntheticDestination -import dev.enro.viewmodel.getNavigationHandle -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KClass - -fun TypedNavigationHandle>.closeWithResult(result: T) { - val resultId = ResultChannelImpl.getResultId(this) - when { - resultId != null -> { - EnroResult.from(controller).addPendingResult( - PendingResult( - resultChannelId = resultId, - resultType = result::class, - result = result - ) - ) - } - controller.isInTest -> { - EnroResult.from(controller).addPendingResult( - PendingResult( - resultChannelId = ResultChannelId( - ownerId = id, - resultId = id - ), - resultType = result::class, - result = result - ) - ) - } - } - close() -} - -fun ExecutorArgs.sendResult( - result: T -) { - val resultId = ResultChannelImpl.getResultId(instruction) - if (resultId != null) { - EnroResult.from(fromContext.controller).addPendingResult( - PendingResult( - resultChannelId = resultId, - resultType = result::class, - result = result - ) - ) - } -} - -fun SyntheticDestination>.sendResult( - result: T -) { - val resultId = ResultChannelImpl.getResultId(instruction) - if (resultId != null) { - EnroResult.from(navigationContext.controller).addPendingResult( - PendingResult( - resultChannelId = resultId, - resultType = result::class, - result = result - ) - ) - } -} - -fun SyntheticDestination>.forwardResult( - navigationKey: NavigationKey.WithResult -) { - val resultId = ResultChannelImpl.getResultId(instruction) - - // If the incoming instruction does not have a resultId attached, we - // still want to open the screen we are being forwarded to - if (resultId == null) { - navigationContext.getNavigationHandle().executeInstruction( - NavigationInstruction.Forward(navigationKey) - ) - } else { - navigationContext.getNavigationHandle().executeInstruction( - ResultChannelImpl.overrideResultId( - NavigationInstruction.Forward(navigationKey), resultId - ) - ) - } -} - -@Deprecated("It is no longer required to provide a navigationHandle") -inline fun ViewModel.registerForNavigationResult( - navigationHandle: NavigationHandle, - noinline onResult: (T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = navigationHandle, - resultType = T::class.java, - onResult = onResult - ) - -inline fun ViewModel.registerForNavigationResult( - noinline onResult: (T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = getNavigationHandle(), - resultType = T::class.java, - onResult = onResult - ) - -inline fun > ViewModel.registerForNavigationResult( - key: KClass, - noinline onResult: (T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = getNavigationHandle(), - resultType = T::class.java, - onResult = onResult - ) - -inline fun FragmentActivity.registerForNavigationResult( - noinline onResult: (T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = this, - resultType = T::class.java, - onResult = onResult - ) - -inline fun > FragmentActivity.registerForNavigationResult( - key: KClass, - noinline onResult: (T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = this, - resultType = T::class.java, - onResult = onResult - ) - -inline fun Fragment.registerForNavigationResult( - noinline onResult: (T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = this, - resultType = T::class.java, - onResult = onResult - ) - -inline fun > Fragment.registerForNavigationResult( - key: KClass, - noinline onResult: (T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = this, - resultType = T::class.java, - onResult = onResult - ) - -/** - * Register for an UnmanagedEnroResultChannel. - * - * Be aware that you need to manage the attach/detach/destroy lifecycle events of this result channel - * yourself, including the initial attach. - * - * @see UnmanagedEnroResultChannel - * @see managedByLifecycle - * @see managedByView - */ -inline fun NavigationHandle.registerForNavigationResult( - id: String, - noinline onResult: (T) -> Unit -): UnmanagedEnroResultChannel> { - return ResultChannelImpl( - navigationHandle = this, - resultType = T::class.java, - onResult = onResult, - additionalResultId = id - ) -} - -/** - * Register for an UnmanagedEnroResultChannel. - * - * Be aware that you need to manage the attach/detach/destroy lifecycle events of this result channel - * yourself, including the initial attach. - * - * @see UnmanagedEnroResultChannel - * @see managedByLifecycle - * @see managedByView - */ -inline fun > NavigationHandle.registerForNavigationResult( - id: String, - key: KClass, - noinline onResult: (T) -> Unit -): UnmanagedEnroResultChannel { - return ResultChannelImpl( - navigationHandle = this, - resultType = T::class.java, - onResult = onResult, - additionalResultId = id - ) -} - -/** - * Sets up an UnmanagedEnroResultChannel to be managed by a Lifecycle. - * - * The result channel will be attached when the ON_START event occurs, detached when the ON_STOP - * event occurs, and destroyed when ON_DESTROY occurs. - */ -fun > UnmanagedEnroResultChannel.managedByLifecycle(lifecycle: Lifecycle): EnroResultChannel { - lifecycle.addObserver(LifecycleEventObserver { _, event -> - if(event == Lifecycle.Event.ON_START) attach() - if(event == Lifecycle.Event.ON_STOP) detach() - if(event == Lifecycle.Event.ON_DESTROY) destroy() - }) - return this -} - -/** - * Sets up an UnmanagedEnroResultChannel to be managed by a View. - * - * The result channel will be attached when the View is attached to a Window, - * detached when the view is detached from a Window, and destroyed when the ViewTreeLifecycleOwner - * lifecycle receives the ON_DESTROY event. - */ -fun > UnmanagedEnroResultChannel.managedByView(view: View): EnroResultChannel { - var activeLifecycle: Lifecycle? = null - val lifecycleObserver = LifecycleEventObserver { _, event -> - if(event == Lifecycle.Event.ON_DESTROY) destroy() - } - - if(view.isAttachedToWindow) { - attach() - val lifecycleOwner = view.findViewTreeLifecycleOwner() ?: throw IllegalStateException() - activeLifecycle = lifecycleOwner.lifecycle.apply { - addObserver(lifecycleObserver) - } - } - - view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View?) { - activeLifecycle?.removeObserver(lifecycleObserver) - - attach() - val lifecycleOwner = view.findViewTreeLifecycleOwner() ?: throw IllegalStateException() - activeLifecycle = lifecycleOwner.lifecycle.apply { - addObserver(lifecycleObserver) - } - } - - override fun onViewDetachedFromWindow(v: View?) { - detach() - } - }) - return this -} - -/** - * Sets up an UnmanagedEnroResultChannel to be managed by a ViewHolder's itemView. - * - * The result channel will be attached when the ViewHolder's itemView is attached to a Window, - * and destroyed when the ViewHolder's itemView is detached from a Window. - * - * It is important to understand that this management strategy is appropriate to be called when a - * ViewHolder is bound to a particular item from the RecyclerView Adapter, not in the constructor of the - * ViewHolder. When RecyclerView items are recycled, they are first detached from the Window and then re-bound, - * and then re-attached to the Window. This management strategy will cause the result channel to be - * destroyed every time the ViewHolder is re-bound to data through onBindViewHolder, which means the - * result channel should be created each time the ViewHolder is bound. - */ -fun > UnmanagedEnroResultChannel.managedByViewHolderItem(viewHolder: RecyclerView.ViewHolder): EnroResultChannel { - if(viewHolder.itemView.isAttachedToWindow) { - attach() - } - - viewHolder.itemView.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View?) { - attach() - } - - override fun onViewDetachedFromWindow(v: View?) { - destroy() - viewHolder.itemView.removeOnAttachStateChangeListener(this) - } - }) - return this -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt b/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt deleted file mode 100644 index 0293bd875..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt +++ /dev/null @@ -1,54 +0,0 @@ -package dev.enro.core.result.internal - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import dev.enro.core.EnroException -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.getNavigationHandle -import dev.enro.core.result.EnroResultChannel -import dev.enro.core.result.managedByLifecycle -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -@PublishedApi -internal class LazyResultChannelProperty>( - owner: Any, - resultType: Class, - onResult: (Result) -> Unit -) : ReadOnlyProperty> { - - private var resultChannel: EnroResultChannel? = null - - init { - val handle = when (owner) { - is FragmentActivity -> lazy { owner.getNavigationHandle() } - is Fragment -> lazy { owner.getNavigationHandle() } - is NavigationHandle -> lazy { owner as NavigationHandle } - else -> throw EnroException.UnreachableState() - } - val lifecycleOwner = owner as LifecycleOwner - val lifecycle = lifecycleOwner.lifecycle - - lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event != Lifecycle.Event.ON_CREATE) return; - resultChannel = ResultChannelImpl( - navigationHandle = handle.value, - resultType = resultType, - onResult = onResult - ).managedByLifecycle(lifecycle) - } - }) - } - - override fun getValue( - thisRef: Any, - property: KProperty<*> - ): EnroResultChannel = resultChannel ?: throw EnroException.ResultChannelIsNotInitialised( - "LazyResultChannelProperty's EnroResultChannel is not initialised. Are you attempting to use the result channel before the result channel's lifecycle owner has entered the CREATED state?" - ) -} diff --git a/enro-core/src/main/java/dev/enro/core/result/internal/PendingResult.kt b/enro-core/src/main/java/dev/enro/core/result/internal/PendingResult.kt deleted file mode 100644 index 5143115ef..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/internal/PendingResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.enro.core.result.internal - -import kotlin.reflect.KClass - -internal data class PendingResult( - val resultChannelId: ResultChannelId, - val resultType: KClass, - val result: Any -) diff --git a/enro-core/src/main/java/dev/enro/core/result/internal/ResultChannelId.kt b/enro-core/src/main/java/dev/enro/core/result/internal/ResultChannelId.kt deleted file mode 100644 index 9f74ffca1..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/internal/ResultChannelId.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.enro.core.result.internal - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class ResultChannelId( - val ownerId: String, - val resultId: String -) : Parcelable diff --git a/enro-core/src/main/java/dev/enro/core/result/internal/ResultChannelImpl.kt b/enro-core/src/main/java/dev/enro/core/result/internal/ResultChannelImpl.kt deleted file mode 100644 index 6778005bb..000000000 --- a/enro-core/src/main/java/dev/enro/core/result/internal/ResultChannelImpl.kt +++ /dev/null @@ -1,136 +0,0 @@ -package dev.enro.core.result.internal - -import androidx.annotation.Keep -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import dev.enro.core.* -import dev.enro.core.result.EnroResult -import dev.enro.core.result.UnmanagedEnroResultChannel - -private class ResultChannelProperties( - val navigationHandle: NavigationHandle, - val resultType: Class, - val onResult: (T) -> Unit, -) - -class ResultChannelImpl> @PublishedApi internal constructor( - navigationHandle: NavigationHandle, - resultType: Class, - onResult: (Result) -> Unit, - additionalResultId: String = "", -) : UnmanagedEnroResultChannel { - - /** - * The arguments passed to the ResultChannelImpl hold references to the external world, and - * can hold references to objects that could leak in memory. We store these properties inside - * a variable which is cleared to null when the ResultChannelImpl is destroyed, to ensure - * that these references are not held by the ResultChannelImpl after it has been destroyed. - */ - private var arguments: ResultChannelProperties? = ResultChannelProperties( - navigationHandle = navigationHandle, - resultType = resultType, - onResult = onResult, - ) - - /** - * The resultId being set here to the JVM class name of the onResult lambda is a key part of - * being able to make result channels work without providing an explicit id. The JVM will treat - * the lambda as an anonymous class, which is uniquely identifiable by it's class name. - * - * If the behaviour of the Kotlin/JVM interaction changes in a future release, it may be required - * to pass an explicit resultId as a part of the ResultChannelImpl constructor, which would need - * to be unique per result channel created. - * - * It is possible to have two result channels registered for the same result type: - * - * val resultOne = registerForResult { ... } - * val resultTwo = registerForResult { ... } - * - * // ... - * resultTwo.open(SomeNavigationKey( ... )) - * - * - * It's important in this case that resultTwo can be identified as the channel to deliver the - * result into, and this identification needs to be stable across application process death. - * The simple solution would be to require users to provide a name for the channel: - * - * val resultTwo = registerForResult("resultTwo") { ... } - * - * - * but using the anonymous class name is a nicer way to do things for now, with the ability to - * fall back to explicit identification of the channels in the case that the Kotlin/JVM behaviour - * changes in the future. - */ - internal val id = ResultChannelId( - ownerId = navigationHandle.id, - resultId = onResult::class.java.name +"@"+additionalResultId - ) - - private val lifecycleObserver = LifecycleEventObserver { _, event -> - if(event == Lifecycle.Event.ON_DESTROY) { - destroy() - } - }.apply { navigationHandle.lifecycle.addObserver(this) } - - override fun open(key: Key) { - val properties = arguments ?: return - properties.navigationHandle.executeInstruction( - NavigationInstruction.Forward(key).internal.copy( - resultId = id - ) - ) - } - - @Suppress("UNCHECKED_CAST") - internal fun consumeResult(result: Any) { - val properties = arguments ?: return - if (!properties.resultType.isAssignableFrom(result::class.java)) - throw EnroException.ReceivedIncorrectlyTypedResult("Attempted to consume result with wrong type!") - result as Result - properties.navigationHandle.runWhenHandleActive { - properties.onResult(result) - } - } - - override fun attach() { - val properties = arguments ?: return - if(properties.navigationHandle.lifecycle.currentState == Lifecycle.State.DESTROYED) return - EnroResult.from(properties.navigationHandle.controller) - .registerChannel(this) - } - - override fun detach() { - val properties = arguments ?: return - EnroResult.from(properties.navigationHandle.controller) - .deregisterChannel(this) - } - - override fun destroy() { - val properties = arguments ?: return - detach() - properties.navigationHandle.lifecycle.removeObserver(lifecycleObserver) - arguments = null - } - - internal companion object { - internal fun getResultId(navigationHandle: NavigationHandle): ResultChannelId? { - return navigationHandle.instruction.internal.resultId - } - - internal fun getResultId(instruction: NavigationInstruction.Open): ResultChannelId? { - return instruction.internal.resultId - } - - internal fun overrideResultId(instruction: NavigationInstruction.Open, resultId: ResultChannelId): NavigationInstruction.Open { - return instruction.internal.copy( - resultId = resultId - ) - } - } -} - -// Used reflectively by ResultExtensions in enro-test -@Keep -private fun getResultId(navigationInstruction: NavigationInstruction.Open): ResultChannelId? { - return navigationInstruction.internal.resultId -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/synthetic/DefaultSyntheticExecutor.kt b/enro-core/src/main/java/dev/enro/core/synthetic/DefaultSyntheticExecutor.kt deleted file mode 100644 index 2810d8d8f..000000000 --- a/enro-core/src/main/java/dev/enro/core/synthetic/DefaultSyntheticExecutor.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.enro.core.synthetic - -import dev.enro.core.* - -object DefaultSyntheticExecutor : NavigationExecutor, NavigationKey>( - fromType = Any::class, - opensType = SyntheticDestination::class, - keyType = NavigationKey::class -) { - override fun open(args: ExecutorArgs, out NavigationKey>) { - args.navigator as SyntheticNavigator - - val destination = args.navigator.destination.invoke() - destination.bind( - args.fromContext, - args.instruction - ) - destination.process() - } - - override fun close(context: NavigationContext>) { - throw EnroException.UnreachableState() - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/synthetic/SyntheticDestination.kt b/enro-core/src/main/java/dev/enro/core/synthetic/SyntheticDestination.kt deleted file mode 100644 index bd305843e..000000000 --- a/enro-core/src/main/java/dev/enro/core/synthetic/SyntheticDestination.kt +++ /dev/null @@ -1,43 +0,0 @@ -package dev.enro.core.synthetic - -import android.util.Log -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import dev.enro.core.NavigationContext -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.getNavigationHandle -import dev.enro.core.result.EnroResult - -abstract class SyntheticDestination { - - private var _navigationContext: NavigationContext? = null - val navigationContext get() = _navigationContext!! - - lateinit var key: T - internal set - - lateinit var instruction: NavigationInstruction.Open - internal set - - internal fun bind( - navigationContext: NavigationContext, - instruction: NavigationInstruction.Open - ) { - this._navigationContext = navigationContext - this.key = instruction.navigationKey as T - this.instruction = instruction - - navigationContext.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if(event == Lifecycle.Event.ON_DESTROY) { - navigationContext.lifecycle.removeObserver(this) - _navigationContext = null - } - } - }) - } - - abstract fun process() -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/synthetic/SyntheticNavigator.kt b/enro-core/src/main/java/dev/enro/core/synthetic/SyntheticNavigator.kt deleted file mode 100644 index 65cc761ef..000000000 --- a/enro-core/src/main/java/dev/enro/core/synthetic/SyntheticNavigator.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.enro.core.synthetic - -import dev.enro.core.NavigationKey -import dev.enro.core.Navigator -import kotlin.reflect.KClass - - -class SyntheticNavigator @PublishedApi internal constructor( - override val keyType: KClass, - val destination: () -> SyntheticDestination -) : Navigator> { - override val contextType: KClass> = SyntheticDestination::class -} - -fun createSyntheticNavigator( - navigationKeyType: Class, - destination: () -> SyntheticDestination -): Navigator> = - SyntheticNavigator( - keyType = navigationKeyType.kotlin, - destination = destination - ) - -inline fun createSyntheticNavigator( - noinline destination: () -> SyntheticDestination -): Navigator> = - SyntheticNavigator( - keyType = KeyType::class, - destination = destination - ) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelExtensions.kt b/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelExtensions.kt deleted file mode 100644 index fc6b03bf7..000000000 --- a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelExtensions.kt +++ /dev/null @@ -1,110 +0,0 @@ -package dev.enro.viewmodel - -import androidx.annotation.MainThread -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.* -import androidx.lifecycle.viewmodel.CreationExtras -import dev.enro.core.* -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KClass -import kotlin.reflect.KProperty - -class ViewModelNavigationHandleProperty @PublishedApi internal constructor( - viewModelType: KClass, - type: KClass, - block: LazyNavigationHandleConfiguration.() -> Unit -) : ReadOnlyProperty> { - - private val navigationHandle = EnroViewModelNavigationHandleProvider.get(viewModelType.java) - .asTyped(type) - .apply { - LazyNavigationHandleConfiguration(type) - .apply(block) - .configure(this) - } - - override fun getValue(thisRef: ViewModel, property: KProperty<*>): TypedNavigationHandle { - return navigationHandle - } -} - -fun ViewModel.navigationHandle( - type: KClass, - block: LazyNavigationHandleConfiguration.() -> Unit = {} -): ViewModelNavigationHandleProperty = - ViewModelNavigationHandleProperty(this::class, type, block) - -inline fun ViewModel.navigationHandle( - noinline block: LazyNavigationHandleConfiguration.() -> Unit = {} -): ViewModelNavigationHandleProperty = navigationHandle(T::class, block) - -@PublishedApi -internal fun ViewModel.getNavigationHandle(): NavigationHandle { - return getNavigationHandleTag() ?: EnroViewModelNavigationHandleProvider.get(this::class.java) -} - -@MainThread -inline fun FragmentActivity.enroViewModels( - noinline extrasProducer: (() -> CreationExtras)? = null, - noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null, -): Lazy { - - val factory = factoryProducer ?: { - defaultViewModelProviderFactory - } - - val navigationHandle = { - getNavigationHandle() - } - - return enroViewModels( - navigationHandle = navigationHandle, - storeProducer = { viewModelStore }, - factoryProducer = factory, - extrasProducer = { extrasProducer?.invoke() ?: defaultViewModelCreationExtras } - ) -} - -@MainThread -inline fun Fragment.enroViewModels( - noinline extrasProducer: (() -> CreationExtras)? = null, - noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null, -): Lazy { - - val factory = factoryProducer ?: { - defaultViewModelProviderFactory - } - - val navigationHandle = { - getNavigationHandle() - } - - return enroViewModels( - navigationHandle = navigationHandle, - storeProducer = { viewModelStore }, - factoryProducer = factory, - extrasProducer = { extrasProducer?.invoke() ?: defaultViewModelCreationExtras } - ) -} - -@MainThread -@PublishedApi -internal inline fun enroViewModels( - noinline navigationHandle: (() -> NavigationHandle), - noinline storeProducer: (() -> ViewModelStore), - noinline factoryProducer: (() -> ViewModelProvider.Factory), - noinline extrasProducer: () -> CreationExtras = { CreationExtras.Empty } -): Lazy { - return ViewModelLazy( - VM::class, - storeProducer, - { - EnroViewModelFactory( - navigationHandle.invoke(), - factoryProducer.invoke() - ) - }, - extrasProducer, - ) -} diff --git a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelFactory.kt b/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelFactory.kt deleted file mode 100644 index c8a516180..000000000 --- a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelFactory.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.enro.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.setNavigationHandleTag -import androidx.lifecycle.viewmodel.CreationExtras -import dev.enro.core.EnroException -import dev.enro.core.NavigationHandle - -@PublishedApi -internal class EnroViewModelFactory( - private val navigationHandle: NavigationHandle, - private val delegate: ViewModelProvider.Factory -) : ViewModelProvider.Factory { - override fun create(modelClass: Class, extras: CreationExtras): T { - EnroViewModelNavigationHandleProvider.put(modelClass, navigationHandle) - val viewModel = try { - delegate.create(modelClass, extras) as T - } catch (ex: RuntimeException) { - if(ex is EnroException) throw ex - throw EnroException.CouldNotCreateEnroViewModel( - "Failed to created ${modelClass.name} using factory ${delegate::class.java.name}.\n", - ex - ) - } - viewModel.setNavigationHandleTag(navigationHandle) - EnroViewModelNavigationHandleProvider.clear(modelClass) - return viewModel - } - - override fun create(modelClass: Class): T { - return create(modelClass, CreationExtras.Empty) - } -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelFactoryExtensions.kt b/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelFactoryExtensions.kt deleted file mode 100644 index f898f72e6..000000000 --- a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelFactoryExtensions.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.enro.viewmodel - -import androidx.compose.runtime.Composable -import androidx.lifecycle.ViewModelProvider -import dev.enro.core.NavigationHandle -import dev.enro.core.compose.navigationHandle - -fun ViewModelProvider.Factory.withNavigationHandle( - navigationHandle: NavigationHandle -): ViewModelProvider.Factory = EnroViewModelFactory( - navigationHandle = navigationHandle, - delegate = this -) - -@Composable -fun ViewModelProvider.Factory.withNavigationHandle() = withNavigationHandle( - navigationHandle = navigationHandle() -) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelNavigationHandleProvider.kt b/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelNavigationHandleProvider.kt deleted file mode 100644 index c9935af6e..000000000 --- a/enro-core/src/main/java/dev/enro/viewmodel/EnroViewModelNavigationHandleProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.enro.viewmodel - -import androidx.annotation.Keep -import dev.enro.core.EnroException -import dev.enro.core.NavigationHandle - -internal object EnroViewModelNavigationHandleProvider { - private val navigationHandles = mutableMapOf, NavigationHandle>() - - fun put(modelClass: Class<*>, navigationHandle: NavigationHandle) { - navigationHandles[modelClass] = navigationHandle - } - - fun clear(modelClass: Class<*>) { - navigationHandles.remove(modelClass) - } - - fun get(modelClass: Class<*>): NavigationHandle { - return navigationHandles[modelClass] - ?: throw EnroException.ViewModelCouldNotGetNavigationHandle( - "Could not get a NavigationHandle inside of ViewModel of type ${modelClass.simpleName}. Make sure you are using `by enroViewModels` and not `by viewModels`." - ) - } - - // Called reflectively by enro-test - @Keep - private fun clearAllForTest() { - navigationHandles.clear() - } -} \ No newline at end of file diff --git a/enro-core/src/main/res/anim/enro_no_op_animation.xml b/enro-core/src/main/res/anim/enro_no_op_animation.xml deleted file mode 100644 index a6b2fa2df..000000000 --- a/enro-core/src/main/res/anim/enro_no_op_animation.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - \ No newline at end of file diff --git a/enro-core/src/main/res/anim/enro_test_enter_animation.xml b/enro-core/src/main/res/anim/enro_test_enter_animation.xml deleted file mode 100644 index 8cbf453ef..000000000 --- a/enro-core/src/main/res/anim/enro_test_enter_animation.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - \ No newline at end of file diff --git a/enro-core/src/main/res/anim/enro_test_exit_animation.xml b/enro-core/src/main/res/anim/enro_test_exit_animation.xml deleted file mode 100644 index 6ef907c74..000000000 --- a/enro-core/src/main/res/anim/enro_test_exit_animation.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - \ No newline at end of file diff --git a/enro-core/src/main/res/animator/animator_example_enter.xml b/enro-core/src/main/res/animator/animator_example_enter.xml deleted file mode 100644 index fb5849aac..000000000 --- a/enro-core/src/main/res/animator/animator_example_enter.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/enro-core/src/main/res/animator/animator_example_two.xml b/enro-core/src/main/res/animator/animator_example_two.xml deleted file mode 100644 index 701fcb707..000000000 --- a/enro-core/src/main/res/animator/animator_example_two.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/enro-core/src/main/res/values/id.xml b/enro-core/src/main/res/values/id.xml deleted file mode 100644 index 24f47a796..000000000 --- a/enro-core/src/main/res/values/id.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/enro-lint/build.gradle b/enro-lint/build.gradle deleted file mode 100644 index 1a04e69d1..000000000 --- a/enro-lint/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -apply plugin: "java-library" -apply plugin: "kotlin" - -dependencies { - compileOnly deps.kotlin.stdLib - compileOnly deps.lint.checks - compileOnly deps.lint.api -} - -jar { - manifest { - attributes("Lint-Registry-v2": "dev.enro.lint.EnroIssueRegistry") - } -} - -compileKotlin { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } -} -sourceCompatibility = "8" -targetCompatibility = "8" \ No newline at end of file diff --git a/enro-lint/build.gradle.kts b/enro-lint/build.gradle.kts new file mode 100644 index 000000000..dc3162ab5 --- /dev/null +++ b/enro-lint/build.gradle.kts @@ -0,0 +1,25 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + id("kotlin") +} + +dependencies { + compileOnly(libs.kotlin.stdLib) + compileOnly(libs.lint.checks) + compileOnly(libs.lint.api) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + manifest { + attributes("Lint-Registry-v2" to "dev.enro.lint.EnroIssueRegistry") + } +} +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} diff --git a/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt b/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt index b980bc14b..23194e171 100644 --- a/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt +++ b/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt @@ -3,12 +3,20 @@ package dev.enro.lint import com.android.tools.lint.client.api.UElementHandler import com.android.tools.lint.detector.api.Detector import com.android.tools.lint.detector.api.JavaContext -import com.android.tools.lint.detector.api.TextFormat +import com.intellij.psi.PsiClass import com.intellij.psi.PsiClassType +import com.intellij.psi.PsiJvmModifiersOwner import com.intellij.psi.PsiType import com.intellij.psi.search.GlobalSearchScope -import com.intellij.psi.util.PsiUtil -import org.jetbrains.uast.* +import com.intellij.psi.util.TypeConversionUtil +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UClass +import org.jetbrains.uast.UClassLiteralExpression +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.getContainingUFile +import org.jetbrains.uast.getParentOfType +import org.jetbrains.uast.toUElementOfType @Suppress("UnstableApiUsage") class EnroIssueDetector : Detector(), Detector.UastScanner { @@ -17,95 +25,152 @@ class EnroIssueDetector : Detector(), Detector.UastScanner { } override fun createUastHandler(context: JavaContext): UElementHandler { + fun PsiJvmModifiersOwner.getNavigationDestinationType(): UClassLiteralExpression? { + return getAnnotation("dev.enro.annotations.NavigationDestination") + ?.findAttributeValue("key") + .toUElementOfType() + } + + // UCallExpression.receiverType is not always correct, so we need to manually resolve the receiver type, + // because "receiver" may be null when "receiverType" is not null, which likely indicates a "this.method()" call, + // which we need to resolve to be the containing class of the UCallExpression + fun UCallExpression.getActualReceiver(): PsiClass? { + return when (val receiver = receiver) { + // This is likely a static method call or a call on 'this' + null -> getParentOfType()?.javaPsi + // This is likely a qualified call (e.g. object.method()) + else -> context.evaluator.getTypeClass(receiver.getExpressionType()); + } + } + + val navigationHandlePropertyType = PsiType.getTypeByName( "dev.enro.core.NavigationHandleProperty", context.project.ideaProject, GlobalSearchScope.allScope(context.project.ideaProject) ) - val viewModelNavigationHandlePropertyType = PsiType.getTypeByName( - "dev.enro.viewmodel.NavigationHandleProperty", + fun visitNavigationHandlePropertyCall(node: UCallExpression) { + val returnType = node.returnType as? PsiClassType ?: return + if (!navigationHandlePropertyType.isAssignableFrom(returnType)) return + + val navigationHandleGenericType = returnType.parameters.first() + + val receiverClass = node.getActualReceiver() ?: return + val navigationDestinationExpression = receiverClass.getNavigationDestinationType() + val navigationDestinationType = navigationDestinationExpression?.type + + if (navigationDestinationExpression == null) { + val classSource = receiverClass.sourceElement?.text + context.report( + issue = missingNavigationDestinationAnnotation, + location = context.getLocation(node), + message = "${receiverClass.name} is not a NavigationDestination", + quickfixData = fix() + .name("Add NavigationDestination for ${navigationHandleGenericType.presentableText} to ${receiverClass.name}") + .replace() + .range(context.getLocation(element = node.getContainingUFile()!!)) + .text("$classSource") + .with("@dev.enro.annotations.NavigationDestination(${navigationHandleGenericType.presentableText}::class)\n$classSource") + .shortenNames() + .build() + ) + return + } + + if (navigationDestinationType != null && !navigationHandleGenericType.isAssignableFrom(navigationDestinationType)) { + context.report( + issue = incorrectlyTypedNavigationHandle, + location = context.getLocation(node), + message = "${receiverClass.name} expects a NavigationKey of type '${navigationDestinationType.presentableText}', which cannot be cast to '${navigationHandleGenericType.presentableText}'", + quickfixData = fix() + .name("Change type to ${navigationDestinationType.presentableText}") + .replace() + .text(navigationHandleGenericType.presentableText) + .with(navigationDestinationType.canonicalText) + .shortenNames() + .build() + ) + } + } + + val typedNavigationHandleType = PsiType.getTypeByName( + "dev.enro.core.TypedNavigationHandle", context.project.ideaProject, GlobalSearchScope.allScope(context.project.ideaProject) ) - return object : UElementHandler() { + val navigationKeyType = PsiType.getTypeByName( + "dev.enro.core.NavigationKey", + context.project.ideaProject, + GlobalSearchScope.allScope(context.project.ideaProject) + ) + + fun getComposableFunctionParent(node: UElement): UMethod? { + val parent = node.uastParent ?: return null + if (parent !is UMethod) { + return getComposableFunctionParent(parent) + } + parent.getAnnotation("androidx.compose.runtime.Composable") + ?: return getComposableFunctionParent(parent) + + return parent + } + + fun visitComposableNavigationHandleCall(node: UCallExpression) { + val composableParent = getComposableFunctionParent(node) ?: return + + val returnType = node.returnType as? PsiClassType ?: return + if (!typedNavigationHandleType.isAssignableFrom(returnType)) return - override fun visitMethod(node: UMethod) { - val isComposable = node.hasAnnotation("androidx.compose.runtime.Composable") - - val isNavigationDestination = - node.hasAnnotation("dev.enro.annotations.NavigationDestination") - - val isExperimentalComposableDestinationsEnabled = - node.hasAnnotation("dev.enro.annotations.ExperimentalComposableDestination") - - if (isComposable && isNavigationDestination && !isExperimentalComposableDestinationsEnabled) { - val annotationLocation = context.getLocation(element = node.findAnnotation("dev.enro.annotations.NavigationDestination")!!) - context.report( - issue = missingExperimentalComposableDestinationOptIn, - scopeClass = node, - location = annotationLocation, - message = missingExperimentalComposableDestinationOptIn.getExplanation( - TextFormat.TEXT - ), - quickfixData = fix() - .name("Add @NavigationDestination annotation") - .replace() - .range(annotationLocation) - .text("") - .with("@dev.enro.annotations.ExperimentalComposableDestination\n") - .shortenNames() - .build() - ) - } + val navigationHandleGenericType = TypeConversionUtil.erasure(returnType.parameters.first()) + val navigationDestinationExpression = composableParent.getNavigationDestinationType() + val navigationDestinationType = navigationDestinationExpression?.type + + if (navigationDestinationExpression == null) { + // allow references like navigationHandle because these aren't dangerous + if (navigationHandleGenericType == navigationKeyType) return + + val functionSource = composableParent.sourceElement?.text + context.report( + issue = missingNavigationDestinationAnnotationCompose, + location = context.getLocation(node), + message = "@Composable function '${composableParent.name}' is not annotated with '@NavigationDestination(${navigationHandleGenericType.presentableText})'", + quickfixData = fix() + .name("Add NavigationDestination to ${composableParent.name}") + .replace() + .range(context.getLocation(element = composableParent)) + .text("$functionSource") + .with("@dev.enro.annotations.NavigationDestination(${navigationHandleGenericType.presentableText}::class)\n$functionSource") + .shortenNames() + .build() + ) + return + } + + if (navigationDestinationType != null && !navigationHandleGenericType.isAssignableFrom(navigationDestinationType)) { + context.report( + issue = incorrectlyTypedNavigationHandle, + location = context.getLocation(node), + message = "${composableParent.name} expects a NavigationKey of type '${navigationDestinationType.presentableText}', which cannot be cast to '${navigationHandleGenericType.presentableText}'", + quickfixData = fix() + .name("Change type to ${navigationDestinationType.presentableText}") + .replace() + .text(navigationHandleGenericType.presentableText) + .with(navigationDestinationType.canonicalText) + .shortenNames() + .build() + ) } + } + + return object : UElementHandler() { + + override fun visitMethod(node: UMethod) {} override fun visitCallExpression(node: UCallExpression) { - val returnType = node.returnType as? PsiClassType ?: return - if (!navigationHandlePropertyType.isAssignableFrom(returnType)) return - - val navigationHandleGenericType = returnType.parameters.first() - - val receiverClass = PsiUtil.resolveClassInType(node.receiverType) ?: return - val navigationDestinationType = receiverClass - .getAnnotation("dev.enro.annotations.NavigationDestination") - ?.findAttributeValue("key") - .toUElementOfType() - ?.type - - if (navigationDestinationType == null) { - val classSource = receiverClass.sourceElement?.text - context.report( - issue = missingNavigationDestinationAnnotation, - location = context.getLocation(node), - message = "${receiverClass.name} is not a NavigationDestination", - quickfixData = fix() - .name("Add NavigationDestination for ${navigationHandleGenericType.presentableText} to ${receiverClass.name}") - .replace() - .range(context.getLocation(element = node.getContainingUFile()!!)) - .text("$classSource") - .with("@dev.enro.annotations.NavigationDestination(${navigationHandleGenericType.presentableText}::class)\n$classSource") - .shortenNames() - .build() - ) - return - } - - if (!navigationHandleGenericType.isAssignableFrom(navigationDestinationType)) { - context.report( - issue = incorrectlyTypedNavigationHandle, - location = context.getLocation(node), - message = "${receiverClass.name} expects a NavigationKey of type '${navigationDestinationType.presentableText}', which cannot be cast to '${navigationHandleGenericType.presentableText}'", - quickfixData = fix() - .name("Change type to ${navigationDestinationType.presentableText}") - .replace() - .text(navigationHandleGenericType.presentableText) - .with(navigationDestinationType.canonicalText) - .shortenNames() - .build() - ) - } + visitNavigationHandlePropertyCall(node) + visitComposableNavigationHandleCall(node) } } } diff --git a/enro-lint/src/main/java/dev/enro/lint/EnroIssueRegistry.kt b/enro-lint/src/main/java/dev/enro/lint/EnroIssueRegistry.kt index bb951246a..9c78d9e32 100644 --- a/enro-lint/src/main/java/dev/enro/lint/EnroIssueRegistry.kt +++ b/enro-lint/src/main/java/dev/enro/lint/EnroIssueRegistry.kt @@ -1,16 +1,21 @@ package dev.enro.lint import com.android.tools.lint.client.api.IssueRegistry +import com.android.tools.lint.client.api.Vendor import com.android.tools.lint.detector.api.CURRENT_API import com.android.tools.lint.detector.api.Issue @Suppress("UnstableApiUsage") class EnroIssueRegistry : IssueRegistry() { override val api: Int = CURRENT_API + override val vendor: Vendor = Vendor( + vendorName = "Enro", + identifier = "dev.enro", + ) override val issues: List = listOf( incorrectlyTypedNavigationHandle, missingNavigationDestinationAnnotation, - missingExperimentalComposableDestinationOptIn + missingNavigationDestinationAnnotationCompose, ) } \ No newline at end of file diff --git a/enro-lint/src/main/java/dev/enro/lint/Issues.kt b/enro-lint/src/main/java/dev/enro/lint/Issues.kt index 0895b0bd6..8b3a8f185 100644 --- a/enro-lint/src/main/java/dev/enro/lint/Issues.kt +++ b/enro-lint/src/main/java/dev/enro/lint/Issues.kt @@ -2,12 +2,16 @@ package dev.enro.lint -import com.android.tools.lint.detector.api.* +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity val incorrectlyTypedNavigationHandle = Issue.create( id = "IncorrectlyTypedNavigationHandle", briefDescription = "Incorrectly Typed Navigation Handle", - explanation = "NavigationHandleProperty is expecting a NavigationKey that is different to the NavigationKey of the NavigationDestination", + explanation = "NavigationHandle is expecting a NavigationKey that is different to the NavigationKey of the NavigationDestination", category = Category.PRODUCTIVITY, priority = 5, severity = Severity.ERROR, @@ -24,12 +28,17 @@ val missingNavigationDestinationAnnotation = Issue.create( implementation = Implementation(EnroIssueDetector::class.java, Scope.JAVA_FILE_SCOPE) ) -val missingExperimentalComposableDestinationOptIn = Issue.create( - id = "MissingExperimentalComposableDestinationOptIn", - briefDescription = "Using @NavigationDestination on @Composable functions is not enabled", - explanation = "You must explicitly opt-in to using @NavigationDestination on @Composable functions by using @ExperimentalComposableDestination", - category = Category.MESSAGES, +val missingNavigationDestinationAnnotationCompose = Issue.create( + id = "MissingNavigationDestinationAnnotation", + briefDescription = "Missing Navigation Destination Annotation", + explanation = "Requesting a TypedNavigationHandle here may cause a crash, " + + "as there is no guarantee that the nearest NavigationHandle has a NavigationKey of the requested type.\n\n" + + "This is not always an error, as there may be higher-level program logic that ensures this will succeed, " + + "but it is important to understand that this works in essentially the same way as an unchecked cast. " + + "If you do not need a TypedNavigationHandle, you can request an untyped NavigationHandle by removing the type" + + "arguments provided to the `navigationHandle` function", + category = Category.PRODUCTIVITY, priority = 5, - severity = Severity.ERROR, + severity = Severity.WARNING, implementation = Implementation(EnroIssueDetector::class.java, Scope.JAVA_FILE_SCOPE) ) \ No newline at end of file diff --git a/enro-masterdetail/build.gradle b/enro-masterdetail/build.gradle deleted file mode 100644 index 0551b974a..000000000 --- a/enro-masterdetail/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ -androidLibrary() -publishAndroidModule("dev.enro", "enro-masterdetail") - -dependencies { - releaseApi "dev.enro:enro-core:$versionName" - debugApi project(":enro-core") - - implementation deps.androidx.core - implementation deps.androidx.appcompat -} - -afterEvaluate { - tasks.findByName("preReleaseBuild") - .dependsOn(":enro-core:publishToMavenLocal") -} \ No newline at end of file diff --git a/enro-masterdetail/consumer-rules.pro b/enro-masterdetail/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/enro-masterdetail/src/main/AndroidManifest.xml b/enro-masterdetail/src/main/AndroidManifest.xml deleted file mode 100644 index bc82d6b98..000000000 --- a/enro-masterdetail/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/enro-masterdetail/src/main/java/dev/enro/masterdetail/MasterDetailComponent.kt b/enro-masterdetail/src/main/java/dev/enro/masterdetail/MasterDetailComponent.kt deleted file mode 100644 index 411165d2e..000000000 --- a/enro-masterdetail/src/main/java/dev/enro/masterdetail/MasterDetailComponent.kt +++ /dev/null @@ -1,134 +0,0 @@ -package dev.enro.masterdetail - -import android.util.Log -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import dev.enro.core.NavigationKey -import dev.enro.core.addOpenInstruction -import dev.enro.core.activity -import dev.enro.core.fragment -import dev.enro.core.controller.NavigationController -import dev.enro.core.activity.DefaultActivityExecutor -import dev.enro.core.ExecutorArgs -import dev.enro.core.controller.navigationController -import dev.enro.core.createOverride -import dev.enro.core.forward -import dev.enro.core.getNavigationHandle -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KClass -import kotlin.reflect.KProperty - -class MasterDetailController - -class MasterDetailProperty( - private val lifecycleOwner: LifecycleOwner, - private val owningType: KClass, - @IdRes private val masterContainer: Int, - private val masterKey: KClass, - @IdRes private val detailContainer: Int, - private val detailKey: KClass, - private val initialMasterKey: () -> NavigationKey -) : ReadOnlyProperty { - - private lateinit var masterDetailController: MasterDetailController - private lateinit var navigationController: NavigationController - - private val masterOverride by lazy { - val masterType = navigationController.navigatorForKeyType(masterKey)!!.contextType as KClass - createOverride(owningType, masterType) { - opened { - val fragment = it.fromContext.childFragmentManager.fragmentFactory.instantiate( - masterType.java.classLoader!!, - masterType.java.name - ).addOpenInstruction(it.instruction) - - it.fromContext.childFragmentManager.beginTransaction() - .replace(masterContainer, fragment) - .setPrimaryNavigationFragment(fragment) - .commitNow() - } - - closed { - it.activity.finish() - } - } - } - - private val detailOverride by lazy { - val detailType = navigationController.navigatorForKeyType(detailKey)!!.contextType as KClass - createOverride(owningType, detailType) { - opened { - if (!Fragment::class.java.isAssignableFrom(it.navigator.contextType.java)) { - Log.e( - "Enro", - "Attempted to open ${detailKey::class.java} as a Detail in ${it.fromContext.contextReference}, " + - "but ${detailKey::class.java}'s NavigationDestination is not a Fragment! Defaulting to standard navigation" - ) - DefaultActivityExecutor.open(it as ExecutorArgs) - return@opened - } - - val fragment = it.fromContext.childFragmentManager.fragmentFactory.instantiate( - detailType.java.classLoader!!, - detailType.java.name - ).addOpenInstruction(it.instruction) - - it.fromContext.childFragmentManager.beginTransaction() - .replace(detailContainer, fragment) - .setPrimaryNavigationFragment(fragment) - .commitNow() - } - - closed { context -> - context.fragment.parentFragmentManager.beginTransaction() - .remove(context.fragment) - .setPrimaryNavigationFragment( - context.activity.supportFragmentManager.findFragmentById( - masterContainer - ) - ) - .commitNow() - } - } - } - - init { - lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if(event == Lifecycle.Event.ON_CREATE) { - navigationController = when(lifecycleOwner) { - is FragmentActivity -> lifecycleOwner.application.navigationController - is Fragment -> lifecycleOwner.requireActivity().application.navigationController - else -> throw IllegalStateException("The MasterDetailProperty requires that it's lifecycle owner is a FragmentActivity or Fragment") - } - navigationController.addOverride(masterOverride) - navigationController.addOverride(detailOverride) - - val activity = lifecycleOwner as FragmentActivity - val masterFragment = activity.supportFragmentManager.findFragmentById(masterContainer) - if(masterFragment == null) { - activity.getNavigationHandle().forward(initialMasterKey()) - } - } - - if(event == Lifecycle.Event.ON_START) { - navigationController.addOverride(masterOverride) - navigationController.addOverride(detailOverride) - } - - if(event == Lifecycle.Event.ON_STOP){ - navigationController.removeOverride(masterOverride) - navigationController.removeOverride(detailOverride) - } - } - }) - } - - override fun getValue(thisRef: Any, property: KProperty<*>): MasterDetailController { - return masterDetailController - } -} \ No newline at end of file diff --git a/enro-multistack/build.gradle b/enro-multistack/build.gradle deleted file mode 100644 index a44f4297b..000000000 --- a/enro-multistack/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ -androidLibrary() -publishAndroidModule("dev.enro", "enro-multistack") - -dependencies { - releaseApi "dev.enro:enro-core:$versionName" - debugApi project(":enro-core") - - implementation deps.androidx.core - implementation deps.androidx.appcompat -} - -afterEvaluate { - tasks.findByName("preReleaseBuild") - .dependsOn(":enro-core:publishToMavenLocal") -} \ No newline at end of file diff --git a/enro-multistack/consumer-rules.pro b/enro-multistack/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/enro-multistack/src/main/AndroidManifest.xml b/enro-multistack/src/main/AndroidManifest.xml deleted file mode 100644 index b8270df50..000000000 --- a/enro-multistack/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/enro-multistack/src/main/java/dev/enro/multistack/AttachFragment.kt b/enro-multistack/src/main/java/dev/enro/multistack/AttachFragment.kt deleted file mode 100644 index 70d9c798f..000000000 --- a/enro-multistack/src/main/java/dev/enro/multistack/AttachFragment.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.enro.multistack - -import android.app.Activity -import android.app.Application -import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import kotlin.reflect.KClass - -internal const val MULTISTACK_CONTROLLER_TAG = "dev.enro.multistack.MULTISTACK_CONTROLLER_TAG" - -@PublishedApi -internal class AttachFragment( - private val type: KClass, - private val fragment: Fragment -) : Application.ActivityLifecycleCallbacks { - @Suppress("UNCHECKED_CAST") - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - } - - override fun onActivityStarted(activity: Activity) { - if (type.java.isAssignableFrom(activity::class.java)) { - activity as T - activity.supportFragmentManager.beginTransaction() - .add(fragment, MULTISTACK_CONTROLLER_TAG) - .commitNow() - activity.application.unregisterActivityLifecycleCallbacks(this) - } - } - - override fun onActivityResumed(activity: Activity) {} - - override fun onActivityPaused(activity: Activity) {} - - override fun onActivityStopped(activity: Activity) {} - - override fun onActivityDestroyed(activity: Activity) {} - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} -} diff --git a/enro-multistack/src/main/java/dev/enro/multistack/MultistackController.kt b/enro-multistack/src/main/java/dev/enro/multistack/MultistackController.kt deleted file mode 100644 index f840a39e8..000000000 --- a/enro-multistack/src/main/java/dev/enro/multistack/MultistackController.kt +++ /dev/null @@ -1,135 +0,0 @@ -package dev.enro.multistack - -import android.os.Parcelable -import androidx.annotation.AnimRes -import androidx.annotation.IdRes -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.compose.ComposableNavigator -import dev.enro.core.controller.NavigationController -import dev.enro.core.controller.navigationController -import dev.enro.core.fragment.FragmentNavigator -import kotlinx.parcelize.Parcelize -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -@Parcelize -data class MultistackContainer @PublishedApi internal constructor( - val containerId: Int, - val rootKey: NavigationKey -) : Parcelable - -class MultistackController internal constructor( - private val multistackController: MultistackControllerFragment -) { - - val activeContainer = multistackController.containerLiveData as LiveData - - fun openStack(container: MultistackContainer) { - multistackController.openStack(container) - } - - fun openStack(container: Int) { - multistackController.openStack(multistackController.containers.first { it.containerId == container }) - } -} - -class MultistackControllerProperty @PublishedApi internal constructor( - private val containerBuilders: List<()-> MultistackContainer>, - @AnimRes private val openStackAnimation: Int?, - private val lifecycleOwner: LifecycleOwner, - private val fragmentManager: () -> FragmentManager -) : ReadOnlyProperty { - - val controller: MultistackController by lazy { - val fragment = fragmentManager().findFragmentByTag(MULTISTACK_CONTROLLER_TAG) - ?: run { - val fragment = MultistackControllerFragment() - - fragmentManager() - .beginTransaction() - .add(fragment, MULTISTACK_CONTROLLER_TAG) - .commit() - - return@run fragment - } - - fragment as MultistackControllerFragment - fragment.containers = containerBuilders.map { it() }.toTypedArray() - fragment.openStackAnimation = openStackAnimation - - return@lazy MultistackController(fragment) - } - - init { - lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == Lifecycle.Event.ON_CREATE) { - controller.hashCode() - } - } - }) - } - - override fun getValue(thisRef: Any, property: KProperty<*>): MultistackController { - return controller - } -} - -class MultistackControllerBuilder @PublishedApi internal constructor( - private val navigationController: () -> NavigationController -){ - - private val containerBuilders = mutableListOf<() -> MultistackContainer>() - - @AnimRes private var openStackAnimation: Int? = null - - fun container(@IdRes containerId: Int, rootKey: T) { - containerBuilders.add { - val navigator = navigationController().navigatorForKeyType(rootKey::class) - val actualKey = when(navigator) { - is FragmentNavigator -> rootKey - is ComposableNavigator -> { - Class.forName("dev.enro.core.compose.ComposeFragmentHostKey") - .getConstructor( - NavigationInstruction.Open::class.java, - Integer::class.java - ) - .newInstance( - NavigationInstruction.Forward(rootKey), - containerId - ) as NavigationKey - } - else -> throw IllegalStateException("TODO") - } - MultistackContainer(containerId, actualKey) - } - } - - fun openStackAnimation(@AnimRes animationRes: Int) { - openStackAnimation = animationRes - } - - internal fun build( - lifecycleOwner: LifecycleOwner, - fragmentManager: () -> FragmentManager - ) = MultistackControllerProperty( - containerBuilders = containerBuilders, - openStackAnimation = openStackAnimation, - lifecycleOwner = lifecycleOwner, - fragmentManager = fragmentManager - ) -} - -fun FragmentActivity.multistackController( - block: MultistackControllerBuilder.() -> Unit -) = MultistackControllerBuilder { application.navigationController }.apply(block).build( - lifecycleOwner = this, - fragmentManager = { supportFragmentManager } -) \ No newline at end of file diff --git a/enro-multistack/src/main/java/dev/enro/multistack/MultistackControllerFragment.kt b/enro-multistack/src/main/java/dev/enro/multistack/MultistackControllerFragment.kt deleted file mode 100644 index 649ca8a43..000000000 --- a/enro-multistack/src/main/java/dev/enro/multistack/MultistackControllerFragment.kt +++ /dev/null @@ -1,154 +0,0 @@ -package dev.enro.multistack - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.view.animation.AnimationUtils -import androidx.annotation.AnimRes -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.MutableLiveData -import dev.enro.core.DefaultAnimations -import dev.enro.core.NavigationInstruction -import dev.enro.core.activity.ActivityNavigator -import dev.enro.core.close -import dev.enro.core.controller.navigationController -import dev.enro.core.fragment.DefaultFragmentExecutor -import dev.enro.core.fragment.FragmentNavigator -import dev.enro.core.getNavigationHandle - - -@PublishedApi -internal class MultistackControllerFragment : Fragment(), ViewTreeObserver.OnGlobalLayoutListener { - - internal lateinit var containers: Array - @AnimRes internal var openStackAnimation: Int? = null - - internal val containerLiveData = MutableLiveData() - - private var listenForEvents = true - private var containerInitialised = false - private lateinit var activeContainer: MultistackContainer - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - activeContainer = savedInstanceState?.getParcelable("activecontainer") ?: containers.first() - containerInitialised = savedInstanceState?.getBoolean("containerInitialised", false) ?: false - requireActivity().findViewById(android.R.id.content) - .viewTreeObserver.addOnGlobalLayoutListener(this) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - openStack(activeContainer) - return null // this is a headless fragment - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putParcelable("activecontainer", activeContainer) - outState.putBoolean("containerInitialised", containerInitialised) - } - - override fun onDestroy() { - super.onDestroy() - requireActivity().findViewById(android.R.id.content) - .viewTreeObserver.removeOnGlobalLayoutListener(this) - } - - override fun onGlobalLayout() { - if (!listenForEvents) return - if (!containerInitialised) return - val isCurrentClosing = - parentFragmentManager.findFragmentById(activeContainer.containerId) == null - if (isCurrentClosing) { - onStackClosed(activeContainer) - return - } - - val newActive = containers.firstOrNull() { - requireActivity().findViewById(it.containerId).isVisible && it.containerId != activeContainer.containerId - } ?: return - - openStack(newActive) - } - - internal fun openStack(container: MultistackContainer) { - listenForEvents = false - activeContainer = container - if(containerLiveData.value != container.containerId) { - containerLiveData.value = container.containerId - } - - val controller = requireActivity().application.navigationController - val navigator = controller.navigatorForKeyType(container.rootKey::class) - - if(navigator is ActivityNavigator<*, *>) { - listenForEvents = true - return - } - - navigator as FragmentNavigator<*, *> - containers.forEach { - requireActivity().findViewById(it.containerId).isVisible = it.containerId == container.containerId - } - - val activeContainer = requireActivity().findViewById(container.containerId) - val existingFragment = parentFragmentManager.findFragmentById(container.containerId) - if (existingFragment != null) { - if (existingFragment != parentFragmentManager.primaryNavigationFragment) { - parentFragmentManager.beginTransaction() - .setPrimaryNavigationFragment(existingFragment) - .commitNow() - } - - containerInitialised = true - } else { - val instruction = NavigationInstruction.Forward(container.rootKey) - val newFragment = DefaultFragmentExecutor.createFragment( - parentFragmentManager, - navigator, - instruction - ) - try { - parentFragmentManager.executePendingTransactions() - parentFragmentManager.beginTransaction() - .setCustomAnimations(0, 0) - .replace(container.containerId, newFragment, instruction.instructionId) - .setPrimaryNavigationFragment(newFragment) - .commitNow() - - containerInitialised = true - } catch (ex: Throwable) { - Log.e("Enro Mutlistack", "Initial open failed", ex) - Handler(Looper.getMainLooper()).post { - openStack(container) - } - } - } - - val animation = openStackAnimation ?: DefaultAnimations.replace.asResource(requireActivity().theme).enter - val enter = AnimationUtils.loadAnimation(requireContext(), animation) - activeContainer.startAnimation(enter) - - listenForEvents = true - } - - private fun onStackClosed(container: MultistackContainer) { - listenForEvents = false - if (container == containers.first()) { - requireActivity().getNavigationHandle().close() - } else { - openStack(containers.first()) - } - listenForEvents = true - } -} \ No newline at end of file diff --git a/enro-processor/README.md b/enro-processor/README.md new file mode 100644 index 000000000..fff905725 --- /dev/null +++ b/enro-processor/README.md @@ -0,0 +1,50 @@ +# `enro-processor` + +The KSP / kapt code generator that turns `@NavigationDestination`, +`@NavigationComponent`, and `@NavigationPath` annotations into the glue +the runtime consumes. Apply it as a `ksp("…")` dependency on any module +that declares Enro destinations; it generates: + +- A per-component **`*Navigation`** class that registers every annotated + destination (and path binding) with the runtime when you call + `installNavigationController(…)`. This is what lets you write a + destination once and have it discovered automatically — no manual + `registerDestination(…)` boilerplate. +- **Path-binding** glue for `@NavigationPath`, including the + serialisers needed to bridge URL segments to typed `NavigationKey` + properties. +- Per-destination metadata (key type, render kind, optional metadata + overrides) so the runtime can resolve `open(KeyType)` calls without + reflection at runtime. + +The processor itself is pure JVM — it has no multiplatform target — but +its output is multiplatform-aware and lands in the right source set per +target. + +## Typical usage + +You don't usually add `enro-processor` to a module directly. The +`dev.enro:enro` meta-artefact takes care of pulling it in alongside the +runtime; the KSP wiring you write looks like: + +```kotlin +plugins { + id("com.google.devtools.ksp") +} + +dependencies { + implementation("dev.enro:enro:3.0.0-beta01") + ksp("dev.enro:enro-processor:3.0.0-beta01") +} +``` + +For multi-target KMP modules, attach the processor to every target source +set that declares destinations (e.g. `kspCommonMainMetadata`, +`kspAndroid`, `kspIosArm64`, etc.). The output classes are then visible +on each platform. + +## When to depend on this directly + +Only when you're building tooling that needs to invoke the processor +outside the standard `ksp(…)` flow (e.g. a custom build plugin that +generates extra glue from the same annotations). diff --git a/enro-processor/build.gradle b/enro-processor/build.gradle deleted file mode 100644 index c09d20ca8..000000000 --- a/enro-processor/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -apply plugin: 'java-library' -apply plugin: 'kotlin' -apply plugin: 'kotlin-kapt' -publishJavaModule("dev.enro", "enro-processor") - -dependencies { - implementation deps.kotlin.stdLib - - implementation deps.processing.incremental - kapt deps.processing.incrementalProcessor - - implementation deps.processing.autoService - kapt deps.processing.autoService - - implementation deps.processing.jsr250 - - implementation project(":enro-annotations") - implementation deps.processing.javaPoet -} - -afterEvaluate { - tasks.findByName("compileKotlin") - .dependsOn(":enro-annotations:publishToMavenLocal") -} - -sourceCompatibility = "8" -targetCompatibility = "8" \ No newline at end of file diff --git a/enro-processor/build.gradle.kts b/enro-processor/build.gradle.kts new file mode 100644 index 000000000..474b581ea --- /dev/null +++ b/enro-processor/build.gradle.kts @@ -0,0 +1,36 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("java-library") + id("kotlin") + id("kotlin-kapt") + id("configure-publishing") +} + +dependencies { + implementation(libs.kotlin.stdLib) + + implementation(libs.processing.ksp) + + implementation(libs.processing.incremental) + kapt(libs.processing.incrementalProcessor) + + implementation(libs.processing.autoService) + kapt(libs.processing.autoService) + + implementation("dev.enro:enro-annotations:${project.enroVersionName}") + implementation(libs.processing.javaPoet) + implementation(libs.processing.kotlinPoet) + implementation(libs.processing.kotlinPoet.ksp) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} diff --git a/enro-processor/src/main/java/dev/enro/processor/BaseProcessor.kt b/enro-processor/src/main/java/dev/enro/processor/BaseProcessor.kt deleted file mode 100644 index 2266a0928..000000000 --- a/enro-processor/src/main/java/dev/enro/processor/BaseProcessor.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.enro.processor - -import com.squareup.javapoet.AnnotationSpec -import com.squareup.javapoet.ClassName -import com.squareup.javapoet.TypeSpec -import javax.annotation.Generated -import javax.annotation.processing.AbstractProcessor -import javax.lang.model.element.Element -import javax.lang.model.element.ExecutableElement -import javax.lang.model.element.QualifiedNameable -import javax.tools.Diagnostic - -abstract class BaseProcessor : AbstractProcessor() { - - internal fun Element.getElementName(): String { - val packageName = processingEnv.elementUtils.getPackageOf(this).toString() - return when (this) { - is QualifiedNameable -> { - qualifiedName.toString() - } - is ExecutableElement -> { - val kotlinMetadata = enclosingElement.getAnnotation(Metadata::class.java) - when (kotlinMetadata?.kind) { - // metadata kind 1 is a "class" type, which means this method belongs to a - // class or object, rather than being a top-level file function (kind 2) - 1 -> "${enclosingElement.getElementName()}.$simpleName" - else -> "$packageName.$simpleName" - } - } - else -> { - "$packageName.$simpleName" - } - } - } - - internal fun Element.extends(className: ClassName): Boolean { - val typeMirror = className.asElement().asType() - return processingEnv.typeUtils.isSubtype(asType(), typeMirror) - } - - internal fun Element.implements(className: ClassName): Boolean { - val typeMirror = processingEnv.typeUtils.erasure(className.asElement().asType()) - return processingEnv.typeUtils.isAssignable(asType(), typeMirror) - } - - internal fun ClassName.asElement() = processingEnv.elementUtils.getTypeElement(canonicalName()) - - internal fun TypeSpec.Builder.addGeneratedAnnotation(): TypeSpec.Builder { - addAnnotation( - AnnotationSpec.builder(Generated::class.java) - .addMember("value", "\"${this@BaseProcessor::class.java.name}\"") - .build() - ) - return this - } - - - fun ExecutableElement.kotlinReceiverTypes(): List { - val receiver = parameters.firstOrNull { - it.simpleName.startsWith("\$this") - } ?: return emptyList() - - val typeParameterNames = typeParameters.map { it.simpleName.toString() } - val superTypes = processingEnv.typeUtils.directSupertypes(receiver.asType()).map { it.toString() } - val receiverTypeName = receiver.asType().toString() - - return if(typeParameterNames.contains(receiverTypeName)) { - superTypes - } else { - superTypes + receiverTypeName - } - } -} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/Extensions.kt b/enro-processor/src/main/java/dev/enro/processor/Extensions.kt deleted file mode 100644 index 355f3b2b7..000000000 --- a/enro-processor/src/main/java/dev/enro/processor/Extensions.kt +++ /dev/null @@ -1,39 +0,0 @@ -package dev.enro.processor - -import com.squareup.javapoet.ClassName -import javax.lang.model.type.MirroredTypeException -import kotlin.reflect.KClass - -internal object EnroProcessor { - const val GENERATED_PACKAGE = "enro_generated_bindings" - -} - -internal object ClassNames { - val navigationComponentBuilderCommand = ClassName.get("dev.enro.core.controller", "NavigationComponentBuilderCommand") - val navigationComponentBuilder = ClassName.get("dev.enro.core.controller", "NavigationComponentBuilder") - val jvmClassMappings = ClassName.get("kotlin.jvm", "JvmClassMappingKt") - - val unit = ClassName.get("kotlin", "Unit") - val fragmentActivity = ClassName.get( "androidx.fragment.app", "FragmentActivity") - - val activityNavigatorKt = ClassName.get("dev.enro.core.activity","ActivityNavigatorKt") - val fragment = ClassName.get("androidx.fragment.app","Fragment") - - val fragmentNavigatorKt = ClassName.get("dev.enro.core.fragment","FragmentNavigatorKt") - val syntheticDestination = ClassName.get("dev.enro.core.synthetic","SyntheticDestination") - - val syntheticNavigatorKt = ClassName.get("dev.enro.core.synthetic","SyntheticNavigatorKt") - - val composableDestination = ClassName.get("dev.enro.core.compose", "ComposableDestination") - val composeNavigatorKt = ClassName.get("dev.enro.core.compose", "ComposableNavigatorKt") -} - -internal fun getNameFromKClass(block: () -> KClass<*>) : String { - try { - return block().java.name - } - catch (ex: MirroredTypeException) { - return ClassName.get(ex.typeMirror).toString() - } -} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/NavigationComponentProcessor.kt b/enro-processor/src/main/java/dev/enro/processor/NavigationComponentProcessor.kt deleted file mode 100644 index 81baae241..000000000 --- a/enro-processor/src/main/java/dev/enro/processor/NavigationComponentProcessor.kt +++ /dev/null @@ -1,181 +0,0 @@ -package dev.enro.processor - -import com.google.auto.service.AutoService -import com.squareup.javapoet.* -import dev.enro.annotations.* -import net.ltgt.gradle.incap.IncrementalAnnotationProcessor -import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType -import javax.annotation.processing.Processor -import javax.annotation.processing.RoundEnvironment -import javax.lang.model.SourceVersion -import javax.lang.model.element.Element -import javax.lang.model.element.Modifier -import javax.lang.model.element.TypeElement -import javax.tools.Diagnostic - -@IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.AGGREGATING) -@AutoService(Processor::class) -class NavigationComponentProcessor : BaseProcessor() { - - private val components = mutableListOf() - private val bindings = mutableListOf() - - override fun getSupportedAnnotationTypes(): MutableSet { - return mutableSetOf( - NavigationComponent::class.java.name, - GeneratedNavigationBinding::class.java.name - ) - } - - override fun getSupportedSourceVersion(): SourceVersion { - return SourceVersion.latest() - } - - override fun process( - annotations: MutableSet?, - roundEnv: RoundEnvironment - ): Boolean { - components += roundEnv.getElementsAnnotatedWith(NavigationComponent::class.java) - bindings += roundEnv.getElementsAnnotatedWith(GeneratedNavigationBinding::class.java) - if (roundEnv.processingOver()) { - val generatedModule = generateModule( - components, - bindings - ) - components.forEach { generateComponent(it, generatedModule) } - } - return true - } - - private fun generateComponent(component: Element, generatedModuleName: String?) { - val destinations = processingEnv.elementUtils - .getPackageElement(EnroProcessor.GENERATED_PACKAGE) - .runCatching { - enclosedElements - } - .getOrNull() - .orEmpty() - .apply { - if(isEmpty()) { - processingEnv.messager.printMessage(Diagnostic.Kind.WARNING, "Created a NavigationComponent but found no navigation destinations. This can indicate that the dependencies which define the @NavigationDestination annotated classes are not on the compile classpath for this module, or that you have forgotten to apply the enro-processor annotation processor to the modules that define the @NavigationDestination annotated classes.") - } - } - .mapNotNull { - val annotation = it.getAnnotation(GeneratedNavigationBinding::class.java) - ?: return@mapNotNull null - - NavigationDestinationArguments( - generatedBinding = it, - destination = annotation.destination, - navigationKey = annotation.navigationKey - ) - } - - val modules = processingEnv.elementUtils - .getPackageElement(EnroProcessor.GENERATED_PACKAGE) - .runCatching { - enclosedElements - } - .getOrNull() - .orEmpty() - .mapNotNull { - it.getAnnotation(GeneratedNavigationModule::class.java) - ?: return@mapNotNull null - it.getElementName() + ".class" - } - .let { - if(generatedModuleName != null) { - it + "$generatedModuleName.class" - } else it - } - .joinToString(separator = ",\n") - - val generatedName = "${component.simpleName}Navigation" - val classBuilder = TypeSpec.classBuilder(generatedName) - .addOriginatingElement(component) - .addOriginatingElement( - processingEnv.elementUtils - .getPackageElement(EnroProcessor.GENERATED_PACKAGE) - ) - .apply { - destinations.forEach { - addOriginatingElement(it.generatedBinding) - } - } - .addGeneratedAnnotation() - .addAnnotation( - AnnotationSpec.builder(GeneratedNavigationComponent::class.java) - .addMember("bindings", "{\n${destinations.joinToString(separator = ",\n") { it.generatedBinding.toString() + ".class" }}\n}") - .addMember("modules", "{\n$modules\n}") - .build() - ) - .addModifiers(Modifier.PUBLIC) - .addSuperinterface(ClassNames.navigationComponentBuilderCommand) - .addMethod( - MethodSpec.methodBuilder("execute") - .addAnnotation(Override::class.java) - .addModifiers(Modifier.PUBLIC) - .addParameter( - ParameterSpec - .builder(ClassNames.navigationComponentBuilder, "builder") - .build() - ) - .apply { - destinations.forEach { - addStatement(CodeBlock.of("new $1T().execute(builder)", it.generatedBinding)) - } - } - .build() - ) - .build() - - JavaFile - .builder( - processingEnv.elementUtils.getPackageOf(component).toString(), - classBuilder - ) - .build() - .writeTo(processingEnv.filer) - } - - private fun generateModule(componentNames: List, bindings: List): String? { - if(bindings.isEmpty()) return null - val moduleIdElements = componentNames.ifEmpty { bindings } - val moduleId = moduleIdElements.fold(0) { acc, it -> acc + it.getElementName().hashCode() } - .toString() - .replace("-", "") - .padStart(10, '0') - - val generatedName = "_dev_enro_processor_ModuleSentinel_$moduleId" - val classBuilder = TypeSpec.classBuilder(generatedName) - .apply { - bindings.forEach { - addOriginatingElement(it) - } - } - .addGeneratedAnnotation() - .addAnnotation( - AnnotationSpec.builder(GeneratedNavigationModule::class.java) - .addMember("bindings", "{\n${bindings.joinToString(separator = ",\n") { it.simpleName.toString() + ".class" }}\n}") - .build() - ) - .addModifiers(Modifier.PUBLIC) - .build() - - JavaFile - .builder( - EnroProcessor.GENERATED_PACKAGE, - classBuilder - ) - .build() - .writeTo(processingEnv.filer) - - return "${EnroProcessor.GENERATED_PACKAGE}.$generatedName" - } -} - -internal data class NavigationDestinationArguments( - val generatedBinding: Element, - val destination: String, - val navigationKey: String -) \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/NavigationDestinationProcessor.kt b/enro-processor/src/main/java/dev/enro/processor/NavigationDestinationProcessor.kt deleted file mode 100644 index a30c228ca..000000000 --- a/enro-processor/src/main/java/dev/enro/processor/NavigationDestinationProcessor.kt +++ /dev/null @@ -1,368 +0,0 @@ -package dev.enro.processor - -import com.google.auto.service.AutoService -import com.squareup.javapoet.* -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.GeneratedNavigationBinding -import dev.enro.annotations.NavigationDestination -import net.ltgt.gradle.incap.IncrementalAnnotationProcessor -import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType -import javax.annotation.processing.Processor -import javax.annotation.processing.RoundEnvironment -import javax.lang.model.SourceVersion -import javax.lang.model.element.* -import javax.tools.Diagnostic -import javax.tools.StandardLocation - -@IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.ISOLATING) -@AutoService(Processor::class) -class NavigationDestinationProcessor : BaseProcessor() { - - private val destinations = mutableListOf() - - override fun getSupportedAnnotationTypes(): MutableSet { - return mutableSetOf( - NavigationDestination::class.java.name - ) - } - - override fun getSupportedSourceVersion(): SourceVersion { - return SourceVersion.latest() - } - - override fun process( - annotations: MutableSet?, - roundEnv: RoundEnvironment - ): Boolean { - destinations += roundEnv.getElementsAnnotatedWith(NavigationDestination::class.java) - .map { - it.also(::generateDestinationForClass) - it.also(::generateDestinationForFunction) - } - return false - } - - private fun generateDestinationForClass(element: Element) { - if (element.kind != ElementKind.CLASS) return - val annotation = element.getAnnotation(NavigationDestination::class.java) - - val keyType = processingEnv.elementUtils.getTypeElement(getNameFromKClass { annotation.key }) - - val bindingName = element.getElementName() - .replace(".", "_") - .let { "_${it}_GeneratedNavigationBinding" } - - val classBuilder = TypeSpec.classBuilder(bindingName) - .addOriginatingElement(element) - .addModifiers(Modifier.PUBLIC) - .addSuperinterface(ClassNames.navigationComponentBuilderCommand) - .addAnnotation( - AnnotationSpec.builder(GeneratedNavigationBinding::class.java) - .addMember( - "destination", - CodeBlock.of("\"${element.getElementName()}\"") - ) - .addMember("navigationKey", CodeBlock.of("\"${keyType.getElementName()}\"")) - .build() - ) - .addGeneratedAnnotation() - .addMethod( - MethodSpec.methodBuilder("execute") - .addAnnotation(Override::class.java) - .addModifiers(Modifier.PUBLIC) - .addParameter( - ParameterSpec - .builder(ClassNames.navigationComponentBuilder, "builder") - .build() - ) - .addNavigationDestination(element, keyType) - .build() - ) - .build() - - JavaFile - .builder(EnroProcessor.GENERATED_PACKAGE, classBuilder) - .addStaticImport(ClassNames.activityNavigatorKt, "createActivityNavigator") - .addStaticImport(ClassNames.fragmentNavigatorKt, "createFragmentNavigator") - .addStaticImport(ClassNames.syntheticNavigatorKt, "createSyntheticNavigator") - .addStaticImport(ClassNames.jvmClassMappings, "getKotlinClass") - .build() - .writeTo(processingEnv.filer) - } - - private fun generateDestinationForFunction(element: Element) { - if (element.kind != ElementKind.METHOD) return - element as ExecutableElement - - element.annotationMirrors - .firstOrNull { - it.annotationType.asElement() - .getElementName() == "androidx.compose.runtime.Composable" - } - ?: run { - processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, "Function ${element.getElementName()} was marked as @NavigationDestination, but was not marked as @Composable") - return - } - - - val isStatic = element.modifiers.contains(Modifier.STATIC) - val parentIsObject = element.enclosingElement.enclosedElements.any { it.simpleName.toString() == "INSTANCE" } - if(!isStatic && !parentIsObject) { - processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, "Function ${element.getElementName()} is an instance function, which is not allowed.") - return - } - - val receiverTypes = element.kotlinReceiverTypes() - val allowedReceiverTypes = listOf( - "java.lang.Object", - "dev.enro.core.compose.dialog.DialogDestination", - "dev.enro.core.compose.dialog.BottomSheetDestination" - ) - val isCompatibleReceiver = receiverTypes.all { - allowedReceiverTypes.contains(it) - } - - val hasNoParameters = element.parameters.size == 0 - val hasAllowedParameters = element.parameters.filter { !it.simpleName.startsWith("\$this") }.all { - false - } - - val parametersAreValid = (hasNoParameters || hasAllowedParameters) && isCompatibleReceiver - if(!parametersAreValid) { - processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, "Function ${element.getElementName()} has parameters which is not allowed.") - return - } - - val annotation = element.getAnnotation(NavigationDestination::class.java) - val enableComposableDestination = - element.getAnnotation(ExperimentalComposableDestination::class.java) != null - - if(!enableComposableDestination) { - val shortMessage = "Failed to create NavigationDestination for function ${element.getElementName()}. Using @Composable functions as @NavigationDestinations is an experimental feature an must be explicitly enabled." - processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, shortMessage) - processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, "To enable @Composable @NavigationDestinations annotate the @Composable function @NavigationDestination with the @ExperimentalComposableDestination annotation") - return - } - val keyType = - processingEnv.elementUtils.getTypeElement(getNameFromKClass { annotation.key }) - - val composableWrapper = createComposableWrapper(element, keyType) - - val bindingName = element.getElementName() - .replace(".", "_") - .let { "${it}_GeneratedNavigationBinding" } - - val classBuilder = TypeSpec.classBuilder(bindingName) - .addOriginatingElement(element) - .addModifiers(Modifier.PUBLIC) - .addSuperinterface(ClassNames.navigationComponentBuilderCommand) - .addAnnotation( - AnnotationSpec.builder(GeneratedNavigationBinding::class.java) - .addMember( - "destination", - CodeBlock.of("\"${EnroProcessor.GENERATED_PACKAGE}.$bindingName\"") - ) - .addMember( - "navigationKey", - CodeBlock.of("\"${keyType.getElementName()}\"") - ) - .build() - ) - .addGeneratedAnnotation() - .addMethod( - MethodSpec.methodBuilder("execute") - .addAnnotation(Override::class.java) - .addModifiers(Modifier.PUBLIC) - .addParameter( - ParameterSpec - .builder(ClassNames.navigationComponentBuilder, "builder") - .build() - ) - .addStatement( - CodeBlock.of( - """ - builder.navigator( - createComposableNavigator( - $1T.class, - $composableWrapper.class - ) - ) - """.trimIndent(), - keyType - ) - ) - .build() - ) - .build() - - JavaFile - .builder(EnroProcessor.GENERATED_PACKAGE, classBuilder) - .addStaticImport(ClassNames.activityNavigatorKt, "createActivityNavigator") - .addStaticImport(ClassNames.fragmentNavigatorKt, "createFragmentNavigator") - .addStaticImport(ClassNames.syntheticNavigatorKt, "createSyntheticNavigator") - .addStaticImport(ClassNames.composeNavigatorKt, "createComposableNavigator") - .addStaticImport(ClassNames.jvmClassMappings, "getKotlinClass") - .build() - .writeTo(processingEnv.filer) - } - - private fun MethodSpec.Builder.addNavigationDestination( - destination: Element, - key: Element - ): MethodSpec.Builder { - val destinationName = destination.simpleName - - val destinationIsActivity = destination.extends(ClassNames.fragmentActivity) - val destinationIsFragment = destination.extends(ClassNames.fragment) - val destinationIsSynthetic = destination.implements(ClassNames.syntheticDestination) - - val annotation = destination.getAnnotation(NavigationDestination::class.java) - - addStatement( - when { - destinationIsActivity -> CodeBlock.of( - """ - builder.navigator( - createActivityNavigator( - $1T.class, - $2T.class - ) - ) - """.trimIndent(), - key, - destination - ) - - destinationIsFragment -> CodeBlock.of( - """ - builder.navigator( - createFragmentNavigator( - $1T.class, - $2T.class - ) - ) - """.trimIndent(), - key, - destination - ) - - destinationIsSynthetic -> CodeBlock.of( - """ - builder.navigator( - createSyntheticNavigator( - $1T.class, - () -> new $2T() - ) - ) - """.trimIndent(), - key, - destination - ) - else -> { - processingEnv.messager.printMessage(Diagnostic.Kind.ERROR, "$destinationName does not extend Fragment, FragmentActivity, or SyntheticDestination") - CodeBlock.of(""" - // Error: $destinationName does not extend Fragment, FragmentActivity, or SyntheticDestination - """.trimIndent()) - } - } - ) - - return this - } - - private fun createComposableWrapper( - element: ExecutableElement, - keyType: Element - ): String { - val packageName = processingEnv.elementUtils.getPackageOf(element).toString() - val composableWrapperName = - element.getElementName().split(".").last() + "Destination" - - val receiverTypes = element.kotlinReceiverTypes() - val additionalInterfaces = receiverTypes.mapNotNull { - when (it) { - "dev.enro.core.compose.dialog.DialogDestination" -> "DialogDestination" - "dev.enro.core.compose.dialog.BottomSheetDestination" -> "BottomSheetDestination" - else -> null - } - }.joinToString(separator = "") { ", $it" } - - val typeParameter = if(element.typeParameters.isEmpty()) "" else "<$composableWrapperName>" - - val additionalImports = receiverTypes.flatMap { - when (it) { - "dev.enro.core.compose.dialog.DialogDestination" -> listOf( - "dev.enro.core.compose.dialog.DialogDestination", - "dev.enro.core.compose.dialog.DialogConfiguration" - ) - "dev.enro.core.compose.dialog.BottomSheetDestination" -> listOf( - "dev.enro.core.compose.dialog.BottomSheetDestination", - "dev.enro.core.compose.dialog.BottomSheetConfiguration", - "androidx.compose.material.ExperimentalMaterialApi" - ) - else -> emptyList() - } - }.joinToString(separator = "") { "\n import $it" } - - val additionalAnnotations = receiverTypes.mapNotNull { - when (it) { - "dev.enro.core.compose.dialog.BottomSheetDestination" -> - """ - @OptIn(ExperimentalMaterialApi::class) - """.trimIndent() - else -> null - } - }.joinToString(separator = "") { "\n $it" } - - val additionalBody = receiverTypes.mapNotNull { - when (it) { - "dev.enro.core.compose.dialog.DialogDestination" -> - """ - override val dialogConfiguration: DialogConfiguration = DialogConfiguration() - """.trimIndent() - "dev.enro.core.compose.dialog.BottomSheetDestination" -> - """ - override val bottomSheetConfiguration: BottomSheetConfiguration = BottomSheetConfiguration() - """.trimIndent() - else -> null - } - }.joinToString(separator = "") { "\n $it" } - - processingEnv.filer - .createResource( - StandardLocation.SOURCE_OUTPUT, - EnroProcessor.GENERATED_PACKAGE, - "$composableWrapperName.kt", - element - ) - .openWriter() - .append( - """ - package $packageName - - import androidx.compose.runtime.Composable - import dev.enro.annotations.NavigationDestination - import javax.annotation.Generated - $additionalImports - - import ${element.getElementName()} - import ${ClassNames.composableDestination} - import ${keyType.getElementName()} - - $additionalAnnotations - @Generated("dev.enro.processor.NavigationDestinationProcessor") - public class $composableWrapperName : ComposableDestination()$additionalInterfaces { - $additionalBody - - @Composable - override fun Render() { - ${element.simpleName}$typeParameter() - } - } - """.trimIndent() - ) - .close() - - return "$packageName.$composableWrapperName" - } -} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/NavigationProcessor.kt b/enro-processor/src/main/java/dev/enro/processor/NavigationProcessor.kt new file mode 100644 index 000000000..00255c4b5 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/NavigationProcessor.kt @@ -0,0 +1,159 @@ +package dev.enro.processor + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.writeTo +import dev.enro.annotations.GeneratedNavigationBinding +import dev.enro.processor.domain.ComponentReference +import dev.enro.processor.domain.GeneratedBindingReference +import dev.enro.processor.extensions.ClassNames +import dev.enro.processor.extensions.EnroLocation +import dev.enro.processor.generator.NavigationBindingGenerator +import dev.enro.processor.generator.NavigationComponentGenerator +import dev.enro.processor.generator.ResolverPlatform + +class NavigationProcessor( + private val environment: SymbolProcessorEnvironment +) : SymbolProcessor { + + private val processedDestinations = mutableSetOf() + + private var platform: ResolverPlatform? = null + + private val componentsToProcess = mutableMapOf() + private val generatedBindings = mutableMapOf() + + @OptIn(KspExperimental::class) + override fun process(resolver: Resolver): List { + if (platform == null) { + // If platform is null, that means this is the first time we've run the processor, + // so we're going to load the platform information, and then we're also going to load all of the + // GeneratedNavigationBindings from the EnroLocation.GENERATED_PACKAGE package, + // and put them into the "bindings" map so they can be referenced in the "finish" function + platform = ResolverPlatform.getPlatform(resolver) + } + + resolver.getDeclarationsFromPackage(EnroLocation.GENERATED_PACKAGE) + .filterIsInstance() + .filter { !generatedBindings.containsKey(it.qualifiedName?.asString()) } + .filter { it.isAnnotationPresent(GeneratedNavigationBinding::class) } + .forEach { declaration -> + GeneratedBindingReference.fromDeclaration(declaration).let { binding -> + generatedBindings[binding.qualifiedName] = binding + } + } + + // Whenever we see a new class annotated with GeneratedNavigationBinding, we're also going to add this + // to the bindings map, so that it can be referenced in the "finish" function + resolver + .getSymbolsWithAnnotation(ClassNames.Kotlin.generatedNavigationBinding.canonicalName) + .toList() + .filterIsInstance() + .onEach { declaration -> + GeneratedBindingReference.fromDeclaration(declaration).let { binding -> + generatedBindings[binding.qualifiedName] = binding + } + } + + // Whenever we see a class annotated with NavigationComponent, we're going to add that to the + // processedComponents. The processedComponents list is used to generate the GeneratedNavigationComponent + // classes in the "finish" function. + resolver + .getSymbolsWithAnnotation(ClassNames.Kotlin.navigationComponent.canonicalName) + .toList() + .filterIsInstance() + .onEach { + val name = it.qualifiedName?.asString() + if (name == null) { + val error = "Failed to process class ${it.simpleName} annotated with NavigationComponent because it does not have a qualified name." + environment.logger.error(error, it) + error(error) + } + componentsToProcess[name] = ComponentReference.fromDeclaration(environment, it) + + // It appears that on some platforms, the "getDeclarationsFromPackage" call above won't + // work unless there's either something *in* that package that's owned by the module + // under compilation, so we write a "Sentinel" here for each NavigationComponent, + // which means that if we're in a module that only defines a NavigationComponent but + // no NavigationDestinations, we're still going to be able to hit a second round of + // processing and get all of the getDeclarationsFromPackage when the "process" function + // gets called for a second time. It appears this is only an issue on iOS/wasm targets, + // so if removing this in the future, make sure to test on those targets! + val typeSpec = TypeSpec.classBuilder("_${name.replace(".", "_")}Sentinel") + .addModifiers(KModifier.PRIVATE) + .build() + + FileSpec + .builder(EnroLocation.GENERATED_PACKAGE, requireNotNull(typeSpec.name)) + .addType(typeSpec) + .build() + .writeTo( + codeGenerator = environment.codeGenerator, + dependencies = Dependencies( + aggregating = false, + sources = arrayOf(requireNotNull(it.containingFile)), + ) + ) + } + + // Whenever we see a class, function or property annotated with NavigationDestination, we're going to grab tha + // declaration and check if a GeneratedNavigationBinding has been created for that NavigationDestination yet + // (if it's qualified name is in processedNavigationDestinations, it's been processed already), and if it has not + // been generated yet, we'll use NavigationBindingGenerator to create a GeneratedNavigationBinding for the declaration + resolver + .getSymbolsWithAnnotation(ClassNames.Kotlin.navigationDestination.canonicalName) + .toList() + .filterIsInstance() + .plus( + resolver + .getSymbolsWithAnnotation(ClassNames.Kotlin.navigationDestinationPlatformOverride.canonicalName) + .filterIsInstance() + ) + .filter { !processedDestinations.contains(it.qualifiedName?.asString()) } + .onEach { destinationDeclaration -> + processedDestinations.add(destinationDeclaration.qualifiedName?.asString().orEmpty()) + NavigationBindingGenerator.generate( + environment = environment, + resolver = resolver, + destinationDeclaration = destinationDeclaration + ) + } + + return emptyList() + } + + override fun finish() { + // After we've finished all rounds of processing for this module, we're going to process the NavigationComponent + // objects that we found and stored in componentsToProcess. We always need to do this as the final step in + // "finish" because the GeneratedNavigationComponent needs to reference all of the GeneratedNavigationBindings + componentsToProcess.values.forEach { + NavigationComponentGenerator.generate( + environment = environment, + platform = requireNotNull(platform), + component = it, + bindings = generatedBindings.values.toList(), + ) + } + } +} + +@AutoService(SymbolProcessorProvider::class) +class NavigationProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return NavigationProcessor( + environment = environment + ) + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/domain/ComponentReference.kt b/enro-processor/src/main/java/dev/enro/processor/domain/ComponentReference.kt new file mode 100644 index 000000000..add8f4fe2 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/domain/ComponentReference.kt @@ -0,0 +1,56 @@ +package dev.enro.processor.domain + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.squareup.kotlinpoet.ClassName + +class ComponentReference private constructor( + val simpleName: String, + val className: ClassName, + val containingFile: KSFile?, +) { + + companion object { + fun fromDeclaration( + environment: SymbolProcessorEnvironment, + declaration: KSDeclaration, + ): ComponentReference { + if (declaration !is KSClassDeclaration) { + val message = "@NavigationComponent can only be applied to objects" + environment.logger.error(message, declaration) + error(message) + } + + val isObject = declaration.classKind == ClassKind.OBJECT + if (!isObject) { + val message = "@NavigationComponent can only be applied to objects" + environment.logger.error(message, declaration) + error(message) + } + + val isNavigationComponentConfiguration = declaration + .getAllSuperTypes() + .any { it.declaration.qualifiedName?.asString() == "dev.enro.controller.NavigationComponentConfiguration" } + + if (!isNavigationComponentConfiguration) { + val message = "@NavigationComponent can only be applied to objects that extend " + + "NavigationComponentConfiguration" + environment.logger.error(message, declaration) + error(message) + } + + return ComponentReference( + simpleName = declaration.simpleName.asString(), + className = ClassName( + declaration.packageName.asString(), + declaration.simpleName.asString() + ), + containingFile = declaration.containingFile, + ) + } + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt b/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt new file mode 100644 index 000000000..eb9ff28d2 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt @@ -0,0 +1,111 @@ +package dev.enro.processor.domain + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getClassDeclarationByName +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.ksp.toAnnotationSpec +import com.squareup.kotlinpoet.ksp.toClassName +import dev.enro.processor.extensions.ClassNames + +@OptIn(KspExperimental::class) +class DestinationReference( + resolver: Resolver, + val declaration: KSDeclaration, +) { + val isClass = declaration is KSClassDeclaration + val isActivity = declaration is KSClassDeclaration && run { + val type = (declaration as KSClassDeclaration).asStarProjectedType() + val activityType = resolver.getClassDeclarationByName("android.app.Activity")?.asStarProjectedType() + if (activityType == null) return@run false + activityType.isAssignableFrom(type.starProjection()) + } + + val isFragment = declaration is KSClassDeclaration && run { + val type = (declaration as KSClassDeclaration).asStarProjectedType() + val fragmentType = resolver.getClassDeclarationByName("androidx.fragment.app.Fragment")?.asStarProjectedType() + if (fragmentType == null) return@run false + fragmentType.isAssignableFrom(type.starProjection()) + } + + val isProperty = declaration is KSPropertyDeclaration && run { + val type = (declaration as KSPropertyDeclaration).type.resolve() + val providerType = + resolver.getClassDeclarationByName("dev.enro.ui.NavigationDestinationProvider")!!.asStarProjectedType() + providerType.isAssignableFrom(type.starProjection()) + } + + val keyTypeFromPropertyProvider: KSType? = run { + if (!isProperty) return@run null + val type = (declaration as KSPropertyDeclaration).type.resolve() + val providerDeclaration = type.declaration as? KSClassDeclaration + if (providerDeclaration == null) return@run null + + if (providerDeclaration.qualifiedName?.asString() == "dev.enro.ui.NavigationDestinationProvider") { + return@run type.arguments.firstOrNull()?.type?.resolve()?.starProjection() + } + + providerDeclaration.superTypes + .firstOrNull { + val resolved = it.resolve() + resolved.declaration.qualifiedName?.asString() == "dev.enro.ui.NavigationDestinationProvider" + } + ?.resolve() + ?.arguments + ?.firstOrNull() + ?.type + ?.resolve() + ?.starProjection() + } + + val isFunction = declaration is KSFunctionDeclaration + + val isComposable = declaration is KSFunctionDeclaration && declaration.annotations + .any { it.shortName.asString() == "Composable" } + + var isPlatformOverride = false + private set + + val annotation = declaration + .annotations.firstOrNull { + val names = listOf("NavigationDestination", "PlatformOverride") + val isValid = it.shortName.getShortName() in names + if (!isValid) return@firstOrNull false + val qualifiedName = it.annotationType.resolve().declaration.qualifiedName?.asString() + if (qualifiedName == null) return@firstOrNull false + if (it.shortName.getShortName() == "PlatformOverride") { + isPlatformOverride = true + } + return@firstOrNull qualifiedName in listOf( + "dev.enro.annotations.NavigationDestination", + "dev.enro.annotations.NavigationDestination.PlatformOverride" + ) + } + ?: error("${declaration.simpleName} is not annotated with @NavigationDestination") + + val keyType: KSClassDeclaration = run { + requireNotNull( + annotation.arguments.first { it.name?.asString() == "key" } + .let { it.value as KSType } + .declaration as KSClassDeclaration + ) + } + + val keyIsKotlinSerializable = keyType.annotations + .any { + // Some annotations may not be resolvable, so we're going to runCatching with this + runCatching { + it.toAnnotationSpec().typeName == ClassNames.Kotlin.kotlinxSerializable + }.getOrElse { false } + } + + val bindingName = requireNotNull(declaration.qualifiedName).asString() + .replace(".", "_") + .let { "_${it}_GeneratedNavigationBinding" } + + fun toClassName() = (declaration as KSClassDeclaration).toClassName() +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/domain/GeneratedBindingReference.kt b/enro-processor/src/main/java/dev/enro/processor/domain/GeneratedBindingReference.kt new file mode 100644 index 000000000..a38f5a3f6 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/domain/GeneratedBindingReference.kt @@ -0,0 +1,27 @@ +package dev.enro.processor.domain + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import dev.enro.annotations.GeneratedNavigationBinding + +data class GeneratedBindingReference( + val qualifiedName: String, + val destination: String, + val navigationKey: String, + val containingFile: KSFile?, +) { + companion object { + @OptIn(KspExperimental::class) + fun fromDeclaration(binding: KSClassDeclaration): GeneratedBindingReference { + val bindingAnnotation = binding.getAnnotationsByType(GeneratedNavigationBinding::class).first() + return GeneratedBindingReference( + qualifiedName = binding.qualifiedName!!.asString(), + destination = bindingAnnotation.destination, + navigationKey = bindingAnnotation.navigationKey, + containingFile = binding.containingFile, + ) + } + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt new file mode 100644 index 000000000..e7cb92bd8 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt @@ -0,0 +1,63 @@ +package dev.enro.processor.extensions + +import com.squareup.kotlinpoet.ClassName + + +object ClassNames { + + object Kotlin { + val composable = ClassName( + "androidx.compose.runtime", + "Composable" + ) + val unit = ClassName( + "kotlin", + "Unit" + ) + val navigationModuleScope = ClassName( + "dev.enro.controller", + "NavigationModuleScope" + ) + val navigationDestination = ClassName("dev.enro.annotations", "NavigationDestination") + val navigationPath = ClassName("dev.enro.annotations", "NavigationPath") + val navigationDestinationPlatformOverride = ClassName("dev.enro.annotations", "NavigationDestination", "PlatformOverride") + val navigationComponent = ClassName("dev.enro.annotations", "NavigationComponent") + val generatedNavigationBinding = ClassName("dev.enro.annotations", "GeneratedNavigationBinding") + + val optIn = ClassName("kotlin", "OptIn") + val experimentalMaterialApi = ClassName("androidx.compose.material", "ExperimentalMaterialApi") + + val experimentalObjCName = ClassName("kotlin.experimental", "ExperimentalObjCName") + val objCName = ClassName("kotlin.native", "ObjCName") + + val navigationController = ClassName( + "dev.enro", + "EnroController" + ) + + val navigationKey = ClassName( + "dev.enro", + "NavigationKey" + ) + + val uiViewController = ClassName( + "platform.UIKit", + "UIViewController" + ) + + val enroIosExtensions = ClassName( + "dev.enro", + "Enro" + ) + + val navigationComponentConfiguration = ClassName( + "dev.enro.controller", + "NavigationComponentConfiguration" + ) + + val kotlinxSerializable = ClassName( + "kotlinx.serialization", + "Serializable" + ) + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/Element.extends.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/Element.extends.kt new file mode 100644 index 000000000..8c779c474 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/Element.extends.kt @@ -0,0 +1,16 @@ +package dev.enro.processor.extensions + +import com.squareup.javapoet.ClassName +import javax.annotation.processing.ProcessingEnvironment +import javax.lang.model.element.Element +import javax.lang.model.element.TypeElement + + +internal fun Element.extends( + processingEnv: ProcessingEnvironment, + className: ClassName +): Boolean { + if (this !is TypeElement) return false + val typeMirror = processingEnv.elementUtils.getTypeElement(className.canonicalName()).asType() + return processingEnv.typeUtils.isSubtype(asType(), typeMirror) +} diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/Element.getElementName.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/Element.getElementName.kt new file mode 100644 index 000000000..d3988e72b --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/Element.getElementName.kt @@ -0,0 +1,27 @@ +package dev.enro.processor.extensions + +import javax.annotation.processing.ProcessingEnvironment +import javax.lang.model.element.Element +import javax.lang.model.element.ExecutableElement +import javax.lang.model.element.QualifiedNameable + +internal fun Element.getElementName(processingEnv: ProcessingEnvironment): String { + val packageName = processingEnv.elementUtils.getPackageOf(this).toString() + return when (this) { + is QualifiedNameable -> { + qualifiedName.toString() + } + is ExecutableElement -> { + val kotlinMetadata = enclosingElement.getAnnotation(Metadata::class.java) + when (kotlinMetadata?.kind) { + // metadata kind 1 is a "class" type, which means this method belongs to a + // class or object, rather than being a top-level file function (kind 2) + 1 -> "${enclosingElement.getElementName(processingEnv)}.$simpleName" + else -> "$packageName.$simpleName" + } + } + else -> { + "$packageName.$simpleName" + } + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/Element.implements.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/Element.implements.kt new file mode 100644 index 000000000..8ed558a52 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/Element.implements.kt @@ -0,0 +1,19 @@ +package dev.enro.processor.extensions + +import com.squareup.javapoet.ClassName +import javax.annotation.processing.ProcessingEnvironment +import javax.lang.model.element.Element +import javax.lang.model.element.TypeElement + +internal fun Element.implements( + processingEnv: ProcessingEnvironment, + className: ClassName +): Boolean { + if (this !is TypeElement) return false + val typeMirror = processingEnv.typeUtils.erasure( + processingEnv.elementUtils.getTypeElement( + className.canonicalName() + ).asType() + ) + return processingEnv.typeUtils.isAssignable(asType(), typeMirror) +} diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/EnroLocation.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/EnroLocation.kt new file mode 100644 index 000000000..a454d3d7a --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/EnroLocation.kt @@ -0,0 +1,5 @@ +package dev.enro.processor.extensions + +object EnroLocation { + const val GENERATED_PACKAGE = "enro_generated_bindings" +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/ExecutableElement.kotlinReceiverTypes.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/ExecutableElement.kotlinReceiverTypes.kt new file mode 100644 index 000000000..712ddbf56 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/ExecutableElement.kotlinReceiverTypes.kt @@ -0,0 +1,21 @@ +package dev.enro.processor.extensions + +import javax.annotation.processing.ProcessingEnvironment +import javax.lang.model.element.ExecutableElement + + +fun ExecutableElement.kotlinReceiverTypes(processingEnv: ProcessingEnvironment): List { + val receiver = parameters.firstOrNull { + it.simpleName.startsWith("\$this") + } ?: return emptyList() + + val typeParameterNames = typeParameters.map { it.simpleName.toString() } + val superTypes = processingEnv.typeUtils.directSupertypes(receiver.asType()).map { it.toString() } + val receiverTypeName = receiver.asType().toString() + + return if(typeParameterNames.contains(receiverTypeName)) { + superTypes + } else { + superTypes + receiverTypeName + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/KSClassDeclaration.toDisplayString.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/KSClassDeclaration.toDisplayString.kt new file mode 100644 index 000000000..9023fd438 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/KSClassDeclaration.toDisplayString.kt @@ -0,0 +1,10 @@ +package dev.enro.processor.extensions + +import com.google.devtools.ksp.symbol.KSClassDeclaration + +fun KSClassDeclaration?.toDisplayString(): String { + if (this == null) return "null" + val qualifiedName = qualifiedName?.asString() ?: return simpleName.asString() + val packageName = packageName.asString() + return qualifiedName.removePrefix("$packageName.") +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/KSType.toDisplayString.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/KSType.toDisplayString.kt new file mode 100644 index 000000000..1ab70a9a3 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/KSType.toDisplayString.kt @@ -0,0 +1,11 @@ +package dev.enro.processor.extensions + +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType + +fun KSType?.toDisplayString(): String { + return when (val declaration = this?.declaration) { + is KSClassDeclaration -> declaration.toDisplayString() + else -> toString() + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/chainIf.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/chainIf.kt new file mode 100644 index 000000000..aca314c93 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/chainIf.kt @@ -0,0 +1,6 @@ +package dev.enro.processor.extensions + +fun T.chainIf(predicate: Boolean, block: T.() -> T): T { + if (!predicate) return this + return block() +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/getNameFromKClass.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/getNameFromKClass.kt new file mode 100644 index 000000000..40c05ffe0 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/getNameFromKClass.kt @@ -0,0 +1,24 @@ +package dev.enro.processor.extensions + +import com.google.devtools.ksp.KSTypeNotPresentException +import com.google.devtools.ksp.KspExperimental +import com.squareup.javapoet.ClassName +import javax.lang.model.type.MirroredTypeException +import kotlin.reflect.KClass + +@OptIn(KspExperimental::class) +internal fun getNameFromKClass(block: () -> KClass<*>) : String { + val exception = runCatching { + return block().java.name + }.exceptionOrNull() + + return when (exception) { + is KSTypeNotPresentException -> { + requireNotNull(exception.ksType.declaration.qualifiedName).asString() + } + is MirroredTypeException -> { + ClassName.get(exception.typeMirror).toString() + } + else -> throw exception!!//error("getNameFromKClass did not throw an exception as expected") + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/getNameFromKClasses.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/getNameFromKClasses.kt new file mode 100644 index 000000000..76e6d0234 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/getNameFromKClasses.kt @@ -0,0 +1,28 @@ +package dev.enro.processor.extensions + +import com.google.devtools.ksp.KSTypesNotPresentException +import com.google.devtools.ksp.KspExperimental +import com.squareup.javapoet.ClassName +import javax.lang.model.type.MirroredTypesException +import kotlin.reflect.KClass + +@OptIn(KspExperimental::class) +internal fun getNamesFromKClasses(block: () -> Array>): List { + val exception = runCatching { + block().map { it.java.name } + }.exceptionOrNull() + + return when (exception) { + is KSTypesNotPresentException -> { + exception.ksTypes.map { type -> + requireNotNull(type.declaration.qualifiedName).asString() + } + } + is MirroredTypesException -> { + exception.typeMirrors.map { typeMirror -> + ClassName.get(typeMirror).toString() + } + } + else -> emptyList() + } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/generator/NavigationBindingGenerator.kt b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationBindingGenerator.kt new file mode 100644 index 000000000..2c1aa0df1 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationBindingGenerator.kt @@ -0,0 +1,613 @@ +package dev.enro.processor.generator + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.annotations.GeneratedNavigationBinding +import dev.enro.annotations.NavigationPath +import dev.enro.processor.domain.DestinationReference +import dev.enro.processor.extensions.EnroLocation +import dev.enro.processor.extensions.toDisplayString + +object NavigationBindingGenerator { + + @OptIn(KspExperimental::class) + fun generate( + environment: SymbolProcessorEnvironment, + resolver: Resolver, + destinationDeclaration: KSDeclaration, + ) { + val destination = DestinationReference(resolver, destinationDeclaration) + + if (destination.isProperty) { + val propertyClassDeclaration = destination.keyTypeFromPropertyProvider + if (propertyClassDeclaration == null) { + environment.logger.error("Cannot find property type for ${destinationDeclaration.simpleName.asString()}") + return + } + val propertyType = propertyClassDeclaration + if (!destination.keyType.asStarProjectedType().isAssignableFrom(propertyType)) { + environment.logger.error( + message = "${destinationDeclaration.simpleName.asString()} is annotated with @NavigationDestination(${destination.keyType.toDisplayString()}::class) but is a NavigationDestinationProvider<${propertyType.toDisplayString()}>", + symbol = destinationDeclaration, + ) + return + } + } + + val typeSpec = TypeSpec.classBuilder(destination.bindingName) + .addModifiers(KModifier.PUBLIC) + .addSuperinterface( + ClassName("dev.enro.controller", "NavigationModuleAction") + ) + .addAnnotation( + AnnotationSpec.builder(GeneratedNavigationBinding::class.java) + .addMember( + "destination = %L", + CodeBlock.of("\"${requireNotNull(destinationDeclaration.qualifiedName).asString()}\"") + ) + .addMember( + "navigationKey = %L", + CodeBlock.of("\"${requireNotNull(destination.keyType.qualifiedName).asString()}\"") + ) + .build() + ) + .addFunction( + FunSpec.builder("invoke") + .receiver(ClassName("dev.enro.controller", "NavigationModule.BuilderScope")) + .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) + .returns(Unit::class.java) + .addNavigationDestination( + environment = environment, + destination = destination, + ) + .addPathBinding( + environment = environment, + destination = destination, + ) + .build() + ) + .build() + + FileSpec + .builder(EnroLocation.GENERATED_PACKAGE, requireNotNull(typeSpec.name)) + .addType(typeSpec) + .addImport( + destinationDeclaration.packageName.asString(), + requireNotNull(destinationDeclaration.qualifiedName).asString() + .removePrefix(destinationDeclaration.packageName.asString()) + ) + .addImport("dev.enro.controller", "NavigationModule") + .addImport("dev.enro.ui", "navigationDestination") + .addImport("dev.enro.path", "NavigationPathBinding") + .addImport("dev.enro.path", "createPathBinding") + .addImport("dev.enro.path", "fromBinding") + .apply { + when { + destination.isActivity -> addImport("dev.enro.ui.destinations", "activityDestination") + destination.isFragment -> addImport("dev.enro.ui.destinations", "fragmentDestination") + } + } + .build() + .writeTo( + codeGenerator = environment.codeGenerator, + dependencies = Dependencies( + aggregating = false, + sources = arrayOf(requireNotNull(destinationDeclaration.containingFile)), + ) + ) + } + + private fun FunSpec.Builder.addNavigationDestination( + environment: SymbolProcessorEnvironment, + destination: DestinationReference, + ): FunSpec.Builder { + val formatting = LinkedHashMap() + formatting["keyType"] = destination.keyType.asStarProjectedType().toTypeName() + formatting["keyName"] = destination.keyType.toClassName() + + val destinationName = when { + destination.isClass -> { + formatting["destinationType"] = destination.toClassName() + "%destinationType:T" + } + destination.isProperty -> { + formatting["destinationProperty"] = destination.declaration.simpleName.asString() + "%destinationProperty:L" + } + destination.isFunction -> { + formatting["destinationFun"] = destination.declaration.simpleName.asString() + "%destinationFun:L" + } + + else -> { + environment.logger.error( + "Could not generate NavigationDestination for ${destination.declaration.qualifiedName?.asString()}. " + + "This is likely because the destination is not a class or function." + ) + "INVALID_DESTINATION" + } + } + val platformOverride = when(destination.isPlatformOverride) { + true -> ", isPlatformOverride = true" + else -> "" + } + when { + destination.isClass -> when { + destination.isFragment -> addNamedCode( + "destination(fragmentDestination(%keyType:T::class, %destinationType:T::class)$platformOverride)", + formatting, + ) + destination.isActivity -> addNamedCode( + "destination(activityDestination(%keyType:T::class, %destinationType:T::class)$platformOverride)", + formatting, + ) + else -> environment.logger.error( + "${destination.declaration.qualifiedName?.asString()} is not a valid enro class destination." + ) + } + destination.isProperty -> addNamedCode( + "destination($destinationName$platformOverride)", + formatting, + ) + destination.isComposable -> addNamedCode( + "destination(navigationDestination<%keyType:T> { $destinationName() }$platformOverride)", + formatting, + ) + else -> { + environment.logger.error( + "${destination.declaration.qualifiedName?.asString()} is not a valid navigation destination for Enro." + ) + } + } + + return this + } + + @OptIn(KspExperimental::class, ExperimentalEnroApi::class) + private fun FunSpec.Builder.addPathBinding( + environment: SymbolProcessorEnvironment, + destination: DestinationReference, + ): FunSpec.Builder { + val navigationPaths = destination.keyType.getAnnotationsByType(NavigationPath::class) + .map { + val isObject = destination.keyType.classKind == ClassKind.OBJECT + val constructor = if (isObject) null else destination.keyType.primaryConstructor + return@map it to constructor + } + .plus( + destination.keyType.getConstructors() + .flatMap { constructor -> + constructor.getAnnotationsByType(NavigationPath::class).toList().map { + it to constructor + } + } + ) + .toList() + + navigationPaths.forEach { (path, constructor) -> + val pattern = path.pattern + val params = pathPatternToParameterNames(pattern) + + val hasValueClassParam = constructor != null && constructor.parameters.any { param -> + val paramName = param.name?.asString() ?: return@any false + if (paramName !in params) return@any false + param.type.resolve().declaration.isValueClass() + } + val exceedsHelperArity = params.size > 8 + + if (constructor != null && (hasValueClassParam || exceedsHelperArity)) { + addExplicitPathBinding(environment, destination, pattern, constructor) + } else { + addHelperPathBinding(destination, pattern, constructor) + } + } + + addFromBindingPaths(environment, destination) + return this + } + + @OptIn(KspExperimental::class) + private fun FunSpec.Builder.addFromBindingPaths( + environment: SymbolProcessorEnvironment, + destination: DestinationReference, + ): FunSpec.Builder { + val fromBindingAnnotations = destination.keyType.annotations + .filter { annotation -> + annotation.annotationType.resolve().declaration.qualifiedName?.asString() == + "dev.enro.annotations.NavigationPath.FromBinding" + } + .toList() + + if (fromBindingAnnotations.isEmpty()) return this + + val pathBindingFqn = "dev.enro.NavigationKey.PathBinding" + + fromBindingAnnotations.forEach { annotation -> + val bindingTypeArg = annotation.arguments + .firstOrNull { it.name?.asString() == "binding" } + ?.value as? KSType + val bindingDecl = bindingTypeArg?.declaration as? KSClassDeclaration + if (bindingDecl == null) { + environment.logger.error( + "@NavigationPath.FromBinding could not resolve binding class on ${destination.keyType.qualifiedName?.asString()}", + destination.declaration, + ) + return@forEach + } + + if (bindingDecl.classKind != ClassKind.OBJECT) { + environment.logger.error( + "@NavigationPath.FromBinding referenced class '${bindingDecl.qualifiedName?.asString()}' must be an object (singleton).", + bindingDecl, + ) + return@forEach + } + + val implementsPathBinding = bindingDecl.getAllSuperTypes().any { superType -> + val decl = superType.declaration + if (decl.qualifiedName?.asString() != pathBindingFqn) return@any false + val typeArg = superType.arguments.firstOrNull()?.type?.resolve() + typeArg?.declaration?.qualifiedName?.asString() == + destination.keyType.qualifiedName?.asString() + } + if (!implementsPathBinding) { + environment.logger.error( + "@NavigationPath.FromBinding referenced class '${bindingDecl.qualifiedName?.asString()}' " + + "must implement NavigationKey.PathBinding<${destination.keyType.qualifiedName?.asString()}>.", + bindingDecl, + ) + return@forEach + } + + val formatting = LinkedHashMap() + formatting["keyType"] = destination.keyType.toClassName() + formatting["binding"] = bindingDecl.toClassName() + + addCode("\n") + addNamedCode( + """ + path( + NavigationPathBinding.fromBinding( + keyType = %keyType:T::class, + binding = %binding:T, + ) + ) + """.trimIndent(), + formatting, + ) + } + return this + } + + private fun FunSpec.Builder.addHelperPathBinding( + destination: DestinationReference, + pattern: String, + constructor: KSFunctionDeclaration?, + ): FunSpec.Builder { + val constructorReference = when { + constructor == null -> "{ %T }" + else -> "::%T" + } + + addCode("\n") + val params = pathPatternToParameterNames(pattern) + + val typeName = destination.keyType.asStarProjectedType().toTypeName() + val paramTypeArray = params.map { typeName }.toTypedArray() + val paramReferences = params.map { "%T::$it" }.joinToString("\n") { + " $it," + } + + addCode( + """ + path( + NavigationPathBinding.createPathBinding( + pattern = %S,${"\n"}$paramReferences + constructor = $constructorReference + ) + ) + """.trimIndent(), + pattern, + *paramTypeArray, + typeName, + ) + return this + } + + private fun FunSpec.Builder.addExplicitPathBinding( + environment: SymbolProcessorEnvironment, + destination: DestinationReference, + pattern: String, + constructor: KSFunctionDeclaration, + ): FunSpec.Builder { + val patternParams = pathPatternToParameterNames(pattern) + val optionalParams = pathPatternToOptionalParameterNames(pattern) + val keyTypeName = destination.keyType.toClassName() + + // Resolve each pattern param to its constructor param + serialization shape. + val resolved = patternParams.mapNotNull { paramName -> + val ksParam = constructor.parameters.firstOrNull { it.name?.asString() == paramName } + if (ksParam == null) { + environment.logger.error( + "Path pattern parameter '$paramName' does not match any parameter on the annotated constructor of ${destination.keyType.qualifiedName?.asString()}", + constructor, + ) + return@mapNotNull null + } + val ksType = ksParam.type.resolve() + val isOptional = paramName in optionalParams + val nullable = ksType.isMarkedNullable + if (nullable != isOptional) { + environment.logger.error( + "Path pattern parameter '$paramName' is ${if (isOptional) "optional" else "required"}, " + + "but constructor parameter is ${if (nullable) "nullable" else "non-nullable"}", + ksParam, + ) + return@mapNotNull null + } + val shape = resolvePathParamShape(environment, ksType, ksParam) + ?: return@mapNotNull null + ResolvedPathParam(paramName, shape, nullable) + } + if (resolved.size != patternParams.size) return this + + // Build deserialize body: K(p1 = ..., p2 = ...) + // Build serialize body: set(...), key.x?.let { ... } + val deserializeLines = mutableListOf() + val serializeLines = mutableListOf() + val codeArgs = mutableListOf() + + deserializeLines.add("%keyType:T(") + resolved.forEachIndexed { index, p -> + val deserializeExpr = buildDeserializeExpr(p, codeArgs) + deserializeLines.add(" ${p.name} = $deserializeExpr,") + val serializeStmt = buildSerializeStmt(p, codeArgs) + serializeLines.add(serializeStmt) + } + deserializeLines.add(")") + + val deserializeBody = deserializeLines.joinToString("\n ") + val serializeBody = serializeLines.joinToString("\n ") + + val formatting = LinkedHashMap() + formatting["keyType"] = keyTypeName + formatting["pattern"] = pattern + codeArgs.forEachIndexed { i, value -> formatting["arg$i"] = value } + + addCode("\n") + addNamedCode( + """ + path( + NavigationPathBinding( + keyType = %keyType:T::class, + pattern = %pattern:S, + deserialize = { + $deserializeBody + }, + serialize = { + $serializeBody + }, + ) + ) + """.trimIndent(), + formatting, + ) + return this + } +} + +private data class PathParamShape( + val primitiveFqn: String, + val valueClassTypeName: TypeName?, + val valueClassUnderlyingPropertyName: String?, +) + +private data class ResolvedPathParam( + val name: String, + val shape: PathParamShape, + val isNullable: Boolean, +) + +private fun resolvePathParamShape( + environment: SymbolProcessorEnvironment, + ksType: KSType, + sourceSymbol: com.google.devtools.ksp.symbol.KSNode, +): PathParamShape? { + val decl = ksType.declaration + val primitiveFqn = decl.qualifiedName?.asString() + if (primitiveFqn in SUPPORTED_PRIMITIVE_FQNS) { + return PathParamShape( + primitiveFqn = primitiveFqn!!, + valueClassTypeName = null, + valueClassUnderlyingPropertyName = null, + ) + } + if (!decl.isValueClass()) { + environment.logger.error( + "Path parameter type '${primitiveFqn ?: decl.simpleName.asString()}' is not supported. " + + "Must be a primitive (String, Int, Long, Float, Double, Short, Byte, Char, Boolean) " + + "or a value class wrapping one of those primitives.", + sourceSymbol, + ) + return null + } + val valueClass = decl as KSClassDeclaration + val underlyingParam = valueClass.primaryConstructor?.parameters?.singleOrNull() + if (underlyingParam == null) { + environment.logger.error( + "Value class '${valueClass.qualifiedName?.asString()}' must have exactly one constructor parameter to be used as a path parameter.", + sourceSymbol, + ) + return null + } + val underlyingType = underlyingParam.type.resolve() + if (underlyingType.isMarkedNullable) { + environment.logger.error( + "Value class '${valueClass.qualifiedName?.asString()}' wraps a nullable type, which is not supported for path parameters.", + sourceSymbol, + ) + return null + } + val underlyingFqn = underlyingType.declaration.qualifiedName?.asString() + if (underlyingFqn !in SUPPORTED_PRIMITIVE_FQNS) { + environment.logger.error( + "Value class '${valueClass.qualifiedName?.asString()}' wraps unsupported type '$underlyingFqn'. " + + "Only value classes wrapping a primitive (String, Int, Long, Float, Double, Short, Byte, Char, Boolean) are supported.", + sourceSymbol, + ) + return null + } + return PathParamShape( + primitiveFqn = underlyingFqn!!, + valueClassTypeName = valueClass.toClassName(), + valueClassUnderlyingPropertyName = underlyingParam.name?.asString(), + ) +} + +private fun buildDeserializeExpr( + param: ResolvedPathParam, + codeArgs: MutableList, +): String { + val accessor = if (param.isNullable) { + "optional(\"${param.name}\")" + } else { + "require(\"${param.name}\")" + } + val converted = primitiveParseExpression(param.shape.primitiveFqn, accessor, param.isNullable) + return if (param.shape.valueClassTypeName != null) { + codeArgs.add(param.shape.valueClassTypeName) + val placeholder = "%arg${codeArgs.size - 1}:T" + if (param.isNullable) { + "$converted?.let { $placeholder(it) }" + } else { + "$placeholder($converted)" + } + } else { + converted + } +} + +private fun buildSerializeStmt( + param: ResolvedPathParam, + codeArgs: MutableList, +): String { + val unwrap = if (param.shape.valueClassUnderlyingPropertyName != null) { + "v.${param.shape.valueClassUnderlyingPropertyName}" + } else { + "v" + } + val stringified = if (param.shape.primitiveFqn == "kotlin.String") unwrap else "$unwrap.toString()" + return if (param.isNullable) { + "it.${param.name}?.let { v -> set(\"${param.name}\", $stringified) }" + } else { + val nonNullSource = if (param.shape.valueClassUnderlyingPropertyName != null) { + "it.${param.name}.${param.shape.valueClassUnderlyingPropertyName}" + } else { + "it.${param.name}" + } + val nonNullStringified = if (param.shape.primitiveFqn == "kotlin.String") nonNullSource else "$nonNullSource.toString()" + "set(\"${param.name}\", $nonNullStringified)" + } +} + +private fun primitiveParseExpression( + primitiveFqn: String, + source: String, + isNullable: Boolean, +): String { + val nullableSuffix = if (isNullable) "?" else "" + return when (primitiveFqn) { + "kotlin.String" -> source + "kotlin.Int" -> "$source$nullableSuffix.toInt()" + "kotlin.Long" -> "$source$nullableSuffix.toLong()" + "kotlin.Float" -> "$source$nullableSuffix.toFloat()" + "kotlin.Double" -> "$source$nullableSuffix.toDouble()" + "kotlin.Short" -> "$source$nullableSuffix.toShort()" + "kotlin.Byte" -> "$source$nullableSuffix.toByte()" + "kotlin.Char" -> "$source$nullableSuffix.first()" + "kotlin.Boolean" -> "$source$nullableSuffix.toBoolean()" + else -> error("Unsupported primitive: $primitiveFqn") + } +} + +private fun KSDeclaration.isValueClass(): Boolean { + if (this !is KSClassDeclaration) return false + if (Modifier.VALUE in modifiers || Modifier.INLINE in modifiers) return true + // A declaration resolved from a compiled dependency module (jar/klib) does + // not reliably report the Kotlin `value`/`inline` modifier through KSP — so + // a value class defined in another module (e.g. a typed id in + // :feature:core:api referenced by a destination processed in + // :feature:core:client) would be missed. Fall back to matching the + // @JvmInline annotation by simple name: every multiplatform value class + // carries it, and on native targets annotation *type resolution* across + // modules can fail, so we deliberately avoid resolving the fully-qualified + // name and match the short name instead. + return annotations.any { it.shortName.asString() == "JvmInline" } +} + +private val SUPPORTED_PRIMITIVE_FQNS = setOf( + "kotlin.String", + "kotlin.Int", + "kotlin.Long", + "kotlin.Float", + "kotlin.Double", + "kotlin.Short", + "kotlin.Byte", + "kotlin.Char", + "kotlin.Boolean", +) + +private fun pathPatternToOptionalParameterNames(pattern: String): Set { + val split = pattern.split("?", limit = 2) + val query = split.getOrNull(1) ?: return emptySet() + return query.split("&") + .mapNotNull { it.split("=", limit = 2).getOrNull(1) } + .filter { it.startsWith("{") && it.endsWith("?}") } + .map { it.removePrefix("{").removeSuffix("?}") } + .toSet() +} + +private fun pathPatternToParameterNames(pattern: String): List { + val split = pattern.split("?", limit = 2) + val path = split[0] + val query = if (split.size > 1) split[1] else null + val pathParameters = path + .split("/") + .filter { it.startsWith("{") && it.endsWith("}") } + + val queryParameters = query?.split("&") + .orEmpty() + .mapNotNull { it.split("=", limit = 2).getOrNull(1) } + .filter { it.startsWith("{") && it.endsWith("}") } + + return (pathParameters + queryParameters) + .map { + it.removePrefix("{") + .removeSuffix("}") + .removeSuffix("?") + } + .filter { it.isNotEmpty() } +} \ No newline at end of file diff --git a/enro-processor/src/main/java/dev/enro/processor/generator/NavigationComponentGenerator.kt b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationComponentGenerator.kt new file mode 100644 index 000000000..9c4287763 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationComponentGenerator.kt @@ -0,0 +1,255 @@ +package dev.enro.processor.generator + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.writeTo +import dev.enro.annotations.GeneratedNavigationComponent +import dev.enro.processor.domain.ComponentReference +import dev.enro.processor.domain.GeneratedBindingReference +import dev.enro.processor.extensions.ClassNames +import dev.enro.processor.extensions.EnroLocation +import dev.enro.processor.extensions.chainIf + +object NavigationComponentGenerator { + + @OptIn(KspExperimental::class) + fun generate( + environment: SymbolProcessorEnvironment, + platform: ResolverPlatform, + component: ComponentReference, + bindings: List, + ) { + val isIos = platform is ResolverPlatform.Ios + val isDesktop = platform is ResolverPlatform.JvmDesktop + val isAndroid = platform is ResolverPlatform.Android + + val bindingNames = bindings.joinToString(separator = ",\n") { + "${it.qualifiedName}::class" + } + + val generatedName = "${component.simpleName}Navigation" + val generatedComponent = TypeSpec.classBuilder(generatedName) + .addAnnotation( + AnnotationSpec.builder(GeneratedNavigationComponent::class.java) + .addMember("bindings = [\n$bindingNames\n]") + .build() + ) + .addModifiers(KModifier.PUBLIC) + .addSuperinterface( + ClassName("dev.enro.controller", "NavigationModuleAction") + ) + .addFunction( + FunSpec.builder("invoke") + .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) + .receiver(ClassName("dev.enro.controller", "NavigationModule.BuilderScope")) + .returns(Unit::class.java) + .apply { + bindings.forEach { + addStatement( + "%T().apply { invoke() }", + ClassName( + EnroLocation.GENERATED_PACKAGE, + it.qualifiedName.split(".").last() + ) + ) + } + } + .build() + ) + .build() + + // Generate extension function for installing navigation controller + val functionName = "installNavigationController" + val (platformParameterName, addPlatformParameter) = createPlatformApplicationReferenceParameter(platform) + + val extensionFunction = FunSpec.builder(functionName) + .addModifiers(KModifier.PUBLIC) + .returns(ClassNames.Kotlin.navigationController) + .addPlatformParameter() + .addParameter( + ParameterSpec.builder("isDebug", Boolean::class) + .defaultValue("false") + .build() + ) + .addParameter( + ParameterSpec.builder( + "block", + LambdaTypeName.get( + receiver = ClassName("dev.enro.controller", "NavigationModule.BuilderScope"), + returnType = ClassNames.Kotlin.unit + ) + ) + .defaultValue("{}") + .build() + ) + .chainIf(isAndroid) { + addCode( + """ + // If we're installing in an Android context, we know that we + // can use Log.e, so we force EnroLog to use Android Logs during + // installation so we log anything that happens during installation, + // and we then reset the "force" afterwards. + // It's important to reset it, as we might be running in a Robolectric + // test, and other non-Robolectric tests might need to use the default logging. + dev.enro.platform.EnroLog.forceAndroidLogs = true + """.trimIndent() + "\n" + ) + } + .addCode( + """ + val controller = internalCreateEnroController( + isDebug = isDebug, + builder = { + ${generatedComponent.name}().apply { + module(module) + invoke() + } + block() + } + ) + controller.install($platformParameterName) + """.trimIndent() + "\n" + ) + .chainIf(isAndroid) { + addCode( + """ + dev.enro.platform.EnroLog.forceAndroidLogs = false + """.trimIndent() + "\n" + ) + } + .addCode( + """ + return controller + """.trimIndent() + ) + .receiver(component.className) + .build() + + val desktopFunction = when { + !isDesktop -> null + else -> extensionFunction.toBuilder("rememberNavigationController") + .apply { modifiers.clear() } + .apply { + val updatedParameters = + parameters.filterNot { it.name == platformParameterName } + parameters.clear() + parameters.addAll(updatedParameters) + } + .addModifiers(KModifier.PUBLIC) + .addAnnotation(ClassNames.Kotlin.composable) + .clearBody() + .addCode( + CodeBlock.of( + """ + return androidx.compose.runtime.remember { + installNavigationController( + $platformParameterName = Unit, + isDebug = isDebug, + block = block, + ) + } + """.trimIndent() + ) + ) + .build() + } + + val fileSpec = FileSpec + .builder( + component.className.packageName, + requireNotNull(generatedComponent.name) + ) + .addAnnotation( + AnnotationSpec.builder(Suppress::class) + .addMember("\"INVISIBLE_REFERENCE\", \"INVISIBLE_MEMBER\"") + .build() + ) + .addImport("dev.enro.controller", "NavigationModule") + .addImport( + packageName = "dev.enro.controller", + names = arrayOf("internalCreateEnroController"), + ) + .addType(generatedComponent) + .addFunction(extensionFunction) + .let { + if (desktopFunction == null) return@let it + it.addFunction(desktopFunction) + } + .build() + + fileSpec.writeTo( + codeGenerator = environment.codeGenerator, + dependencies = Dependencies( + aggregating = true, + sources = bindings.mapNotNull { it.containingFile } + .plus(listOfNotNull(component.containingFile)) + .toTypedArray() + ) + ) + } + + @OptIn(KspExperimental::class) + fun createPlatformApplicationReferenceParameter( + resolverPlatform: ResolverPlatform + ): Pair FunSpec.Builder> { + when { + resolverPlatform is ResolverPlatform.Android -> { + return "application" to { + addParameter( + ParameterSpec.builder( + "application", + resolverPlatform.androidApplicationClassName, + ).build() + ) + } + } + + resolverPlatform is ResolverPlatform.JvmDesktop -> { + return "ignored" to { + addParameter( + ParameterSpec.builder( + "ignored", + ClassNames.Kotlin.unit, + ).build() + ) + } + } + + resolverPlatform is ResolverPlatform.WasmJs -> { + return "document" to { + addParameter( + ParameterSpec.builder( + "document", + resolverPlatform.webDocumentClassName, + ).build() + ) + } + } + + resolverPlatform is ResolverPlatform.Ios -> { + return "application" to { + addParameter( + ParameterSpec.builder( + "application", + resolverPlatform.uiApplicationClassName, + ).build() + ) + } + } + + else -> { + error("Unsupported platform!") + } + } + } +} diff --git a/enro-processor/src/main/java/dev/enro/processor/generator/ResolverPlatform.kt b/enro-processor/src/main/java/dev/enro/processor/generator/ResolverPlatform.kt new file mode 100644 index 000000000..e800bcb94 --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/generator/ResolverPlatform.kt @@ -0,0 +1,58 @@ +package dev.enro.processor.generator + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getKotlinClassByName +import com.google.devtools.ksp.processing.Resolver +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.ksp.toClassName + +sealed class ResolverPlatform { + class Android( + val androidApplicationClassName: ClassName, + ) : ResolverPlatform() + + class Ios( + val uiApplicationClassName: ClassName, + ) : ResolverPlatform() + + class JvmDesktop : ResolverPlatform() + + class WasmJs( + val webDocumentClassName: ClassName, + ) : ResolverPlatform() + + data object Other : ResolverPlatform() + + companion object { + @OptIn(KspExperimental::class) + fun getPlatform(resolver: Resolver) : ResolverPlatform { + val isAndroid = resolver.getKotlinClassByName("dev.enro.platform.EnroPlatformAndroid") != null + if (isAndroid) { + return ResolverPlatform.Android( + androidApplicationClassName = resolver.getKotlinClassByName("android.app.Application")!!.toClassName() + ) + } + + val isIos = resolver.getKotlinClassByName("dev.enro.platform.EnroPlatformIOS") != null + if (isIos) { + return ResolverPlatform.Ios( + uiApplicationClassName = resolver.getKotlinClassByName("platform.UIKit.UIApplication")!!.toClassName() + ) + } + + val isDesktop = resolver.getKotlinClassByName("dev.enro.platform.EnroPlatformDesktop") != null + if (isDesktop) { + return ResolverPlatform.JvmDesktop() + } + + val isWeb = resolver.getKotlinClassByName("dev.enro.platform.EnroPlatformWasmJs") != null + if (isWeb) { + return ResolverPlatform.WasmJs( + webDocumentClassName = resolver.getKotlinClassByName("org.w3c.dom.Document")!!.toClassName() + ) + } + + return ResolverPlatform.Other + } + } +} diff --git a/enro-masterdetail/.gitignore b/enro-runtime/.gitignore similarity index 100% rename from enro-masterdetail/.gitignore rename to enro-runtime/.gitignore diff --git a/enro-runtime/README.md b/enro-runtime/README.md new file mode 100644 index 000000000..aa9f4b075 --- /dev/null +++ b/enro-runtime/README.md @@ -0,0 +1,54 @@ +# `enro-runtime` + +The runtime engine of Enro. Defines the public API every Enro app touches +at runtime: [`NavigationKey`](src/commonMain/kotlin/dev/enro/NavigationKey.kt), +[`NavigationHandle`](src/commonMain/kotlin/dev/enro/NavigationHandle.kt), +[`NavigationContainer`](src/commonMain/kotlin/dev/enro/NavigationContainer.kt), +[`NavigationDisplay`](src/commonMain/kotlin/dev/enro/ui/NavigationDisplay.kt), +plus the operation / scene / result-channel machinery that makes them +work. If you're declaring a destination or driving navigation from +inside one, you're using this module. + +Targets **Android, iOS, JVM Desktop, and WASM JS** through Compose +Multiplatform. Non-UI definitions that need to be shared with non-UI +targets (e.g. a NodeJS backend) live in [`enro-common`](../enro-common/) +instead — `enro-runtime` re-exports them. + +## What's in here + +- **Keys & handles** — `NavigationKey` (the screen contract), + `NavigationKey.WithResult` (typed completion), `NavigationHandle` (the + destination's view of the runtime). +- **Containers & operations** — `NavigationContainer` (a hosted + backstack), `NavigationOperation` (Open / Close / Complete / + CompleteFrom / SetBackstack and their aggregates). +- **Scenes & display** — `NavigationDisplay` (Compose renderer), + `NavigationSceneStrategy` / `SceneDecoratorStrategy` (how a backstack + becomes a scene tree), built-in `SinglePane` / `Dialog` / `DirectOverlay` + scene strategies. +- **Results** — `registerForNavigationResult` (the caller's side), + `NavigationKey.WithResult.complete(…)` (the callee's side), + `NavigationFlow` (multi-step flows). +- **Path bindings** — `@NavigationPath` and the path-resolution APIs that + turn URLs into keys and back, used for deep-linking and web routing. +- **Synthetic destinations** — `syntheticDestination { … }` for keys + whose "destination" is a synchronous decision, not UI (auth gates, + external URL launchers, redirects). + +## Typical usage + +Apps usually depend on the meta-module `dev.enro:enro`, which pulls in +`enro-runtime` along with the KSP processor and a sensible default +configuration. Depending on `enro-runtime` directly is for advanced +setups (custom processors, slimmer artefacts). + +```kotlin +dependencies { + implementation("dev.enro:enro:3.0.0-beta01") + ksp("dev.enro:enro-processor:3.0.0-beta01") +} +``` + +See the root [README](../README.md) for a working "define a key, render +a destination, navigate" example, and [enro.dev](https://enro.dev) for +the full guide. diff --git a/enro-runtime/build.gradle.kts b/enro-runtime/build.gradle.kts new file mode 100644 index 000000000..c79968d5a --- /dev/null +++ b/enro-runtime/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + id("com.google.devtools.ksp") + id("configure-library") + id("configure-publishing") + id("configure-compose") + kotlin("plugin.serialization") +} + +kotlin { + // Enable the Android host (unit) test component so commonTest also runs on the + // Android JVM target. + androidLibrary { + withHostTest { + // Return default values for unmocked Android framework methods + // rather than throwing "Method ... not mocked" — defensive against + // any test path that brushes a stub from android.jar (e.g. + // savedstate's Bundle). Tests that genuinely exercise framework + // classes should extend dev.enro.test.platform.RobolectricHostTest + // to run under Robolectric on this target instead of the stubs. + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } +} + +// These tests use Compose-UI-test or platform SavedState APIs that require a real +// Android runtime (Robolectric or instrumentation) to function. They already run on +// :enro-runtime:desktopTest (and iosSimulatorArm64Test) — keep coverage there and skip +// the JVM-only Android host-test pass. +tasks.withType().configureEach { + if (name.contains("AndroidHostTest", ignoreCase = true)) { + filter { + excludeTestsMatching("dev.enro.SceneHarnessSmokeTest") + excludeTestsMatching("dev.enro.SceneIntegrationTests") + excludeTestsMatching("dev.enro.BackstackSavedStateTests") + } + } +} + +kotlin { + sourceSets { + desktopMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + } + commonMain.dependencies { + api("dev.enro:enro-annotations:${project.enroVersionName}") + api("dev.enro:enro-common:${project.enroVersionName}") + implementation(libs.compose.runtime) + api(libs.compose.viewmodel) + implementation(libs.compose.lifecycle) + api(libs.compose.navigationEvent) + implementation(libs.androidx.savedState) + implementation(libs.androidx.savedState.compose) + implementation(libs.kotlinx.serialization) + implementation(libs.kotlin.reflect) + implementation(libs.thauvin.urlencoder) + } + commonTest.dependencies { + implementation(project(":enro-test")) + implementation(libs.compose.uiTest) + } + val androidHostTest by getting { + dependencies { + implementation(libs.testing.robolectric) + } + } + androidMain.dependencies { + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.fragment) + implementation(libs.androidx.fragment.compose) + implementation(libs.androidx.activity) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.lifecycle.process) + implementation(libs.kotlin.reflect) + } + + wasmJsMain.dependencies { + implementation(libs.kotlin.js) + } + } +} \ No newline at end of file diff --git a/enro-runtime/consumer-rules.pro b/enro-runtime/consumer-rules.pro new file mode 100644 index 000000000..4401170be --- /dev/null +++ b/enro-runtime/consumer-rules.pro @@ -0,0 +1,9 @@ +-dontwarn dagger.hilt.** + +-keep class kotlin.LazyKt + +-keep class * extends dev.enro.NavigationKey + +#noinspection ShrinkerUnresolvedReference +-keep @dev.enro.annotations.GeneratedNavigationBinding public class ** +-keep @dev.enro.annotations.GeneratedNavigationComponent public class ** \ No newline at end of file diff --git a/enro-multistack/proguard-rules.pro b/enro-runtime/proguard-rules.pro similarity index 100% rename from enro-multistack/proguard-rules.pro rename to enro-runtime/proguard-rules.pro diff --git a/enro-runtime/src/androidHostTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt b/enro-runtime/src/androidHostTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt new file mode 100644 index 000000000..760d98c0b --- /dev/null +++ b/enro-runtime/src/androidHostTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt @@ -0,0 +1,7 @@ +package dev.enro.test.platform + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +actual abstract class RobolectricHostTest actual constructor() diff --git a/enro-runtime/src/androidMain/AndroidManifest.xml b/enro-runtime/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..2ac0e376b --- /dev/null +++ b/enro-runtime/src/androidMain/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/NavigationHandle.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/NavigationHandle.android.kt new file mode 100644 index 000000000..f12f38641 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/NavigationHandle.android.kt @@ -0,0 +1,62 @@ +package dev.enro + +import androidx.activity.ComponentActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import dev.enro.handle.getNavigationHandleHolder +import dev.enro.platform.getNavigationKeyInstance +import dev.enro.ui.destinations.fragment.FragmentNavigationHandle +import dev.enro.ui.destinations.fragment.fragmentContextHolder +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass + +public inline fun Fragment.navigationHandle(): ReadOnlyProperty> { + return navigationHandle(T::class) +} + +public fun Fragment.navigationHandle( + keyType: KClass, +): ReadOnlyProperty> { + return ReadOnlyProperty> { fragment, _ -> + require(lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { + "NavigationHandle can only be accessed after the Activity is in the CREATED state." + } + val holder = fragment.fragmentContextHolder + val navigation = holder.navigationHandle + val delegate = navigation.delegate + if (delegate is FragmentNavigationHandle.NotInitialized) { + fragment.arguments?.getNavigationKeyInstance()?.let { + delegate.instance = it + navigation.instance = it + } + } + require(keyType.isInstance(navigation.instance.key)) { + error("Expected NavigationHandle for ${keyType.qualifiedName}, but found ${navigation.instance.key::class.simpleName}") + } + @Suppress("UNCHECKED_CAST") + return@ReadOnlyProperty navigation as NavigationHandle + } +} + +public inline fun ComponentActivity.navigationHandle(): ReadOnlyProperty> { + return navigationHandle(T::class) +} + +public fun ComponentActivity.navigationHandle( + keyType: KClass, +): ReadOnlyProperty> { + val navigationHandle by lazy { + require(lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { + "NavigationHandle can only be accessed after the Activity is in the CREATED state." + } + val navigation = getNavigationHandleHolder().navigationHandle + require(keyType.isInstance(navigation.instance.key)) { + error("Expected NavigationHandle for ${keyType.qualifiedName}, but found ${navigation.instance.key::class.simpleName}") + } + @Suppress("UNCHECKED_CAST") + return@lazy navigation as NavigationHandle + } + return ReadOnlyProperty { activity, _ -> + navigationHandle + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/handle/RootNavigationHandle.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/handle/RootNavigationHandle.android.kt new file mode 100644 index 000000000..2c3246122 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/handle/RootNavigationHandle.android.kt @@ -0,0 +1,86 @@ +package dev.enro.handle + +import android.app.Activity +import android.content.Intent +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.RootContext +import dev.enro.platform.activity +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import dev.enro.ui.destinations.ActivityTypeKey +import dev.enro.ui.destinations.putNavigationKeyInstance +import kotlin.reflect.KClass + +internal actual fun RootNavigationHandle.handleNavigationOperationForPlatform( + operation: NavigationOperation, + context: RootContext, +): Boolean { + val operations = when(operation) { + is NavigationOperation.AggregateOperation -> operation.operations + else -> listOf(operation) + } + val close = operations + .filterIsInstance>() + .firstOrNull { it.instance.id == instance.id } + + val complete = operations.filterIsInstance>() + .firstOrNull { it.instance.id == instance.id } + + val opens = operations.filterIsInstance>() + .mapNotNull { + val activityType = context.controller.bindings + .bindingFor(it.instance) + .provider + .peekMetadata(it.instance) + .get(ActivityTypeKey) + + when (activityType) { + is KClass<*> -> it.instance to activityType + else -> null + } + } + + if (opens.isEmpty() && close == null && complete == null) return false + val activity = context.activity + val intents = opens.map { (instance, type) -> + Intent(activity, type.java).putNavigationKeyInstance(instance) + } + if (intents.isNotEmpty()) { + // TODO for result! + activity.startActivities(intents.toTypedArray()) + } + when { + complete != null -> { + activity.setResult(Activity.RESULT_OK, resultFromEnro()) + NavigationResultChannel.registerResult( + NavigationResult.Completed(instance, complete.result), + ) + activity.finish() + } + close != null -> { + if (!close.silent) { + activity.setResult(Activity.RESULT_CANCELED, resultFromEnro()) + NavigationResultChannel.registerResult( + NavigationResult.Closed(instance), + ) + } + activity.finish() + } + else -> {} + } + return true +} + +private const val RESULT_FROM_ENRO = "dev.enro.result.RESULT_FROM_ENRO" + +internal fun resultFromEnro(): Intent { + return Intent().apply { + putExtra(RESULT_FROM_ENRO, true) + } +} + +@PublishedApi +internal fun Intent.isResultFromEnro(): Boolean { + return getBooleanExtra(RESULT_FROM_ENRO, false) +} diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Activity.navigationContext.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Activity.navigationContext.kt new file mode 100644 index 000000000..337d34039 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Activity.navigationContext.kt @@ -0,0 +1,20 @@ +package dev.enro.platform + +import android.app.Activity +import androidx.activity.ComponentActivity +import dev.enro.context.RootContext +import dev.enro.handle.RootNavigationHandle +import dev.enro.handle.getNavigationHandleHolder + +public val Activity.navigationContext: RootContext + get() { + if (this !is ComponentActivity) { + error("Cannot retrieve navigation context from Activity that does not extend ComponentActivity") + } + val navigationHandle = getNavigationHandleHolder().navigationHandle + require(navigationHandle is RootNavigationHandle) { + "Expected $this to have a RootNavigationHandle, but found $navigationHandle" + } + return navigationHandle.context + ?: error("Navigation context is not available for this activity") + } \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/ActivityPlugin.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/ActivityPlugin.kt new file mode 100644 index 000000000..d38fea795 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/ActivityPlugin.kt @@ -0,0 +1,90 @@ +package dev.enro.platform + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.createSavedStateHandle +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.context.RootContext +import dev.enro.handle.RootNavigationHandle +import dev.enro.handle.getNavigationHandleHolder +import dev.enro.handle.getOrCreateNavigationHandleHolder +import dev.enro.plugin.NavigationPlugin +import dev.enro.ui.destinations.getNavigationKeyInstance +import kotlinx.serialization.Serializable + +@PublishedApi +internal object ActivityPlugin : NavigationPlugin() { + private const val ACTIVE_CONTAINER_KEY = "dev.enro.platform.ACTIVE_CONTAINER_KEY" + private const val SAVED_INSTANCE_KEY = "dev.enro.platform.SAVED_INSTANCE_KEY" + + private var callbacks: ActivityCallbacks? = null + + override fun onAttached(controller: EnroController) { + val application = controller.platformReference as? Application ?: return + if (callbacks != null) { + application.unregisterActivityLifecycleCallbacks(callbacks) + } + callbacks = ActivityCallbacks(controller) + application.registerActivityLifecycleCallbacks(callbacks) + } + + override fun onDetached(controller: EnroController) { + val application = controller.platformReference as? Application ?: return + application.unregisterActivityLifecycleCallbacks(callbacks) + callbacks = null + } + + private class ActivityCallbacks( + private val controller: EnroController, + ): Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity !is ComponentActivity) return + val rootContext = RootContext( + id = (activity::class.simpleName ?: "UnknownActivity")+"@${activity.hashCode()}", + parent = activity, + controller = controller, + lifecycleOwner = activity, + viewModelStoreOwner = activity, + defaultViewModelProviderFactory = activity, + activeChildId = mutableStateOf(savedInstanceState?.getString(ACTIVE_CONTAINER_KEY)) + ) + val instance = activity.intent.getNavigationKeyInstance() + ?: savedInstanceState?.getNavigationKeyInstance() + ?: NavigationKey.Instance(DefaultActivityNavigationKey) + + val navigationHandleHolder = activity.getOrCreateNavigationHandleHolder { + RootNavigationHandle( + instance = instance, + savedStateHandle = createSavedStateHandle(), + ) + } + val navigationHandle = navigationHandleHolder.navigationHandle + require(navigationHandle is RootNavigationHandle) + navigationHandle.bindContext(rootContext) + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + if (activity !is ComponentActivity) return + val navigationHandle = activity.getNavigationHandleHolder().navigationHandle + require(navigationHandle is RootNavigationHandle) { + "Expected Activity $activity to have a RootNavigationHandle but was $navigationHandle" + } + outState.putString(ACTIVE_CONTAINER_KEY, navigationHandle.context?.activeChild?.id) + outState.putNavigationKeyInstance(navigationHandle.instance) + } + + override fun onActivityDestroyed(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + } +} + + +@Serializable +internal object DefaultActivityNavigationKey : NavigationKey diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Application.enroController.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Application.enroController.kt new file mode 100644 index 000000000..fe42019e7 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Application.enroController.kt @@ -0,0 +1,13 @@ +package dev.enro.platform + +import android.app.Application +import dev.enro.EnroController + +public val Application.enroController: EnroController get() { + val instance = EnroController.instance + val reference = instance?.platformReference + require(this == reference) { + "The currently installed EnroController $instance is not installed with an Application reference. The current reference is $reference." + } + return instance +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Bundle.addNavigationKeyInstance.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Bundle.addNavigationKeyInstance.kt new file mode 100644 index 000000000..105b5b973 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/Bundle.addNavigationKeyInstance.kt @@ -0,0 +1,25 @@ +package dev.enro.platform + +import android.os.Bundle +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi + +private const val BundleInstanceKey = "dev.enro.platform.BundleInstanceKey" + +@AdvancedEnroApi +public fun Bundle.putNavigationKeyInstance(instance: NavigationKey.Instance): Bundle { + val savedStateConfig = requireNotNull(EnroController.instance).serializers.savedStateConfiguration + val encodedInstance = encodeToSavedState(instance, savedStateConfig) + putBundle(BundleInstanceKey, encodedInstance) + return this +} + +@AdvancedEnroApi +public fun Bundle.getNavigationKeyInstance(): NavigationKey.Instance? { + val encodedInstance = getBundle(BundleInstanceKey) ?: return null + val savedStateConfig = requireNotNull(EnroController.instance).serializers.savedStateConfiguration + return decodeFromSavedState(encodedInstance, savedStateConfig) +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroController.application.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroController.application.kt new file mode 100644 index 000000000..362d797a1 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroController.application.kt @@ -0,0 +1,16 @@ +package dev.enro.platform + +import android.app.Application +import dev.enro.EnroController + +internal val EnroController.application: Application get() { + val instance = EnroController.instance + val reference = platformReference + require(this == instance) { + "The EnroController $this is not the same as the currently installed EnroController $instance" + } + require(reference is Application) { + "The EnroController $this is not installed on an Application" + } + return reference +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroLog.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroLog.android.kt new file mode 100644 index 000000000..9c9289dc3 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroLog.android.kt @@ -0,0 +1,53 @@ +package dev.enro.platform + +import android.app.Application +import android.util.Log +import dev.enro.EnroController + +@PublishedApi +internal actual object EnroLog { + private const val LOG_TAG = "Enro" + + internal var forceAndroidLogs = false + + // Enabled/disabled by EnroTest + @Suppress("MemberVisibilityCanBePrivate") + internal val usePrint + get() = !forceAndroidLogs && EnroController.instance?.platformReference !is Application + + actual fun debug(message: String) { + if (usePrint) { + // In JVM tests, we don't have a logcat to write to, so we just print to stdout + println("[Debug] $LOG_TAG: $message") + return + } + Log.d(LOG_TAG, message) + } + + actual fun warn(message: String) { + if (usePrint) { + // In JVM tests, we don't have a logcat to write to, so we just print to stdout + println("[Warn] $LOG_TAG: $message") + return + } + Log.w(LOG_TAG, message) + } + + actual fun error(message: String) { + if (usePrint) { + // In JVM tests, we don't have a logcat to write to, so we just print to stdout + println("[Error] $LOG_TAG: $message") + return + } + Log.e(LOG_TAG, message) + } + + actual fun error(message: String, throwable: Throwable) { + if (usePrint) { + // In JVM tests, we don't have a logcat to write to, so we just print to stdout + println("[Error] $LOG_TAG: $message\n${throwable.stackTraceToString()}") + return + } + Log.e(LOG_TAG, message, throwable) + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroPlatform.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroPlatform.android.kt new file mode 100644 index 000000000..1c582cb1f --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/EnroPlatform.android.kt @@ -0,0 +1,3 @@ +package dev.enro.platform + +internal object EnroPlatformAndroid : EnroPlatform diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/RootContext.activity.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/RootContext.activity.kt new file mode 100644 index 000000000..1aa7d4d80 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/RootContext.activity.kt @@ -0,0 +1,12 @@ +package dev.enro.platform + +import androidx.activity.ComponentActivity +import dev.enro.context.RootContext + +public val RootContext.activity: ComponentActivity + get() { + require(parent is ComponentActivity) { + "The parent of the RootContext must be a ComponentActivity, but found ${parent::class.simpleName} instead." + } + return parent + } \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/platform/platformNavigationModule.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/platformNavigationModule.android.kt new file mode 100644 index 000000000..5de95465e --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/platform/platformNavigationModule.android.kt @@ -0,0 +1,50 @@ +package dev.enro.platform + +import dev.enro.NavigationKey +import dev.enro.controller.NavigationModule +import dev.enro.controller.createNavigationModule +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +internal actual val platformNavigationModule: NavigationModule = createNavigationModule { + applyCompatModule() + plugin(ActivityPlugin) + serializersModule(SerializersModule { + polymorphic(Any::class) { + subclass(DefaultActivityNavigationKey::class) + } + polymorphic(NavigationKey::class) { + subclass(DefaultActivityNavigationKey::class) + } + }) +} + +/** + * Attempts to load and apply the optional enro-compat module if it is present on the classpath. + * + * This function uses reflection to check for the presence of the `dev.enro.compat.EnroCompat` class + * at runtime. If found, it instantiates the class and retrieves its `compatModule` field, which + * contains compatibility-related navigation functionality. + * + * The enro-compat module is optional and provides backward compatibility support for applications + * migrating from older versions of Enro. By loading it dynamically at runtime, applications can + * include the compatibility module as a dependency only when needed, without requiring it as a + * compile-time dependency of the core module. + * + * If the compat module is successfully loaded, a warning log is emitted to inform developers that + * the compatibility layer is active. + */ +private fun NavigationModule.BuilderScope.applyCompatModule() { + val compatClass = runCatching { Class.forName("dev.enro.compat.EnroCompat") } + .getOrNull() ?: return + + val compat = compatClass.constructors.first().newInstance() + val compatModule = compatClass.declaredFields + .first { it.name == "compatModule" } + .get(compat) + + require(compatModule is NavigationModule) + module(compatModule) + EnroLog.error("The enro-compat module is active. This is not recommended for new applications. Please migrate to the new API as soon as possible, enro-compat will be removed in a future release.") +} diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/result/registerForNavigationResult.fragment.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/result/registerForNavigationResult.fragment.kt new file mode 100644 index 000000000..15f38b781 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/result/registerForNavigationResult.fragment.kt @@ -0,0 +1,64 @@ +package dev.enro.result + +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.lifecycleScope +import dev.enro.NavigationKey +import dev.enro.platform.getNavigationKeyInstance +import dev.enro.ui.destinations.fragment.fragmentContextHolder +import kotlinx.coroutines.Job +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass + + +public inline fun Fragment.registerForNavigationResult( + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onCompleted: NavigationResultScope>.(R) -> Unit, +): PropertyDelegateProvider>> { + return registerForNavigationResult(R::class, onClosed, onCompleted) +} + +public fun Fragment.registerForNavigationResult( + @Suppress("unused") + resultType: KClass, + onClosed: NavigationResultScope>.() -> Unit = {}, + onCompleted: NavigationResultScope>.(R) -> Unit, +): PropertyDelegateProvider>> { + return PropertyDelegateProvider>> { thisRef, property -> + val resultId = "${thisRef::class.java.name}.${property.name}" + val lazyResultChannel = lazy { + val id = arguments?.getNavigationKeyInstance()?.id + ?: error( + "registerForNavigationResult on Fragment ${thisRef::class.qualifiedName} " + + "requires the Fragment to be hosted via an Enro navigation flow — its " + + "arguments bundle is missing a NavigationKey.Instance. Open the Fragment " + + "through a NavigationContainer / NavigationHandle, not via FragmentManager " + + "directly." + ) + NavigationResultChannel( + id = NavigationResultChannel.Id( + ownerId = id, + resultId = resultId, + ), + onClosed = onClosed as NavigationResultScope.() -> Unit, + onCompleted = onCompleted as NavigationResultScope.(R) -> Unit, + navigationHandle = fragmentContextHolder.navigationHandle, + ) + } + var job: Job? = null + lifecycle.addObserver(LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + job = NavigationResultChannel.observe(resultType, lifecycleScope, lazyResultChannel.value) + } + if (event == Lifecycle.Event.ON_PAUSE) { + job?.cancel() + } + }) + + ReadOnlyProperty> { _, _ -> + lazyResultChannel.value + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/serialization/NavigationKeyParceler.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/serialization/NavigationKeyParceler.kt new file mode 100644 index 000000000..9585c84ba --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/serialization/NavigationKeyParceler.kt @@ -0,0 +1,50 @@ +package dev.enro.serialization + +import android.os.Parcel +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import dev.enro.EnroController +import dev.enro.NavigationKey +import kotlinx.android.parcel.Parceler +import kotlinx.serialization.PolymorphicSerializer + +public object NavigationKeyParceler : Parceler { + override fun NavigationKey.write(parcel: Parcel, flags: Int) { + parcel.writeBundle( + encodeToSavedState( + serializer = PolymorphicSerializer(NavigationKey::class), + value = this, + configuration = EnroController.savedStateConfiguration + ) + ) + } + + override fun create(parcel: Parcel): NavigationKey { + return decodeFromSavedState( + deserializer = PolymorphicSerializer(NavigationKey::class), + savedState = parcel.readBundle(this::class.java.classLoader)!!, + configuration = EnroController.savedStateConfiguration + ) + } + + public object Nullable : Parceler { + override fun NavigationKey?.write(parcel: Parcel, flags: Int) { + parcel.writeBundle(this?.let { + encodeToSavedState( + serializer = PolymorphicSerializer(NavigationKey::class), + value = it, + configuration = EnroController.savedStateConfiguration + ) + }) + } + + override fun create(parcel: Parcel): NavigationKey? { + val data = parcel.readBundle(this::class.java.classLoader) ?: return null + return decodeFromSavedState( + deserializer = PolymorphicSerializer(NavigationKey::class), + savedState = data, + configuration = EnroController.savedStateConfiguration + ) + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/LocalNavigationContext.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/LocalNavigationContext.android.kt new file mode 100644 index 000000000..287d7ca17 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/LocalNavigationContext.android.kt @@ -0,0 +1,16 @@ +package dev.enro.ui + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.enro.context.RootContext +import dev.enro.platform.navigationContext + +@Composable +internal actual fun findRootNavigationContext(): RootContext { + val activity = LocalActivity.current + return remember(activity) { + requireNotNull(activity) + activity.navigationContext + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.android.kt new file mode 100644 index 000000000..4514192c3 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.android.kt @@ -0,0 +1,10 @@ +package dev.enro.ui.decorators + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable + +@Composable +internal actual fun rememberShouldRemoveViewModelStoreCallback(): () -> Boolean { + val activity = LocalActivity.current + return { activity?.isChangingConfigurations != true } +} diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/ActivityDestination.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/ActivityDestination.kt new file mode 100644 index 000000000..0c14f908a --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/ActivityDestination.kt @@ -0,0 +1,46 @@ +package dev.enro.ui.destinations + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import dev.enro.NavigationKey +import dev.enro.platform.getNavigationKeyInstance +import dev.enro.platform.putNavigationKeyInstance +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.navigationDestination +import kotlin.reflect.KClass + +public inline fun activityDestination(): NavigationDestinationProvider { + return activityDestination(T::class, A::class) +} + +public fun activityDestination( + keyType: KClass, + activityType: KClass, +): NavigationDestinationProvider { + return navigationDestination( + metadata = { + add(ActivityTypeKey to activityType) + rootContextDestination() + } + ) { + error("activityDestination should not be rendered directly. If you are reaching this, please report this as a bug.") + } +} + +internal const val ActivityTypeKey = "dev.enro.ui.destinations.ActivityDestinationKey" +private const val IntentInstanceKey = "dev.enro.ui.destinations.ActivityDestination.IntentInstanceKey" + +public fun Intent.putNavigationKeyInstance(instance: NavigationKey.Instance<*>): Intent { + return putExtra( + IntentInstanceKey, Bundle().putNavigationKeyInstance( + instance.copy( + metadata = instance.metadata.copy() + ) + ) + ) +} + +public fun Intent.getNavigationKeyInstance(): NavigationKey.Instance? { + return getBundleExtra(IntentInstanceKey)?.getNavigationKeyInstance() +} diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/FragmentDestination.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/FragmentDestination.kt new file mode 100644 index 000000000..cba8546f9 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/FragmentDestination.kt @@ -0,0 +1,88 @@ +package dev.enro.ui.destinations + +import android.os.Bundle +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.rememberFragmentState +import dev.enro.NavigationKey +import dev.enro.platform.putNavigationKeyInstance +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.NavigationDestinationScope +import dev.enro.ui.destinations.fragment.AndroidDialogFragment +import dev.enro.ui.destinations.fragment.FragmentNavigationHandle +import dev.enro.ui.destinations.fragment.fragmentContextHolder +import dev.enro.ui.navigationDestination +import kotlin.reflect.KClass +import kotlin.reflect.full.isSubclassOf + +public inline fun fragmentDestination( + noinline metadata: NavigationDestination.MetadataBuilder.() -> Unit = {}, + noinline arguments: NavigationDestinationScope.() -> Bundle = { Bundle() }, +): NavigationDestinationProvider { + return fragmentDestination( + keyType = T::class, + fragmentType = F::class, + metadata = metadata, + arguments = arguments, + ) +} + +public fun fragmentDestination( + keyType: KClass, + fragmentType: KClass, + metadata: NavigationDestination.MetadataBuilder.() -> Unit = {}, + arguments: NavigationDestinationScope.() -> Bundle = { Bundle() }, +): NavigationDestinationProvider { + return navigationDestination(metadata) { + key(navigation.instance.id) { + var fragment: F? by remember { + mutableStateOf(null) + } + val fragmentState = rememberFragmentState() + if (fragmentType.isSubclassOf(DialogFragment::class)) { + AndroidDialogFragment( + clazz = fragmentType.java as Class, + tag = navigation.instance.id, + fragmentState = fragmentState, + arguments = arguments().apply { + putNavigationKeyInstance(navigation.instance) + }, + ) { f -> + fragment = f as F + } + } else { + AndroidFragment( + clazz = fragmentType.java, + modifier = Modifier.fillMaxSize(), + fragmentState = fragmentState, + arguments = arguments().apply { + putNavigationKeyInstance(navigation.instance) + }, + ) { f -> + fragment = f + } + } + DisposableEffect(fragment) { + val fragment = fragment + if (fragment == null) return@DisposableEffect onDispose { } + val navigation = fragment.fragmentContextHolder.navigationHandle + @Suppress("UNCHECKED_CAST") + navigation as FragmentNavigationHandle + navigation.bind(this@navigationDestination) + onDispose { + navigation.unbind() + } + } + } + } +} diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/AndroidDialogFragment.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/AndroidDialogFragment.kt new file mode 100644 index 000000000..9323e862d --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/AndroidDialogFragment.kt @@ -0,0 +1,91 @@ +package dev.enro.ui.destinations.fragment + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commitNow +import androidx.fragment.compose.FragmentState +import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import dev.enro.platform.EnroLog + +/** + * This is largely copied from the AndroidFragment implementation, but with some changes to support DialogFragments + */ +@Composable +internal fun AndroidDialogFragment( + clazz: Class, + tag: String, + fragmentState: FragmentState = rememberFragmentState(), + arguments: Bundle = Bundle.EMPTY, + onUpdate: (T) -> Unit = { }, +) { + val updateCallback = rememberUpdatedState(onUpdate) + val view = LocalView.current + val fragmentManager = remember(view) { + FragmentManager.findFragmentManager(view) + } + val tag = currentCompositeKeyHash.toString() + val context = LocalContext.current + DisposableEffect(fragmentManager, clazz, fragmentState) { + var removeEvenIfStateIsSaved = false + val fragment = fragmentManager.findFragmentByTag(tag) + ?: fragmentManager.fragmentFactory + .instantiate(context.classLoader, clazz.name) + .apply { + this as DialogFragment + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + setInitialSavedState(fragmentState.state.value) + setArguments(arguments) + val transaction = fragmentManager + .beginTransaction() + .add(this, tag) + + if (fragmentManager.isStateSaved) { + // If the state is saved when we add the fragment, + // we want to remove the Fragment in onDispose + // if isStateSaved never becomes true for the lifetime + // of this AndroidFragment - we use a LifecycleObserver + // on the Fragment as a proxy for that signal + removeEvenIfStateIsSaved = true + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + removeEvenIfStateIsSaved = false + lifecycle.removeObserver(this) + } + } + ) + transaction.commitNowAllowingStateLoss() + } else { + transaction.commitNow() + } + } + @Suppress("UNCHECKED_CAST") updateCallback.value(fragment as T) + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + onDispose { + val state = fragmentManager.saveFragmentInstanceState(fragment) + fragmentState.state.value = state + if (removeEvenIfStateIsSaved) { + // The Fragment was added when the state was saved and + // isStateSaved never became true for the lifetime of this + // AndroidFragment, so we unconditionally remove it here + fragmentManager.commitNow(allowStateLoss = true) { remove(fragment) } + } else if (!fragmentManager.isStateSaved) { + // If the state isn't saved, that means that some state change + // has removed this Composable from the hierarchy + fragmentManager.commitNow { + remove(fragment) + } + } + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/FragmentContextHolder.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/FragmentContextHolder.kt new file mode 100644 index 000000000..c6829a50e --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/FragmentContextHolder.kt @@ -0,0 +1,24 @@ +package dev.enro.ui.destinations.fragment + +import androidx.fragment.app.Fragment +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dev.enro.NavigationKey + +@PublishedApi +internal val Fragment.fragmentContextHolder: FragmentContextHolder + get() { + return ViewModelProvider + .create( + owner = this, + factory = (this as HasDefaultViewModelProviderFactory).defaultViewModelProviderFactory, + ) + .get(FragmentContextHolder::class) + } + +@PublishedApi +internal class FragmentContextHolder : ViewModel() { + @PublishedApi + internal val navigationHandle: FragmentNavigationHandle = FragmentNavigationHandle() +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/FragmentNavigationHandle.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/FragmentNavigationHandle.kt new file mode 100644 index 000000000..04c674944 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/ui/destinations/fragment/FragmentNavigationHandle.kt @@ -0,0 +1,52 @@ +package dev.enro.ui.destinations.fragment + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.SavedStateHandle +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.platform.EnroLog +import dev.enro.ui.NavigationDestinationScope + +@PublishedApi +internal class FragmentNavigationHandle() : NavigationHandle() { + internal var delegate: NavigationHandle = NotInitialized() + override lateinit var instance: NavigationKey.Instance + override val savedStateHandle: SavedStateHandle + get() = delegate.savedStateHandle + + @AdvancedEnroApi + override fun execute(operation: NavigationOperation) { + delegate.execute(operation) + } + + override val lifecycle: Lifecycle get() = delegate.lifecycle + + internal fun bind(scope: NavigationDestinationScope) { + instance = scope.navigation.instance + delegate = scope.navigation + } + + internal fun unbind() { + delegate = NotInitialized() + } + + internal class NotInitialized() : NavigationHandle() { + override val savedStateHandle: SavedStateHandle = SavedStateHandle() + override lateinit var instance: NavigationKey.Instance + + override val lifecycle: Lifecycle = object : Lifecycle() { + override val currentState: State = State.INITIALIZED + override fun addObserver(observer: LifecycleObserver) {} + override fun removeObserver(observer: LifecycleObserver) {} + } + + override fun execute( + operation: NavigationOperation, + ) { + EnroLog.warn("NavigationHandle with instance $instance has been not been initialised, but has received an operation which will be ignored") + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.android.kt b/enro-runtime/src/androidMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.android.kt new file mode 100644 index 000000000..7455b302e --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.android.kt @@ -0,0 +1,39 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import dev.enro.NavigationHandle +import kotlin.reflect.KClass + +public actual class EnroViewModelFactory actual constructor( + private val navigationHandle: NavigationHandle<*>, + private val delegate: ViewModelProvider.Factory +) : ViewModelProvider.Factory { + public override fun create(modelClass: Class): T { + NavigationHandleProvider.put(modelClass.kotlin, navigationHandle) + return delegate.create(modelClass).also { + NavigationHandleProvider.clear(modelClass.kotlin) + } + } + + public override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + NavigationHandleProvider.put(modelClass.kotlin, navigationHandle) + return delegate.create(modelClass, extras).also { + NavigationHandleProvider.clear(modelClass.kotlin) + } + } + + public override fun create( + modelClass: KClass, + extras: CreationExtras + ): T { + NavigationHandleProvider.put(modelClass, navigationHandle) + return delegate.create(modelClass, extras).also { + NavigationHandleProvider.clear(modelClass) + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/anim/enro_example_enter.xml b/enro-runtime/src/androidMain/res/anim/enro_example_enter.xml new file mode 100644 index 000000000..0169e1901 --- /dev/null +++ b/enro-runtime/src/androidMain/res/anim/enro_example_enter.xml @@ -0,0 +1,36 @@ + + + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/anim/enro_example_exit.xml b/enro-runtime/src/androidMain/res/anim/enro_example_exit.xml new file mode 100644 index 000000000..b0dba6eac --- /dev/null +++ b/enro-runtime/src/androidMain/res/anim/enro_example_exit.xml @@ -0,0 +1,33 @@ + + + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/anim/enro_fallback_exit.xml b/enro-runtime/src/androidMain/res/anim/enro_fallback_exit.xml new file mode 100644 index 000000000..d744ca20c --- /dev/null +++ b/enro-runtime/src/androidMain/res/anim/enro_fallback_exit.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/anim/enro_no_op_enter_animation.xml b/enro-runtime/src/androidMain/res/anim/enro_no_op_enter_animation.xml new file mode 100644 index 000000000..3470942fd --- /dev/null +++ b/enro-runtime/src/androidMain/res/anim/enro_no_op_enter_animation.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/anim/enro_no_op_exit_animation.xml b/enro-runtime/src/androidMain/res/anim/enro_no_op_exit_animation.xml new file mode 100644 index 000000000..d63a0620c --- /dev/null +++ b/enro-runtime/src/androidMain/res/anim/enro_no_op_exit_animation.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/animator/animator_enro_fallback_exit.xml b/enro-runtime/src/androidMain/res/animator/animator_enro_fallback_exit.xml new file mode 100644 index 000000000..4dc9a0a71 --- /dev/null +++ b/enro-runtime/src/androidMain/res/animator/animator_enro_fallback_exit.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/animator/animator_example_enter.xml b/enro-runtime/src/androidMain/res/animator/animator_example_enter.xml new file mode 100644 index 000000000..1822b747c --- /dev/null +++ b/enro-runtime/src/androidMain/res/animator/animator_example_enter.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/animator/animator_example_exit.xml b/enro-runtime/src/androidMain/res/animator/animator_example_exit.xml new file mode 100644 index 000000000..4a9830d18 --- /dev/null +++ b/enro-runtime/src/androidMain/res/animator/animator_example_exit.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/enro-core/src/main/res/animator/animator_example_no.xml b/enro-runtime/src/androidMain/res/animator/animator_example_no.xml similarity index 100% rename from enro-core/src/main/res/animator/animator_example_no.xml rename to enro-runtime/src/androidMain/res/animator/animator_example_no.xml diff --git a/enro-runtime/src/androidMain/res/animator/animator_no_op_exit.xml b/enro-runtime/src/androidMain/res/animator/animator_no_op_exit.xml new file mode 100644 index 000000000..b207a0ee2 --- /dev/null +++ b/enro-runtime/src/androidMain/res/animator/animator_no_op_exit.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/values/id.xml b/enro-runtime/src/androidMain/res/values/id.xml new file mode 100644 index 000000000..5ee6a69aa --- /dev/null +++ b/enro-runtime/src/androidMain/res/values/id.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/enro-runtime/src/androidMain/res/values/styles.xml b/enro-runtime/src/androidMain/res/values/styles.xml new file mode 100644 index 000000000..ee7010d34 --- /dev/null +++ b/enro-runtime/src/androidMain/res/values/styles.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/EnroController.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/EnroController.kt new file mode 100644 index 000000000..6eb738fa4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/EnroController.kt @@ -0,0 +1,120 @@ +package dev.enro + +import androidx.compose.runtime.Stable +import androidx.savedstate.serialization.SavedStateConfiguration +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.context.RootContextRegistry +import dev.enro.controller.NavigationModule +import dev.enro.controller.repository.BindingRepository +import dev.enro.controller.repository.DecoratorRepository +import dev.enro.controller.repository.InterceptorRepository +import dev.enro.controller.repository.PathRepository +import dev.enro.controller.repository.PluginRepository +import dev.enro.controller.repository.SerializerRepository +import dev.enro.controller.repository.ViewModelRepository +import dev.enro.serialization.wrapForSerialization +import kotlinx.serialization.json.Json + +@Stable +public class EnroController { + // Set during construction by internalCreateEnroController, plumbed through + // from the isDebug parameter on the generated installNavigationController. + internal var isDebug: Boolean = false + internal var platformReference: Any? = null + internal val plugins = PluginRepository() + internal val bindings = BindingRepository(plugins) + internal val serializers = SerializerRepository() + internal val interceptors = InterceptorRepository() + internal val decorators = DecoratorRepository() + internal val paths = PathRepository() + internal val viewModelRepository = ViewModelRepository() + + internal val rootContextRegistry: RootContextRegistry = RootContextRegistry() + + /** + * Attaches a module to the EnroController *after* the Controller has been attached; + * you can't uninstall a module once it has been added, use with caution. + */ + internal fun addModule(module: NavigationModule) { + plugins.addPlugins(module.plugins) + bindings.addNavigationBindings(module.bindings) + interceptors.addInterceptors(module.interceptors) + paths.addPaths(module.paths) + decorators.addDecorators(module.decorators) + serializers.registerSerializersModule(module.serializers) + serializers.registerSerializersModule(module.serializersForBindings) + } + + // The reference parameter is used to pass the platform-specific reference to the NavigationController, + // for example, the Application instance in Android, or the ApplicationScope instance on Desktop + public fun install(reference: Any?) { + if (instance == this) return + require (instance == null) { + "A NavigationController is already installed" + } + instance = this + platformReference = reference + plugins.onAttached(this) + } + + // This method is called by the test module to install/uninstall Enro from test applications + internal fun uninstall() { + plugins.onDetached(this) + if (instance == null) return + require(instance == this) { + "The currently installed NavigationController is not the same as the one being uninstalled" + } + instance = null + platformReference = null + } + + public companion object { + init { + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + NavigationKey.verifyMetadataSerialization = verifyMetadataSerialization@ { key, value -> + // The verification is a debug-mode safety check; if no + // controller is installed (e.g. teardown is racing + // composition disposal in a test), there's nothing to check + // against, so silently no-op rather than throw out of a + // DisposableEffect's onDispose path. + val controller = instance ?: return@verifyMetadataSerialization + if (!controller.isDebug) return@verifyMetadataSerialization + val isTransient = key is NavigationKey.TransientMetadataKey + if (isTransient) return@verifyMetadataSerialization + if (value != null) { + val wrapped = value.wrapForSerialization() + val hasSerializer = controller.serializers.serializersModule.getPolymorphic(Any::class, wrapped) != null + if (!hasSerializer) { + error("Object of type ${value::class} could not be added to NavigationKey.Metadata, make sure to register the serializer with the NavigationController.") + } + } + } + } + + internal var instance: EnroController? = null + private set + + internal fun requireInstance(): EnroController { + return instance ?: error("EnroController has not been installed") + } + + /** + * The controller's Json configuration, including all registered + * serializers. Uses the default (POLYMORPHIC) class-discriminator + * mode — `ALL_JSON_OBJECTS` is incompatible with kotlinx 1.11 for + * keys containing value-class or collection fields + * (https://github.com/Kotlin/kotlinx.serialization/issues/3022); see + * the note on SerializerRepository.jsonConfiguration and + * HistoryStateSerializationTests in enro-runtime. + */ + public val jsonConfiguration: Json get() { + val instance = requireInstance() + return instance.serializers.jsonConfiguration + } + + public val savedStateConfiguration: SavedStateConfiguration get() { + val instance = requireInstance() + return instance.serializers.savedStateConfiguration + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationBinding.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationBinding.kt new file mode 100644 index 000000000..aad71f1ca --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationBinding.kt @@ -0,0 +1,57 @@ +package dev.enro + +import dev.enro.serialization.serializerForNavigationKey +import dev.enro.ui.NavigationDestinationProvider +import kotlinx.serialization.modules.SerializersModuleBuilder +import kotlinx.serialization.modules.polymorphic +import kotlin.reflect.KClass + +public class NavigationBinding @PublishedApi internal constructor( + public val keyType: KClass, + public val serializerModule: SerializersModuleBuilder.() -> Unit, + public val provider: NavigationDestinationProvider, + public val isPlatformOverride: Boolean = false, +) { + public companion object { + internal object UseOriginalBindingKey : NavigationKey.MetadataKey(default = false) + + internal fun setUsesOriginalBinding(instance: NavigationKey.Instance<*>) { + instance.metadata.set(UseOriginalBindingKey, true) + } + + internal fun usesOriginalBinding(instance: NavigationKey.Instance<*>): Boolean { + return instance.metadata.get(UseOriginalBindingKey) + } + + public inline fun create( + provider: NavigationDestinationProvider, + isPlatformOverride: Boolean = false, + ): NavigationBinding { + val serializer = serializerForNavigationKey() + return NavigationBinding( + keyType = K::class, + serializerModule = { + contextual(K::class, serializer) + polymorphic(Any::class) { + subclass(K::class, serializer) + } + polymorphic(NavigationKey::class) { + subclass(K::class, serializer) + } + }, + provider = provider, + isPlatformOverride = isPlatformOverride, + ) + } + } +} + +public fun NavigationKey.Instance.asCommonDestination(): NavigationKey.Instance { + val commonInstance = NavigationKey.Instance( + key = this.key, + id = this.id, + metadata = this.metadata.copy(), + ) + NavigationBinding.setUsesOriginalBinding(commonInstance) + return commonInstance +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContainer.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContainer.kt new file mode 100644 index 000000000..afa241e06 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContainer.kt @@ -0,0 +1,264 @@ +package dev.enro + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.snapshotFlow +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.context.AnyNavigationContext +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext +import dev.enro.context.findContext +import dev.enro.context.root +import dev.enro.interceptor.AggregateNavigationInterceptor +import dev.enro.interceptor.NavigationInterceptor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.serialization.Serializable + + +/** + * A NavigationContainer is an identifiable backstack (using navigation container key), which + * provides the rendering context for a backstack. + * + * It's probably the NavigationContainer that needs to be able to host NavigationScenes/NavigationRenderers\ + * + * Instead of having a CloseParent/AllowEmpty, we should provide a special "Empty" instruction here (maybe even with a + * placeholder) so that the close behaviour is always consistent (easier for predictive back stuff). + */ +public class NavigationContainer( + public val key: Key, + public val controller: EnroController, + backstack: NavigationBackstack = emptyBackstack(), +) { + private val mutableBackstack: MutableState = mutableStateOf(backstack) + public val backstack: NavigationBackstack by mutableBackstack + + public val backstackFlow: Flow = + snapshotFlow { this.backstack } + + private val interceptors = mutableListOf() + private val emptyInterceptors = mutableListOf() + private var filter = acceptNone() + + @AdvancedEnroApi + public fun addInterceptor(interceptor: NavigationInterceptor) { + interceptors.add(interceptor) + } + + @AdvancedEnroApi + public fun removeInterceptor(interceptor: NavigationInterceptor) { + interceptors.remove(interceptor) + } + + @AdvancedEnroApi + public fun addEmptyInterceptor(interceptor: EmptyInterceptor) { + emptyInterceptors.add(interceptor) + } + + @AdvancedEnroApi + public fun removeEmptyInterceptor(interceptor: EmptyInterceptor) { + emptyInterceptors.remove(interceptor) + } + + @AdvancedEnroApi + public fun setFilter(filter: NavigationContainerFilter) { + this.filter = filter + } + + @AdvancedEnroApi + public fun clearFilter(filter: NavigationContainerFilter) { + if (this.filter == filter) { + this.filter = acceptNone() + } + } + + internal fun setBackstackDirect(backstack: NavigationBackstack) { + mutableBackstack.value = backstack + } + + private val executionMutex = Mutex(false) + + // TODO Need to add documentation to explain what is accepted -> close/completes for instances in the backstack, + // or opens which are accepted by the filter + public fun accepts(fromContext: AnyNavigationContext, operation: NavigationOperation): Boolean { + val operations = when(operation) { + is NavigationOperation.AggregateOperation -> operation.operations + is NavigationOperation.RootOperation -> listOf(operation) + } + + var isFromChild = false + var currentContext = fromContext as AnyNavigationContext + while (currentContext !is RootContext) { + isFromChild = currentContext is ContainerContext && currentContext.container.key == key + if (isFromChild) break + currentContext = currentContext.parent as AnyNavigationContext + } + + val ids = backstack.map { it.id }.toSet() + operations.forEach { + val isValid = when (it) { + is NavigationOperation.Close<*> -> ids.contains(it.instance.id) + is NavigationOperation.Complete<*> -> ids.contains(it.instance.id) + is NavigationOperation.Open<*> -> { + filter.accepts(it.instance) && (!filter.fromChildrenOnly || isFromChild) + } + is NavigationOperation.SideEffect -> true + } + if (!isValid) return false + } + return true + } + + // TODO This skips the accept checking, need to add documentation to explain that accept checking is + // performed by the navigation handle to find a container + @AdvancedEnroApi + public fun execute( + context: AnyNavigationContext, + operation: NavigationOperation, + ) { + if (executionMutex.isLocked) { + error( + "NavigationContainer is currently executing an operation. " + + "This is likely caused by a navigationInterceptor that is triggering another navigation operation " + + "inside of its [NavigationInterceptor.intercept] method." + ) + } + executionMutex.tryLock(this) + var afterExecution: () -> Unit = {} + try { + val containerContext = when { + context is ContainerContext && context.container == this -> context + context is DestinationContext<*> && context.parent.container == this -> context.parent + else -> findContextFrom(context) + } + requireNotNull(containerContext) { + "Could not find ContainerContext with id ${key.name} from context $context" + } + require(containerContext.container == this) { + "ContainerContext with id ${key.name} is not part of this NavigationContainer" + } + val operations = when (operation) { + is NavigationOperation.RootOperation -> listOf(operation) + is NavigationOperation.AggregateOperation -> operation.operations + } + + val interceptor = AggregateNavigationInterceptor( + interceptors = interceptors + controller.interceptors.aggregateInterceptor, + ) + + val interceptedOperations = NavigationInterceptor + .processOperations( + fromContext = context, + containerContext = containerContext, + operations = operations, + interceptor = interceptor, + ) + if (interceptedOperations.isEmpty()) return + val updatedBackstack = interceptedOperations + .fold(emptyList>()) { backstack, operation -> + when (operation) { + is NavigationOperation.Open<*> -> backstack + operation.instance + else -> backstack + } + } + .asBackstack() + + val isBecomingEmpty = backstack.isNotEmpty() && updatedBackstack.isEmpty() + val emptyInterceptorResults = when (isBecomingEmpty) { + true -> emptyInterceptors.map { emptyInterceptor -> + emptyInterceptor.onEmpty(NavigationTransition(backstack, updatedBackstack)) + } + else -> listOf(EmptyInterceptor.Result.AllowEmpty) + } + val isPreventEmpty = emptyInterceptorResults.any { it is EmptyInterceptor.Result.DenyEmpty } + + if (!isPreventEmpty) { + mutableBackstack.value = updatedBackstack + containerContext.requestActiveInRoot() + } + + afterExecution = { + interceptedOperations.filterIsInstance>() + .onEach { it.registerResult() } + + interceptedOperations.filterIsInstance>() + .onEach { it.registerResult() } + + interceptedOperations.filterIsInstance() + .onEach { it.performSideEffect() } + + emptyInterceptorResults.filterIsInstance() + .onEach { it.performSideEffect() } + } + + } finally { + executionMutex.unlock(this) + } + afterExecution() + } + + private fun findContextFrom( + context: AnyNavigationContext, + ): ContainerContext? { + when (context) { + is ContainerContext -> if (context.container == this) return context + is DestinationContext<*> -> if (context.parent.container == this) return context.parent + is RootContext -> {} + } + return context.root().findContext { + it is ContainerContext && it.container == this + } as? ContainerContext + } + + public fun updateBackstack(context: ContainerContext, block: (NavigationBackstack) -> NavigationBackstack) { + execute(context, NavigationOperation.SetBackstack(backstack, block(backstack))) + } + + @Stable + @Immutable + @Serializable + public data class Key(val name: String) { + public companion object { + @Deprecated("Use NavigationContainer.Key(name) instead") + public fun FromName(name: String): Key = Key(name) + } + + public object Saver : androidx.compose.runtime.saveable.Saver { + override fun restore(value: String): Key = Key(value) + override fun SaverScope.save(value: Key): String = value.name + } + } + + public abstract class EmptyInterceptor { + + public fun allowEmpty(): Result { + return Result.AllowEmpty + } + + public fun denyEmpty(): Result { + return Result.DenyEmpty {} + } + + public fun denyEmptyAnd(block: () -> Unit): Result { + return Result.DenyEmpty( + block = block + ) + } + + public abstract fun onEmpty(transition: NavigationTransition): Result + + public sealed interface Result { + public object AllowEmpty : Result + public class DenyEmpty(private val block: () -> Unit) : Result { + internal fun performSideEffect() { + block() + } + } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContainerFilter.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContainerFilter.kt new file mode 100644 index 000000000..a67fbb0d9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContainerFilter.kt @@ -0,0 +1,102 @@ +package dev.enro + +import kotlin.jvm.JvmName + +/** + * A NavigationContainerFilter is used to determine whether or not a given [NavigationKey.Instance] should be accepted by a [NavigationContainer] to be handled/displayed by that container. + */ +public class NavigationContainerFilter internal constructor( + internal val fromChildrenOnly: Boolean = false, + private val block: (NavigationKey.Instance) -> Boolean +) { + // validates + public fun accepts(instance: NavigationKey.Instance): Boolean { + return block(instance) + } +} + +/** + * A builder for creating a [NavigationContainerFilter] + */ +public class NavigationContainerFilterBuilder internal constructor() { + private val filters: MutableList = mutableListOf() + + /** + * Matches any instructions that have a NavigationKey that returns true for the provided predicate + */ + public fun key(predicate: (NavigationKey) -> Boolean) { + filters.add(NavigationContainerFilter { predicate(it.key) }) + } + + /** + * Matches any instructions that have a NavigationKey that is equal to the provided key + */ + public fun key(key: NavigationKey) { + key { it == key } + } + + /** + * Matches any instructions that match the provided predicate + */ + @JvmName("keyWithType") + public inline fun key( + crossinline predicate: (T) -> Boolean = { true } + ) { + key { it is T && predicate(it) } + } + + /** + * Matches any instructions that match the provided predicate + */ + public fun instance(predicate: (NavigationKey.Instance) -> Boolean) { + filters.add( + NavigationContainerFilter(fromChildrenOnly = false, block = predicate) + ) + } + + internal fun build(): NavigationContainerFilter { + return NavigationContainerFilter( + fromChildrenOnly = false, + ) { instruction -> + filters.any { it.accepts(instruction) } + } + } +} + +/** + * A [NavigationContainerFilter] that accepts all [NavigationKey.Instance]. + */ +public fun acceptAll(): NavigationContainerFilter = NavigationContainerFilter { true } + +/** + * A [NavigationContainerFilter] that accepts no [NavigationKey.Instance]. + * + * This is useful in cases where a Navigation Container should only contain the initial destination, + * or where the Navigation Container only has it's backstack updated manually through the + * [NavigationContainer.setBackstack] method + */ +public fun acceptNone(): NavigationContainerFilter = NavigationContainerFilter { false } + +/** + * A [NavigationContainerFilter] that accepts [NavigationKey.Instance] + * that match configuration provided a NavigationContainerFilterBuilder created using the [block]. + */ +public fun accept(block: NavigationContainerFilterBuilder.() -> Unit): NavigationContainerFilter { + return NavigationContainerFilterBuilder() + .apply(block) + .build() +} + + +/** + * A [NavigationContainerFilter] that accepts [NavigationKey.Instance] + * that do not match configuration provided a NavigationContainerFilterBuilder created using the [block]. + */ +public fun doNotAccept(block: NavigationContainerFilterBuilder.() -> Unit): NavigationContainerFilter { + return NavigationContainerFilterBuilder() + .apply(block) + .build() + .let { filter -> + NavigationContainerFilter { !filter.accepts(it) } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContext.kt new file mode 100644 index 000000000..17c7b6608 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationContext.kt @@ -0,0 +1,5 @@ +package dev.enro + +import dev.enro.context.AnyNavigationContext + +public typealias NavigationContext = AnyNavigationContext \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.kt new file mode 100644 index 000000000..92ff523fa --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.kt @@ -0,0 +1,287 @@ +package dev.enro + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import kotlin.jvm.JvmName + +/** + * The active destination's view of the navigation framework. + * + * A [NavigationHandle] represents a single live entry on the backstack — + * one instance, one lifecycle, one [SavedStateHandle]. From inside a + * destination you use it to talk back to the navigation system: open + * another destination, close yourself, complete with a result, etc. + * + * Get one via: + * - `navigationHandle()` inside a `@Composable` destination + * - `by navigationHandle()` as a delegated property in a `ViewModel` + * - `viewModelStoreOwner.getNavigationHandle()` from a Fragment / Activity + * + * The handle is always typed to the [NavigationKey] subtype that started + * the destination, so completion APIs that depend on the key's result type + * (see [NavigationKey.WithResult]) stay statically checked. + * + * Implements [LifecycleOwner]: the handle's lifecycle tracks the + * destination's lifecycle (created → started → resumed → … → destroyed), + * so anything scoped to it (coroutines, observers) cleans up automatically + * when the destination leaves the backstack. + * + * @param T the [NavigationKey] subtype this handle was created for. + */ +public abstract class NavigationHandle internal constructor() : LifecycleOwner { + /** + * State that survives configuration changes and process death for this + * destination. Wraps the platform's `SavedStateHandle` and is scoped to + * this entry — different backstack entries for the same key each get + * their own. + */ + public abstract val savedStateHandle: SavedStateHandle + + /** + * The backstack entry this handle was attached to: the [NavigationKey] + * plus a stable id and any metadata layered onto it. Prefer [instance] + * over [key] when you need the id (e.g. to compare against other + * backstack entries) or metadata; use [key] when you only need the + * key's data. + */ + public abstract val instance: NavigationKey.Instance + + /** + * Shorthand for `instance.key`. Same value object that the caller + * passed to `open(…)` / put in the initial backstack — its properties + * are the key's serialised fields. + */ + public val key: T get() = instance.key + + @Deprecated("Use instance") + public val instruction: NavigationKey.Instance get() = instance + + /** + * Low-level entry point that dispatches a [NavigationOperation] through + * the controller. The strongly-typed helpers ([open], [close], + * [complete], [completeFrom], [closeAndReplaceWith], + * [closeAndCompleteFrom]) cover every common case; call [execute] + * directly only when you need to compose an operation those helpers + * don't already build — e.g. a custom [NavigationOperation.SetBackstack] + * or a hand-rolled [NavigationOperation.AggregateOperation]. + */ + public abstract fun execute( + operation: NavigationOperation, + ) +} + +/** + * Asks the destination to close itself, routing through any + * `onCloseRequested` callback registered via [NavigationHandleConfiguration]. + * + * If a callback is registered it runs in place of the default close — that's + * how a destination opts in to "prompt before dismissing" behaviour by + * showing a confirmation dialog and only invoking [close] when the user + * confirms. With no callback registered this is equivalent to [close]. + * + * This is the function predictive-back and system-back gestures invoke, so + * if you want gesture-triggered close to route through your confirmation + * UI, register the callback (and DO NOT call [close] directly elsewhere). + */ +public fun NavigationHandle<*>.requestClose() { + NavigationHandleConfiguration.onCloseRequested(this) +} + +/** + * Closes this destination unconditionally — removes it from the backstack + * and tears down its lifecycle. + * + * Bypasses any registered `onCloseRequested` callback; use [requestClose] + * if you want the registered callback to have a chance to intervene + * (typical for system-back / "X" button affordances). + */ +public fun NavigationHandle<*>.close() { + execute(NavigationOperation.Close(instance)) +} + +/** + * Completes a non-result destination — closes this entry and notifies + * the opener that the work finished successfully. + * + * Use this for navigation keys with no result payload. For + * [NavigationKey.WithResult] keys, call the result-carrying overload + * `complete(result)` instead — completing a result key without a result + * is a programming error and produces a deprecation-level error. + */ +public fun NavigationHandle<*>.complete() { + execute(NavigationOperation.Complete(instance)) +} + +@JvmName("completeWithoutResult") +@Deprecated( + message = "A NavigationKey.WithResult should not be completed without a result, doing so will result in an error", + level = DeprecationLevel.ERROR, +) +public fun NavigationHandle>.complete() { + error("${instance.key} is a NavigationKey.WithResult and cannot be completed without a result") +} + +/** + * Completes this result-bearing destination with [result], closes it, and + * delivers the value back to whoever opened it. + * + * The opener receives the result through the `registerForNavigationResult` + * channel they set up before opening this key. + */ +public fun NavigationHandle>.complete(result: R) { + execute(NavigationOperation.Complete(instance, result)) +} + +/** + * Forwards completion of this destination to another navigation [key]. + * + * Used by *intermediary* destinations that delegate to a downstream + * destination: this entry closes, [key] is opened in its place, and when + * [key] eventually completes its result is delivered to whoever originally + * opened *this* entry. Lets you wedge a chooser / disambiguator in front + * of a result-producing destination without the opener knowing. + * + * For result-typed destinations, prefer the [NavigationKey.WithResult] + * overload, which enforces matching result types at compile time. + */ +public fun NavigationHandle.completeFrom(key: NavigationKey) { + execute(NavigationOperation.CompleteFrom(instance, key.asInstance())) +} + +@JvmName("completeFromGeneric") +@Deprecated( + message = "A NavigationKey.WithResult cannot complete from a NavigationKey that does not have a result", + level = DeprecationLevel.ERROR, +) +public fun NavigationHandle>.completeFrom(key: NavigationKey) { + error("${instance.key} is a NavigationKey.WithResult and cannot complete from a NavigationKey that does not have a result") +} + +/** + * Type-safe [completeFrom] for result destinations: forwards completion + * to [key], which must produce a result of the same type [R] this handle + * is expecting. The compiler rejects mismatched result types. + */ +public fun NavigationHandle>.completeFrom(key: NavigationKey.WithResult) { + execute(NavigationOperation.CompleteFrom(instance, key.asInstance())) +} + +/** + * [completeFrom] variant that accepts a [NavigationKey.WithMetadata] + * wrapper around the forwarded key — use this when the destination you + * want to forward to was built with extra metadata (e.g. result-channel + * routing data attached via `withMetadata { … }`). + */ +public fun NavigationHandle>.completeFrom(key: NavigationKey.WithMetadata>) { + execute(NavigationOperation.CompleteFrom(instance, key.asInstance())) +} + +/** + * Pushes a new destination for [key] on top of the current backstack. + * + * Doesn't close the calling destination — both stay on the stack. Use + * [closeAndReplaceWith] when you want the new destination to take this + * one's place. + */ +public fun NavigationHandle<*>.open(key: NavigationKey) { + execute(NavigationOperation.Open(key.asInstance())) +} + +/** + * [open] variant that accepts a [NavigationKey.WithMetadata] wrapper — + * use this when the destination needs metadata attached (e.g. a result + * channel id from `registerForNavigationResult`, or transition overrides). + */ +public fun NavigationHandle<*>.open(key: NavigationKey.WithMetadata<*>) { + execute(NavigationOperation.Open(key.asInstance())) +} + +/** + * Closes this destination and opens [key] in one atomic operation — the + * backstack transitions directly from `[…, this]` to `[…, key]` with no + * intermediate state. + * + * Equivalent to a `close()` followed by an `open(key)` from the previous + * destination's perspective, but bundled so that interceptors, + * animations, and saved-state cleanup all see it as a single operation. + * Prefer this over manual `close(); open(key)` when you want a clean + * "replace this screen with that one" effect. + */ +public fun NavigationHandle<*>.closeAndReplaceWith(key: NavigationKey) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.Open(key.asInstance()), + ) + ) +} + +/** + * [closeAndReplaceWith] variant accepting a [NavigationKey.WithMetadata] + * wrapper for the replacement key. + */ +public fun NavigationHandle<*>.closeAndReplaceWith(key: NavigationKey.WithMetadata<*>) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.Open(key.asInstance()), + ) + ) +} + +/** + * Closes this destination AND forwards completion to [key] atomically. + * + * Compared to [completeFrom], this one also removes the current + * destination from the backstack immediately — useful when you don't want + * the intermediary to stay visible underneath while the forwarded + * destination is open. Common shape: a "choose flavour" screen + * closes-and-completes-from the variant-specific result destination. + */ +public fun NavigationHandle.closeAndCompleteFrom(key: NavigationKey) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.CompleteFrom(instance, key.asInstance()) + ) + ) +} + +@JvmName("closeAndCompleteFromGeneric") +@Deprecated( + message = "A NavigationKey.WithResult cannot complete from a NavigationKey that does not have a result", + level = DeprecationLevel.ERROR, +) +public fun NavigationHandle>.closeAndCompleteFrom(key: NavigationKey) { + error("${instance.key} is a NavigationKey.WithResult and cannot complete from a NavigationKey that does not have a result") +} + +/** + * Type-safe [closeAndCompleteFrom] for result destinations: [key] must + * produce a result of the same type [R] this handle is expecting. + */ +public fun NavigationHandle>.closeAndCompleteFrom( + key: NavigationKey.WithResult, +) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.CompleteFrom(instance, key.asInstance()) + ) + ) +} + +/** + * [closeAndCompleteFrom] variant that accepts a [NavigationKey.WithMetadata] + * wrapper around the forwarded key. + */ +public fun NavigationHandle>.closeAndCompleteFrom( + key: NavigationKey.WithMetadata>, +) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.CompleteFrom(instance, key.asInstance()) + ) + ) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.ui.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.ui.kt new file mode 100644 index 000000000..a01d0e9ac --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.ui.kt @@ -0,0 +1,85 @@ +package dev.enro + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.enro.handle.NavigationHandleHolder +import dev.enro.ui.LocalNavigationContext +import kotlin.jvm.JvmName +import kotlin.reflect.KClass + +/** + * Returns the [NavigationHandle] for the destination this Composable is + * rendering in. Untyped — the handle is `NavigationHandle`, + * so the key type is whatever the destination was opened with. + * + * Prefer the reified `navigationHandle()` overload when you know + * the key type, to avoid casting and to surface mismatches at compile + * time. + * + * Throws if called outside a destination composition (i.e. outside the + * Composable lambda passed to `navigationDestination<…>`). + */ +@JvmName("untypedNavigationHandle") +@Composable +public fun navigationHandle(): NavigationHandle { + return navigationHandle() +} + +/** + * Returns the [NavigationHandle] typed to [T] for the destination this + * Composable is rendering in. Verifies at runtime that the destination's + * key really is a [T] and throws a descriptive error otherwise — usually + * a sign the function was called from the wrong destination. + * + * The typed handle gives you access to the result-aware overloads of + * [complete] / [completeFrom] / [closeAndCompleteFrom] when [T] extends + * [NavigationKey.WithResult]. + */ +@Composable +public inline fun navigationHandle(): NavigationHandle { + return navigationHandle(T::class) +} + +/** + * Explicit-[KClass] variant of `navigationHandle()` for the rare case + * you can't use a reified type parameter (e.g. dynamically chosen key + * type, Java interop). + */ +@Composable +public fun navigationHandle( + keyType: KClass, +): NavigationHandle { + val holder = viewModel>( + viewModelStoreOwner = LocalNavigationContext.current, + ) { + error("No NavigationHandle found for ${keyType.qualifiedName}") + } + val navigationHandle = holder.navigationHandle + @Suppress("USELESS_IS_CHECK") + require(navigationHandle.instance.key is T) { + "Expected key of type ${keyType.qualifiedName}, but found ${navigationHandle.instance.key::class}" + } + return navigationHandle +} + +/** + * Applies a [NavigationHandleConfiguration] block to this handle for the + * lifetime of the surrounding composition. Registrations made inside + * [block] (e.g. `onCloseRequested { … }`) are torn down automatically + * when the Composable leaves composition. + * + * Backed by a [DisposableEffect] keyed on [block], so changing the block + * identity tears down the previous configuration and re-runs the new one. + * For a `ViewModel`-scoped lifetime, use the `config` parameter on the + * `ViewModel.navigationHandle` delegated property instead. + */ +@Composable +public inline fun NavigationHandle.configure( + noinline block: NavigationHandleConfiguration.() -> Unit +) { + DisposableEffect(block) { + val configuration = NavigationHandleConfiguration(this@configure).apply(block) + onDispose { configuration.close() } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.viewmodel.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.viewmodel.kt new file mode 100644 index 000000000..454e26da8 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.viewmodel.kt @@ -0,0 +1,107 @@ +package dev.enro + +import androidx.lifecycle.ViewModel +import dev.enro.viewmodel.NavigationHandleProvider +import dev.enro.viewmodel.navigationHandleReference +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass + +/** + * Delegated property that exposes this ViewModel's [NavigationHandle] + * typed to [K], with an optional [config] block for lifetime-scoped + * configuration (e.g. registering an `onCloseRequested` callback). + * + * Standard usage: + * ``` + * class MyViewModel : ViewModel() { + * val navigation by navigationHandle { + * onCloseRequested { + * // ask the user before closing + * } + * } + * } + * ``` + * + * If [K] doesn't match the destination's actual key type, the property + * throws on first access with a clear message — usually a sign the wrong + * ViewModel was wired to the destination. Configuration registered via + * [config] is torn down when the ViewModel itself is cleared. + */ +public inline fun ViewModel.navigationHandle( + noinline config: (NavigationHandleConfiguration.() -> Unit)? = null, +): ReadOnlyProperty> { + return navigationHandle( + K::class, + config, + ) +} + +/** + * Explicit-[KClass] form of `ViewModel.navigationHandle(config)` for + * cases where you can't use a reified type parameter. + */ +public fun ViewModel.navigationHandle( + keyType: KClass, + config: (NavigationHandleConfiguration.() -> Unit)? = null, +): ReadOnlyProperty> { + val navigationHandle = getNavigationHandle() + require(keyType.isInstance(navigationHandle.key)) { + "The navigation handle key does not match the expected type. Expected ${keyType.simpleName}, but got ${navigationHandle.key::class.simpleName}" + } + + if (config != null) { + @Suppress("UNCHECKED_CAST") + val configuration = NavigationHandleConfiguration(navigationHandle) + .apply(config as NavigationHandleConfiguration.() -> Unit) + addCloseable(AutoCloseable { configuration.close() }) + } + + @Suppress("UNCHECKED_CAST") + return ReadOnlyProperty { _, _ -> navigationHandle as NavigationHandle } +} + +/** + * Delegated property that exposes this ViewModel's [NavigationHandle] + * typed to [K], with no extra configuration. Use the `(config)` overload + * when you need to register lifetime-scoped behaviour. + */ +public inline fun ViewModel.navigationHandle(): ReadOnlyProperty> { + return navigationHandle( + config = null, + ) +} + +/** + * Explicit-[KClass] form of `ViewModel.navigationHandle()`. + */ +public fun ViewModel.navigationHandle( + keyType: KClass, +): ReadOnlyProperty> { + return navigationHandle( + keyType = keyType, + config = null, + ) +} + +/** + * Returns the untyped [NavigationHandle] attached to this ViewModel. + * + * Lower-level than the `navigationHandle()` delegated property — use this + * when you need the handle outside a property declaration (e.g. inside a + * helper that takes the ViewModel as a parameter). Most destination code + * should use `by navigationHandle()` instead. + * + * Throws if the ViewModel wasn't created with a navigation handle bound + * to it (i.e. wasn't constructed via the destination's ViewModel factory). + */ +public fun ViewModel.getNavigationHandle(): NavigationHandle { + val reference = navigationHandleReference + if (reference.navigationHandle == null) { + reference.navigationHandle = NavigationHandleProvider.get(this::class) + } + val navigationHandle = reference.navigationHandle + requireNotNull(navigationHandle) { + "Unable to retrieve navigation handle for ViewModel ${this::class.simpleName}" + } + return navigationHandle +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandleConfiguration.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandleConfiguration.kt new file mode 100644 index 000000000..0c6a6bbc0 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandleConfiguration.kt @@ -0,0 +1,108 @@ +package dev.enro + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SavedStateHandle +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.interceptor.builder.OnNavigationKeyClosedScope +import dev.enro.interceptor.builder.navigationInterceptor + +private typealias OnCloseCallback = NavigationHandle.() -> Unit + +/** + * Scope for configuring lifecycle-aware behaviour on a [NavigationHandle]. + * + * Obtain one of these via `NavigationHandle.configure { … }` inside a + * Composable or via the `config` lambda on the `ViewModel.navigationHandle` + * delegated property. The configuration's lifetime is tied to the call + * site: + * + * - In a Composable, `configure` wraps a [androidx.compose.runtime.DisposableEffect], + * so the registrations are torn down when the composable leaves + * composition. + * - In a `ViewModel`, the configuration is attached to the ViewModel's + * `addCloseable`, so it lives as long as the ViewModel does. + * + * Registrations made in this scope (currently only [onCloseRequested]) are + * automatically removed when the configuration is closed — you can + * register without worrying about manual cleanup. + */ +public class NavigationHandleConfiguration( + private val navigation: NavigationHandle, +) { + private val closeables: MutableList = mutableListOf() + + /** + * Registers [callback] to run when something asks the destination to + * close via [requestClose] (system back gesture, predictive back, + * "X" affordances calling `requestClose`). + * + * Use this to insert pre-close work — a confirmation dialog, a "save + * draft?" prompt, an unsaved-changes guard. Inside the callback, call + * [close] yourself when you actually want the destination to close; + * if you don't, the destination stays. + * + * Only one callback may be active per handle at a time — registering a + * second while the first is still in scope throws at the moment the + * close is requested. Register in exactly one place per destination + * (typically the ViewModel; otherwise the top-level Composable). + * + * The registration is removed automatically when the surrounding + * configuration is disposed, so you don't need to unregister manually. + */ + public fun onCloseRequested( + callback: OnCloseCallback, + ) { + @Suppress("USELESS_CAST") + val callbacks = navigation.instance.metadata + .get(OnCloseCallbacks) + .plus(callback) + + @Suppress("UNCHECKED_CAST") + navigation.instance.metadata.set(OnCloseCallbacks, callbacks as List>) + closeables.add(AutoCloseable { + val callbacks = navigation.instance.metadata + .get(OnCloseCallbacks) + .minus(callback) + + @Suppress("UNCHECKED_CAST") + navigation.instance.metadata.set(OnCloseCallbacks, callbacks as List>) + }) + } + + /** + * A NavigationHandleConfiguration can be applied inside ViewModels or Composables, where + * the configuration block may need to be removed/disposed before the NavigationHandle is closed, + * so we need a way to close/undo the configuration and remove anything that might cause memory leaks + */ + @PublishedApi + internal fun close() { + closeables.forEach { it.close() } + } + + internal object OnCloseCallbacks : + NavigationKey.TransientMetadataKey>>(emptyList()) + + internal companion object { + internal fun onCloseRequested( + navigation: NavigationHandle + ) { + val callbacks = navigation.instance.metadata.get(OnCloseCallbacks) as List> + when { + callbacks.isEmpty() -> { + navigation.execute(NavigationOperation.Close(navigation.instance)) + } + callbacks.size > 1 -> { + error( + "Multiple onCloseRequested callbacks have been registered for NavigationHandle " + + "with key ${navigation.key}. Only one onCloseRequested callback may be active " + + "for a given NavigationHandle at a time — register it in exactly one place " + + "(typically the destination's ViewModel, otherwise the top-level Composable)." + ) + } + else -> { + callbacks.single().invoke(navigation) + } + } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationKey.Instance.asOperation.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationKey.Instance.asOperation.kt new file mode 100644 index 000000000..f19bc1932 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationKey.Instance.asOperation.kt @@ -0,0 +1,70 @@ +package dev.enro + +import dev.enro.result.NavigationResultChannel +import kotlin.jvm.JvmName +import kotlin.jvm.JvmStatic + +public fun NavigationKey.Instance.asOpenOperation(): NavigationOperation.Open { + return NavigationOperation.Open(this) +} + +public fun NavigationKey.Instance.asCloseOperation(): NavigationOperation.Close { + return NavigationOperation.Close(this) +} + +@JvmName("complete") +public fun NavigationKey.Instance.asCompleteOperation(): NavigationOperation.Complete { + return NavigationOperation.Complete(this) +} + +@JvmName("completeWithoutResult") +@Deprecated( + message = "A NavigationKey.WithResult should not be completed without a result, doing so will result in an error", + level = DeprecationLevel.ERROR, +) +public fun NavigationKey.Instance>.asCompleteOperation( +): NavigationOperation.Complete { + error("${this.key} is a NavigationKey.WithResult and cannot be completed without a result") +} + +@JvmName("complete") +public fun NavigationKey.Instance>.asCompleteOperation( + result: R, +): NavigationOperation.Complete> { + return NavigationOperation.Complete(this, result) +} + +@JvmName("completeFromWithoutResult") +public fun NavigationKey.Instance.asCompleteFromOperation( + completeFrom: NavigationKey.Instance, +): NavigationOperation.Open { + completeFrom.metadata.set( + NavigationResultChannel.ResultIdKey, + this.metadata.get(NavigationResultChannel.ResultIdKey) + ) + return NavigationOperation.Open(completeFrom) +} + +@Deprecated( + message = "A NavigationKey.WithResult cannot completeFrom a NavigationKey that does not also implement NavigationKey.WithResult", + level = DeprecationLevel.ERROR, +) +@JvmName("completeFromWithoutResultDeprecated") +public fun NavigationKey.Instance.asCompleteFromOperation( + completeFrom: NavigationKey.Instance>, +): NavigationOperation { + error("Cannot completeFrom a NavigationKey.WithResult from a NavigationKey that does not also implement NavigationKey.WithResult") +} + +@JvmName("completeFrom") +public fun NavigationKey.Instance>.asCompleteFromOperation( + completeFrom: NavigationKey.Instance>, +): NavigationOperation.Open> { + completeFrom.metadata.set( + NavigationResultChannel.ResultIdKey, + this.metadata.get(NavigationResultChannel.ResultIdKey) + ) + return NavigationOperation.Open>( + instance = completeFrom, + ) +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationOperation.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationOperation.kt new file mode 100644 index 000000000..2e06b2c11 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationOperation.kt @@ -0,0 +1,163 @@ +package dev.enro + +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import kotlin.jvm.JvmName +import kotlin.jvm.JvmStatic + +public sealed class NavigationOperation { + public sealed class RootOperation : NavigationOperation() + + public class AggregateOperation( + internal val operations: List, + ) : NavigationOperation() { + public constructor( + vararg operations: RootOperation, + ) : this(operations.toList()) + } + + public data class Open( + public val instance: NavigationKey.Instance, + ) : RootOperation() + + public data class Close( + public val instance: NavigationKey.Instance, + // A silent close indicates that after this operation is completed, + // any NavigationResult channels should not be notified of the close operation, + public val silent: Boolean = false, + ) : RootOperation() { + + // Registers the close operation with the NavigationResultChannel associated with this instance, + // which will allow any registerForNavigationResult callbacks to be executed + // Note, if "silent" is true, no result will be delivered + @AdvancedEnroApi + public fun registerResult() { + if (silent) return + NavigationResultChannel.registerResult( + NavigationResult.Closed( + instance = instance, + ) + ) + } + } + + @ConsistentCopyVisibility + public data class Complete private constructor( + public val instance: NavigationKey.Instance, + @PublishedApi + internal val result: Any?, + ) : RootOperation() { + // Registers the complete operation with the NavigationResultChannel associated with this instance, + // which will allow any registerForNavigationResult callbacks to be executed + @AdvancedEnroApi + public fun registerResult() { + NavigationResultChannel.registerResult( + NavigationResult.Completed( + instance = instance, + data = result, + ) + ) + } + + public companion object Companion { + @JvmStatic + @JvmName("complete") + public operator fun invoke( + instance: NavigationKey.Instance, + ): Complete { + return Complete(instance, null) + } + + @JvmStatic + @JvmName("completeWithoutResult") + @Deprecated( + message = "A NavigationKey.WithResult should not be completed without a result, doing so will result in an error", + level = DeprecationLevel.ERROR, + ) + public operator fun invoke( + instance: NavigationKey.Instance>, + ): Complete { + error("${instance.key} is a NavigationKey.WithResult and cannot be completed without a result") + } + + @JvmStatic + @JvmName("complete") + public operator fun invoke( + instance: NavigationKey.Instance>, + result: R, + ): Complete> { + return Complete(instance, result) + } + } + } + + public class SideEffect( + private val block: () -> Unit, + ) : RootOperation() { + public fun performSideEffect() { + block() + } + } + + public companion object CompleteFrom { + @JvmName("completeFromWithoutResult") + @JvmStatic + public operator fun invoke( + instance: NavigationKey.Instance, + completeFrom: NavigationKey.Instance, + ): Open { + completeFrom.metadata.set( + NavigationResultChannel.ResultIdKey, + instance.metadata.get(NavigationResultChannel.ResultIdKey) + ) + return Open(completeFrom) + } + + @Deprecated( + message = "A NavigationKey.WithResult cannot completeFrom a NavigationKey that does not also implement NavigationKey.WithResult", + level = DeprecationLevel.ERROR, + ) + @JvmName("completeFromWithoutResultDeprecated") + @JvmStatic + public operator fun invoke( + instance: NavigationKey.Instance, + completeFrom: NavigationKey.Instance>, + ): NavigationOperation { + error("Cannot completeFrom a NavigationKey.WithResult from a NavigationKey that does not also implement NavigationKey.WithResult") + } + + @JvmStatic + @JvmName("completeFrom") + public operator fun invoke( + instance: NavigationKey.Instance>, + completeFrom: NavigationKey.Instance>, + ): Open> { + completeFrom.metadata.set( + NavigationResultChannel.ResultIdKey, + instance.metadata.get(NavigationResultChannel.ResultIdKey) + ) + return Open>( + instance = completeFrom, + ) + } + } + + public object SetBackstack { + public operator fun invoke( + currentBackstack: NavigationBackstack, + targetBackstack: NavigationBackstack, + ): AggregateOperation { + val transition = NavigationTransition(currentBackstack, targetBackstack) + // Explicit RootOperation typing on the intermediate lists keeps + // K/Native's runtime element type as RootOperation rather than + // widening to NavigationOperation via inference -- the latter + // surfaces as a downstream cast failure in + // NavigationInterceptor.processOperations when the resulting + // AggregateOperation is rewritten through an interceptor. + val opens: List = transition.targetBackstack.map { Open(it) } + val closes: List = transition.closed.map { Close(it) } + return AggregateOperation(opens + closes) + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationTransition.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationTransition.kt new file mode 100644 index 000000000..e8a9bcdb7 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationTransition.kt @@ -0,0 +1,18 @@ +package dev.enro + +public data class NavigationTransition( + public val currentBackstack: NavigationBackstack, + public val targetBackstack: NavigationBackstack, +) { + public val closed: List> by lazy { + currentBackstack - targetBackstack + } + + public val opened: List> by lazy { + targetBackstack - currentBackstack + } + + public val retained: Set> by lazy { + currentBackstack intersect targetBackstack + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/ContainerContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/ContainerContext.kt new file mode 100644 index 000000000..e137e5019 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/ContainerContext.kt @@ -0,0 +1,59 @@ +package dev.enro.context + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.EnroController +import dev.enro.NavigationContainer +import dev.enro.NavigationKey + +public class ContainerContext( + override val parent: NavigationContext<*, ContainerContext>, + public val container: NavigationContainer, +) : NavigationContext, DestinationContext>(), + LifecycleOwner by parent, + ViewModelStoreOwner by parent, + HasDefaultViewModelProviderFactory by parent { + + override val id: String = container.key.name + override val controller: EnroController = parent.controller + + override val activeChild: DestinationContext? by derivedStateOf { + val backstack = container.backstack + for (index in container.backstack.indices.reversed()) { + val instance = backstack[index] + mutableChildren[instance.id] + ?.takeIf { it.isVisible } + ?.let { return@derivedStateOf it.child } + } + return@derivedStateOf null + } + + override fun registerChild(child: DestinationContext) { + mutableChildren[child.id] = ChildState( + child = child, + isVisible = false, + registrationOrder = 0, // registration order doesn't matter for Destinations + ) + } + + override fun unregisterChild(child: DestinationContext) { + mutableChildren.remove(child.id) + } + + override fun registerVisibility( + child: DestinationContext, + isVisible: Boolean, + ) { + val current = mutableChildren[child.id] + if (current == null) return + if (current.isVisible == isVisible) return + mutableChildren[child.id] = ChildState( + child = child, + isVisible = isVisible, + registrationOrder = 0, // registration order doesn't matter for Destinations + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/DestinationContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/DestinationContext.kt new file mode 100644 index 000000000..0cdb5cd84 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/DestinationContext.kt @@ -0,0 +1,28 @@ +package dev.enro.context + +import androidx.compose.runtime.MutableState +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination + +public class DestinationContext( + lifecycleOwner: LifecycleOwner, + viewModelStoreOwner: ViewModelStoreOwner, + defaultViewModelProviderFactory: HasDefaultViewModelProviderFactory, + public override val parent: ContainerContext, + public val destination: NavigationDestination, + activeChildId: MutableState, +) : NavigationContext.WithContainerChildren(activeChildId), + LifecycleOwner by lifecycleOwner, + ViewModelStoreOwner by viewModelStoreOwner, + HasDefaultViewModelProviderFactory by defaultViewModelProviderFactory { + + override val id: String get() = destination.id + override val controller: EnroController = parent.controller + + public val key: T get() = destination.key + public val instance: NavigationKey.Instance get() = destination.instance +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.activeLeaf.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.activeLeaf.kt new file mode 100644 index 000000000..51ac9740d --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.activeLeaf.kt @@ -0,0 +1,17 @@ +package dev.enro.context + +public fun AnyNavigationContext.activeLeaf(): NavigationContext<*, *> { + return when(this) { + is RootContext -> activeChild?.activeLeaf() ?: this + is ContainerContext -> activeChild?.activeLeaf() ?: this + is DestinationContext<*> -> activeChild?.activeLeaf() ?: this + } +} + +public fun AnyNavigationContext.activeLeafDestination(): DestinationContext<*>? { + return when(this) { + is RootContext -> activeChild?.activeLeafDestination() + is ContainerContext -> activeChild?.activeLeafDestination() + is DestinationContext<*> -> activeChild?.activeLeafDestination() ?: this + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.findAllContainers.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.findAllContainers.kt new file mode 100644 index 000000000..4f0f823fa --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.findAllContainers.kt @@ -0,0 +1,45 @@ +package dev.enro.context + +import dev.enro.NavigationContainer + +/** + * Finds all child containers from the given navigation context. + * This function recursively traverses the navigation hierarchy to find all containers + * that are descendants of the given context. + * + * @param context The navigation context to start searching from + * @return A list of all child containers found + * + * Example usage: + * ``` + * val rootContext = navigationController.rootContext + * val allContainers = findAllChildContainers(rootContext) + * + * // Find containers from a specific destination context + * val destinationContext = navigationHandle.context + * val childContainers = destinationContext.findAllChildContainers() + * ``` + */ +public fun AnyNavigationContext.findAllContainers(): List { + val containers = mutableListOf() + fun traverse(currentContext: AnyNavigationContext) { + when (currentContext) { + is ContainerContext -> { + // Add this container + containers.add(currentContext.container) + } + else -> {} + } + currentContext.children + .filterIsInstance() + .forEach { child -> + traverse(child) + } + } + this.children + .filterIsInstance() + .forEach { child -> + traverse(child) + } + return containers +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.findContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.findContext.kt new file mode 100644 index 000000000..6b36c7a9e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.findContext.kt @@ -0,0 +1,80 @@ +package dev.enro.context + +import dev.enro.NavigationContainer +import dev.enro.NavigationKey + +private fun AnyNavigationContext.findContext( + activeOnly: Boolean, + predicate: (AnyNavigationContext) -> Boolean, +): AnyNavigationContext? { + if (predicate(this)) return this + children.onEach { child -> + child as AnyNavigationContext + + if (activeOnly && !child.isActive) return@onEach + child.findContext( + activeOnly = activeOnly, + predicate = predicate + )?.let { return it } + } + return null +} + +public fun AnyNavigationContext.findContext( + predicate: (AnyNavigationContext) -> Boolean, +): AnyNavigationContext? { + return findContext(activeOnly = false, predicate = predicate) +} + +public fun AnyNavigationContext.findActiveContext( + predicate: (AnyNavigationContext) -> Boolean, +): AnyNavigationContext? { + return findContext(activeOnly = true, predicate = predicate) +} + +@Suppress("UNCHECKED_CAST") +public inline fun AnyNavigationContext.findDestinationContext( + crossinline predicate: (DestinationContext) -> Boolean = { true }, +): DestinationContext? { + return findContext { + it is DestinationContext<*> && it.key is T && predicate(it as DestinationContext) + } as? DestinationContext +} + +@Suppress("UNCHECKED_CAST") +public inline fun AnyNavigationContext.findActiveDestinationContext( + crossinline predicate: (DestinationContext) -> Boolean = { true }, +): DestinationContext? { + return findActiveContext { + @Suppress("UNCHECKED_CAST") + it is DestinationContext<*> && it.key is T && predicate(it as DestinationContext) + } as? DestinationContext +} + +public inline fun AnyNavigationContext.findDestinationContext( + key: T, +): DestinationContext? = findDestinationContext { + it.key == key +} + +public inline fun AnyNavigationContext.findActiveDestinationContext( + key: T, +): DestinationContext? = findActiveDestinationContext { + it.key == key +} + +public fun AnyNavigationContext.findContainerContext( + key: NavigationContainer.Key, +): ContainerContext? { + return findContext { + it is ContainerContext && it.container.key == key + } as? ContainerContext +} + +public fun AnyNavigationContext.findActiveContainerContext( + key: NavigationContainer.Key, +): ContainerContext? { + return findActiveContext { + it is ContainerContext && it.container.key == key + } as? ContainerContext +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getDebugString.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getDebugString.kt new file mode 100644 index 000000000..893347b69 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getDebugString.kt @@ -0,0 +1,55 @@ +package dev.enro.context + + + +public fun AnyNavigationContext.getDebugString(): String { + return buildString { + appendNode(this@getDebugString, 0) + } +} + +private fun StringBuilder.appendNode( + context: AnyNavigationContext, + depth: Int, +) { + val indent = " ".repeat(depth) + val activeIndent = "- - ".repeat(depth) + when { + context.isActiveInRoot -> append(activeIndent) + else -> append(indent) + } + + if (context !is RootContext) { + if (context.isActiveInRoot) { + append("→ ") + } else { + append(" ") + } + } + when (context) { + is ContainerContext -> append("Container(${context.container.key.name})") + is DestinationContext<*> -> append("Destination(${context.key::class.simpleName})") + is RootContext -> append("Root") + } + appendLine() + + if (context is ContainerContext) { + val childrenById = context.children.associateBy { it.id } + context.container.backstack.forEach { + val context = childrenById[it.id] + if (context != null) { + appendNode(context, depth + 1) + } + else { + appendLine("$indent Destination(${it.key::class.simpleName})") + } + } + } + else { + context.children.forEachIndexed { index, child -> + if (child is AnyNavigationContext) { + appendNode(child, depth + 1) + } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getNavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getNavigationHandle.kt new file mode 100644 index 000000000..11cbe05f9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getNavigationHandle.kt @@ -0,0 +1,31 @@ +package dev.enro.context + +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.viewmodel.getNavigationHandle +import kotlin.jvm.JvmName + +/** + * Returns the [NavigationHandle] typed to [T] for this navigation + * context, validating the key type at runtime. + * + * Most consumers should reach for the Composable / ViewModel accessors + * instead. This is the right call when you're walking the navigation + * context tree (e.g. inside a `NavigationContainer` traversal or a custom + * back handler that inspects child contexts) and need a handle for a + * specific child. + */ +public inline fun AnyNavigationContext.getNavigationHandle(): NavigationHandle { + return (this as ViewModelStoreOwner).getNavigationHandle() +} + +/** + * Untyped variant of [getNavigationHandle] for code that needs to reach + * the handle but doesn't care (or can't tell) what the destination's key + * type is. + */ +@JvmName("getNavigationHandleDefault") +public fun AnyNavigationContext.getNavigationHandle(): NavigationHandle { + return (this as ViewModelStoreOwner).getNavigationHandle() +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getViewModel.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getViewModel.kt new file mode 100644 index 000000000..430fecd88 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getViewModel.kt @@ -0,0 +1,91 @@ +package dev.enro.context + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import kotlin.reflect.KClass + +/** + * When attempting to find a ViewModel in a NavigationContext, we don't want to create a new ViewModel, rather we want to + * get an existing instance of that ViewModel, if it exists, so this ViewModelProvider.Factory always throws an exception + * if it is ever asked to actually create a ViewModel. + */ +private class NavigationContextViewModelFactory() : ViewModelProvider.Factory { + override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T { + error("Failed to create ViewModel $modelClass. This factory should not be used to create ViewModels.") + } +} + +private fun viewModelNotFoundError(context: AnyNavigationContext, modelClass: KClass<*>): Nothing { + val contextString = when (context) { + is DestinationContext<*> -> "NavigationContext.Destination with key: ${context.key::class.simpleName} and id: ${context.id}" + is ContainerContext -> "NavigationContext.Container with id: ${context.id}" + is RootContext -> "NavigationContext.Root" + } + error("ViewModel ${modelClass.simpleName} was not found in $contextString") +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested, or null if the ViewModel does not exist in the NavigationContext's ViewModelStore + */ +public fun AnyNavigationContext.getViewModel( + cls: KClass, + key: String? = null, +): T? { + val provider = ViewModelProvider.create( + owner = this, + factory = NavigationContextViewModelFactory(), + ) + val result = kotlin.runCatching { + if (key == null) { + provider.get(modelClass = cls) + } else { + provider.get(modelClass = cls, key = key) + } + } + return result.getOrNull() +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested + * + * @throws IllegalStateException if the ViewModel does not already exist in the NavigationContext + */ +public fun AnyNavigationContext.requireViewModel( + cls: KClass, + key: String? = null, +): T { + return getViewModel(cls, key) + ?: viewModelNotFoundError(this, cls) +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested, or null if the ViewModel does not exist in the NavigationContext's ViewModelStore + */ +public inline fun AnyNavigationContext.getViewModel( + key: String? = null, +): T? { + return getViewModel(T::class, key) +} + +/** + * Attempt to get a ViewModel of a certain type from a NavigationContext. + * + * @return The ViewModel requested + * + * @throws IllegalStateException if the ViewModel does not already exist in the NavigationContext + */ +public inline fun AnyNavigationContext.requireViewModel( + key: String? = null, +): T { + return requireViewModel(T::class, key) +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.kt new file mode 100644 index 000000000..0124c49f9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.kt @@ -0,0 +1,170 @@ +package dev.enro.context + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.EnroController + +public sealed interface NavigationContextBase +public typealias AnyNavigationContext = NavigationContext<*, *> + +public sealed class NavigationContext() : + NavigationContextBase, + LifecycleOwner, + ViewModelStoreOwner, + HasDefaultViewModelProviderFactory { + + public abstract val id: String + public abstract val controller: EnroController + + // Returns true if this NavigationContext can be considered active within the scope of it's parent + public val isActive: Boolean by derivedStateOf { + val parentContext = parent as? NavigationContext<*, *> + if (parentContext == null) return@derivedStateOf true + return@derivedStateOf parentContext.activeChild == this + } + + // Returns true if this NavigationContext can be considered to be active globally, + // in other words, is this context and its parent context considered active + public val isActiveInRoot: Boolean by derivedStateOf { + val parentContext = parent as? NavigationContext<*, *> + isActive && (parentContext == null || parentContext.isActiveInRoot) + } + public abstract val parent: Parent + + protected val mutableChildren: SnapshotStateMap> = mutableStateMapOf() + public val children: List by derivedStateOf { + mutableChildren.values.map { it.child } + } + + public abstract val activeChild: Child? + + public abstract fun registerChild(child: Child) + public abstract fun unregisterChild(child: Child) + + public abstract fun registerVisibility(child: Child, isVisible: Boolean) + + // requests that the current container becomes active + // For NavigationContainer contexts, this will cause the NavigationContainer to become + // active in its parent context (but not active globally) + public fun requestActive() { + when (this) { + is ContainerContext -> { + parent.setActiveContainer(this) + } + + is DestinationContext<*> -> { + // if a destination is requested to become active, we request that the parent container + // becomes active + parent.requestActive() + } + + is RootContext -> { + // RootContext does not have ability to request active + } + } + } + + // requests that the current container becomes active globally, which is to say + // that this container is requested to become active, and then + // all parent containers are requested to become active recursively up until the root + public fun requestActiveInRoot() { + requestActive() + when (this) { + is ContainerContext -> { + parent.requestActiveInRoot() + } + + is DestinationContext<*> -> { + parent.requestActiveInRoot() + } + + is RootContext -> { + // RootContext does not have ability to request active + } + } + } + + protected data class ChildState( + val child: NavigationContextBase, + val isVisible: Boolean, + val registrationOrder: Long, + ) + + internal companion object { + internal var registrationOrderCounter = 0L + } + + public sealed class WithContainerChildren( + private val activeChildId: MutableState, + ) : NavigationContext() { + override val activeChild: ContainerContext? by derivedStateOf { + mutableChildren[activeChildId.value]?.child + } + + override fun registerChild(child: ContainerContext) { + mutableChildren[child.id] = ChildState( + child = child, + isVisible = false, + registrationOrder = registrationOrderCounter++, + ) + if (activeChildId.value == null) { + activeChildId.value = child.id + } + } + + override fun unregisterChild(child: ContainerContext) { + mutableChildren.remove(child.id) + if (activeChildId.value == child.id) { + activeChildId.value = mutableChildren.values + .sortedBy { it.registrationOrder } + .firstOrNull { + it.isVisible + }?.child?.id + } + } + + override fun registerVisibility( + child: ContainerContext, + isVisible: Boolean, + ) { + val current = mutableChildren[child.id] + if (current == null) return + if (current.isVisible == isVisible) return + mutableChildren[child.id] = ChildState( + child = child, + isVisible = isVisible, + registrationOrder = current.registrationOrder + ) + + val currentlyActive = mutableChildren[activeChildId.value] + if (currentlyActive == null || !currentlyActive.isVisible) { + if (isVisible) { + activeChildId.value = child.id + } + } + if (!isVisible && activeChildId.value == child.id) { + val visibleChild = mutableChildren.values + .sortedBy { it.registrationOrder } + .firstOrNull { + it.isVisible + }?.child + if (visibleChild != null) { + activeChildId.value = visibleChild.id + } + } + } + + public fun setActiveContainer(childId: String) { + val child = children.firstOrNull { it.id == childId } + if (child != null) { + activeChildId.value = child.id + } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.requireContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.requireContext.kt new file mode 100644 index 000000000..bec5f1280 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.requireContext.kt @@ -0,0 +1,69 @@ +package dev.enro.context + +import dev.enro.NavigationContainer +import dev.enro.NavigationKey + +public fun AnyNavigationContext.requireContext( + predicate: (AnyNavigationContext) -> Boolean, +): AnyNavigationContext { + val found = findContext(predicate = predicate) + return requireNotNull(found) { + "Could not find a context that matches the given predicate from NavigationContext with id: $id" + } +} + +public fun AnyNavigationContext.requireActiveContext( + predicate: (AnyNavigationContext) -> Boolean, +): AnyNavigationContext { + val found = findActiveContext(predicate = predicate) + return requireNotNull(found) { + "Could not find a context that matches the given predicate from NavigationContext with id: $id" + } +} + +@Suppress("UNCHECKED_CAST") +public inline fun AnyNavigationContext.requireContext( + crossinline predicate: (DestinationContext) -> Boolean = { true }, +): DestinationContext { + return requireContext { + it is DestinationContext<*> && it.key is T && predicate(it as DestinationContext) + } as DestinationContext +} + +@Suppress("UNCHECKED_CAST") +public inline fun AnyNavigationContext.requireActiveContext( + crossinline predicate: (DestinationContext) -> Boolean = { true }, +): DestinationContext { + return requireActiveContext { + @Suppress("UNCHECKED_CAST") + it is DestinationContext<*> && it.key is T && predicate(it as DestinationContext) + } as DestinationContext +} + +public inline fun AnyNavigationContext.requireContext( + key: T, +): AnyNavigationContext? = requireContext { + it.key == key +} + +public inline fun AnyNavigationContext.requireActiveContext( + key: T, +): AnyNavigationContext = requireActiveContext { + it.key == key +} + +public fun AnyNavigationContext.requireContext( + key: NavigationContainer.Key, +): AnyNavigationContext { + return requireContext { + it is ContainerContext && it.container.key == key + } +} + +public fun AnyNavigationContext.requireActiveContext( + key: NavigationContainer.Key, +): AnyNavigationContext { + return requireActiveContext { + it is ContainerContext && it.container.key == key + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.root.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.root.kt new file mode 100644 index 000000000..1b0c9381e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.root.kt @@ -0,0 +1,9 @@ +package dev.enro.context + +public fun AnyNavigationContext.root(): RootContext { + return when(this) { + is RootContext -> this + is ContainerContext -> parent.root() + is DestinationContext<*> -> parent.root() + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.setActiveContainer.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.setActiveContainer.kt new file mode 100644 index 000000000..b0212d068 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.setActiveContainer.kt @@ -0,0 +1,26 @@ +package dev.enro.context + +import dev.enro.NavigationContainer +import dev.enro.ui.NavigationContainerState + +private fun NavigationContext<*, ContainerContext>.setActiveContainerId( + id: String, +) { + when (this) { + is ContainerContext -> return + is DestinationContext<*> -> setActiveContainer(id) + is RootContext -> setActiveContainer(id) + } +} + +public fun NavigationContext<*, ContainerContext>.setActiveContainer(child: ContainerContext) { + setActiveContainerId(child.id) +} + +public fun NavigationContext<*, ContainerContext>.setActiveContainer(child: NavigationContainerState) { + setActiveContainerId(child.key.name) +} + +public fun NavigationContext<*, ContainerContext>.setActiveContainer(child: NavigationContainer) { + setActiveContainerId(child.key.name) +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/RootContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/RootContext.kt new file mode 100644 index 000000000..9c6e36264 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/RootContext.kt @@ -0,0 +1,22 @@ +package dev.enro.context + +import androidx.compose.runtime.MutableState +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.EnroController + +public class RootContext( + override val id: String, + override val parent: Any, + override val controller: EnroController, + lifecycleOwner: LifecycleOwner, + viewModelStoreOwner: ViewModelStoreOwner, + defaultViewModelProviderFactory: HasDefaultViewModelProviderFactory, + private val activeChildId: MutableState, +) : NavigationContext.WithContainerChildren(activeChildId), + LifecycleOwner by lifecycleOwner, + ViewModelStoreOwner by viewModelStoreOwner, + HasDefaultViewModelProviderFactory by defaultViewModelProviderFactory { + +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/context/RootContextRegistry.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/context/RootContextRegistry.kt new file mode 100644 index 000000000..a3bcbd6f4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/RootContextRegistry.kt @@ -0,0 +1,26 @@ +package dev.enro.context + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateListOf +import dev.enro.platform.EnroLog + +@Stable +internal class RootContextRegistry() { + private val contexts = mutableStateListOf() + + fun register(context: RootContext) { + val hadExistingContexts = contexts.removeAll { it.id == context.id} + if (hadExistingContexts) { + EnroLog.warn("Registered a RootContext that is already registered: ${context.id}") + } + contexts.add(context) + } + + fun unregister(context: RootContext) { + contexts.remove(context) + } + + fun getAllContexts(): List { + return contexts + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationComponentConfiguration.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationComponentConfiguration.kt new file mode 100644 index 000000000..53100e98a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationComponentConfiguration.kt @@ -0,0 +1,5 @@ +package dev.enro.controller + +public abstract class NavigationComponentConfiguration( + public val module: NavigationModule = createNavigationModule { } +) \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationModule.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationModule.kt new file mode 100644 index 000000000..eb6e73b8f --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationModule.kt @@ -0,0 +1,109 @@ +package dev.enro.controller + +import androidx.compose.runtime.Composable +import dev.enro.NavigationBinding +import dev.enro.NavigationKey +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.interceptor.builder.NavigationInterceptorBuilder +import dev.enro.interceptor.builder.navigationInterceptor +import dev.enro.path.NavigationPathBinding +import dev.enro.plugin.NavigationPlugin +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.decorators.NavigationDestinationDecorator +import dev.enro.ui.decorators.navigationDestinationDecorator +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus + +public class NavigationModule @PublishedApi internal constructor() { + internal val plugins: MutableList = mutableListOf() + internal val bindings: MutableList> = mutableListOf() + internal val decorators: MutableList<() -> NavigationDestinationDecorator> = mutableListOf() + internal val interceptors: MutableList = mutableListOf() + internal val paths: MutableList> = mutableListOf() + internal var serializers: SerializersModule = EmptySerializersModule() + + internal val serializersForBindings: SerializersModule + get() { + if (bindings.isEmpty()) return EmptySerializersModule() + return SerializersModule { + bindings.forEach { binding -> + binding.serializerModule.invoke(this) + } + } + } + + public class BuilderScope @PublishedApi internal constructor( + private val module: NavigationModule + ) { + public fun plugin(plugin: NavigationPlugin) { + module.plugins.add(plugin) + } + + public fun interceptor(interceptor: NavigationInterceptor) { + module.interceptors.add(interceptor) + } + + public fun interceptor(block: NavigationInterceptorBuilder.() -> Unit) { + module.interceptors.add(navigationInterceptor(block)) + } + + public fun binding(binding: NavigationBinding<*>) { + module.bindings.add(binding) + } + + public fun decorator(decorator: () -> NavigationDestinationDecorator) { + module.decorators.add(decorator) + } + + @Deprecated( + message = "Use 'decorator' instead, and provide a full NavigationDestinationDecorator" + ) + public fun composeEnvironment( + block: @Composable (content: @Composable () -> Unit) -> Unit + ) { + decorator { + navigationDestinationDecorator { destination -> + block { + destination.Content() + } + } + } + } + + public inline fun destination( + destination: NavigationDestinationProvider, + isPlatformOverride: Boolean = false + ) { + binding( + binding = NavigationBinding.create( + provider = destination, + isPlatformOverride = isPlatformOverride, + ) + ) + } + + public fun path(path: NavigationPathBinding<*>) { + module.paths.add(path) + } + + public fun serializersModule(serializersModule: SerializersModule) { + module.serializers = module.serializers + serializersModule + } + + public fun module(module: NavigationModule) { + this.module.plugins.addAll(module.plugins) + this.module.bindings.addAll(module.bindings) + this.module.interceptors.addAll(module.interceptors) + this.module.decorators.addAll(module.decorators) + this.module.paths.addAll(module.paths) + this.module.serializers += module.serializers + } + } +} + +public fun createNavigationModule(block: NavigationModule.BuilderScope.() -> Unit): NavigationModule { + val module = NavigationModule() + NavigationModule.BuilderScope(module).block() + return module +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationModuleAction.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationModuleAction.kt new file mode 100644 index 000000000..c311c6abf --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/NavigationModuleAction.kt @@ -0,0 +1,5 @@ +package dev.enro.controller + +public interface NavigationModuleAction { + public fun NavigationModule.BuilderScope.invoke() +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/defaultNavigationModule.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/defaultNavigationModule.kt new file mode 100644 index 000000000..f07fca726 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/defaultNavigationModule.kt @@ -0,0 +1,15 @@ +package dev.enro.controller + +import dev.enro.NavigationHandleConfiguration +import dev.enro.controller.interceptors.PreviouslyActiveContainerInterceptor +import dev.enro.controller.interceptors.RootDestinationInterceptor +import dev.enro.ui.destinations.EmptyNavigationKey +import dev.enro.ui.destinations.SyntheticDestination +import dev.enro.ui.destinations.emptyDestination + +internal val defaultNavigationModule = createNavigationModule { + interceptor(RootDestinationInterceptor) + interceptor(SyntheticDestination.interceptor) + interceptor(PreviouslyActiveContainerInterceptor) + destination(emptyDestination()) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/interceptors/PreviouslyActiveContainerInterceptor.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/interceptors/PreviouslyActiveContainerInterceptor.kt new file mode 100644 index 000000000..cab113ff3 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/interceptors/PreviouslyActiveContainerInterceptor.kt @@ -0,0 +1,72 @@ +package dev.enro.controller.interceptors + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext +import dev.enro.context.activeLeaf +import dev.enro.context.findContext +import dev.enro.context.root +import dev.enro.interceptor.NavigationInterceptor +/** + * A core Enro interceptor that tracks the active container when navigation operations are executed + * and restores that container's active state when the navigation is closed. + * + * This interceptor ensures that when a navigation operation opens a destination from a specific + * container, and that destination is later closed, the original container becomes active again. + * This is particularly useful in scenarios with multiple containers, such as the HorizontalPager + * sample in the test application. + * + * The interceptor works by: + * 1. Attaching metadata to NavigationKey instances during open operations that records which + * container was active at the time + * 2. Reading this metadata during close/complete operations and creating a side effect to + * reactivate the previously active container + * + * This functionality is currently enabled by default in Enro but may become optional in future + * versions. + */ +internal object PreviouslyActiveContainerInterceptor : NavigationInterceptor() { + override fun beforeIntercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operations: List, + ): List { + if (operations.size != 1) return operations + val operation = operations.first() + val previouslyActiveContainer = when (operation) { + is NavigationOperation.Close<*> -> operation.instance.metadata.get(PreviouslyActiveContainer) + is NavigationOperation.Complete<*> -> operation.instance.metadata.get(PreviouslyActiveContainer) + is NavigationOperation.Open<*> -> null + is NavigationOperation.SideEffect -> null + } ?: return operations + + if (previouslyActiveContainer == containerContext.id) return operations + + val context = fromContext.root().findContext { it.id == previouslyActiveContainer } + return operations + NavigationOperation.SideEffect { + context?.requestActive() + } + } + + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Open, + ): NavigationOperation? { + val leaf = fromContext.root().activeLeaf() + val activeContainerId = when (leaf) { + is ContainerContext -> leaf.id + is DestinationContext<*> -> leaf.parent.id + is RootContext -> return operation + } + if (activeContainerId == containerContext.id) return operation + return operation.apply { + instance.metadata.set(PreviouslyActiveContainer, activeContainerId) + } + } + + private object PreviouslyActiveContainer : NavigationKey.MetadataKey(null) +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/interceptors/RootDestinationInterceptor.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/interceptors/RootDestinationInterceptor.kt new file mode 100644 index 000000000..02ca10653 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/interceptors/RootDestinationInterceptor.kt @@ -0,0 +1,58 @@ +package dev.enro.controller.interceptors + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext +import dev.enro.context.root +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.ui.destinations.isRootContextDestination +import dev.enro.viewmodel.getNavigationHandle + +/** + * A core navigation interceptor that handles operations targeting root context destinations. + * + * This interceptor identifies navigation operations that should open new root contexts (e.g., new activities + * on Android, new windows on desktop) and redirects them to the appropriate root navigation handle. + * While most navigation operations resolve to composables that can be rendered within existing containers, + * some destinations require opening entirely new root contexts. + * + * The interceptor works by: + * 1. Filtering out operations marked as root context destinations + * 2. Creating a side effect that executes these operations through the root context's navigation handle + * 3. Allowing platform-specific implementations to handle root context creation appropriately + * + * @see NavigationInterceptor + */ +internal object RootDestinationInterceptor : NavigationInterceptor() { + /** + * Intercepts navigation operations before they are processed, extracting root context operations + * and redirecting them to the root navigation handle. + * + * @param fromContext The navigation context initiating the operation + * @param containerContext The container context where operations would normally be rendered + * @param operations The list of navigation operations to process + * @return Modified list of operations with root operations replaced by a side effect + */ + override fun beforeIntercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operations: List, + ): List { + val rootOperations = operations.filterIsInstance>() + .filter { it.instance.isRootContextDestination(fromContext.controller) } + + val rootOperation = when { + rootOperations.isEmpty() -> return operations + rootOperations.size == 1 -> rootOperations.first() + else -> NavigationOperation.AggregateOperation(rootOperations) + } + return (operations - rootOperations).plus( + NavigationOperation.SideEffect { + fromContext.root() + .getNavigationHandle() + .execute(rootOperation) + } + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/internalCreateEnroController.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/internalCreateEnroController.kt new file mode 100644 index 000000000..81efa0587 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/internalCreateEnroController.kt @@ -0,0 +1,18 @@ +package dev.enro.controller + +import dev.enro.EnroController +import dev.enro.platform.platformNavigationModule + +// Marked as internal, but is used in generated code with a @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") +internal fun internalCreateEnroController( + isDebug: Boolean = false, + builder: NavigationModule.BuilderScope.() -> Unit = {}, +) : EnroController { + val module = createNavigationModule(builder) + return EnroController().apply { + this.isDebug = isDebug + addModule(defaultNavigationModule) + addModule(platformNavigationModule) + addModule(module) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/BindingRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/BindingRepository.kt new file mode 100644 index 000000000..5a687d5ea --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/BindingRepository.kt @@ -0,0 +1,110 @@ +package dev.enro.controller.repository + +import dev.enro.NavigationBinding +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import kotlin.reflect.KClass + +internal class BindingRepository( + private val plugins: PluginRepository, +) { + private val bindingsByKeyType = mutableMapOf, NavigationBinding<*>>() + private val originalBindingsByKeyType = mutableMapOf, NavigationBinding<*>>() + + fun addNavigationBindings(binding: List>) { + binding.forEach { it -> + val existingBinding = bindingsByKeyType[it.keyType] + val existingIsPlatformOverride = existingBinding?.isPlatformOverride == true + + val multiplePlatformOverrides = existingIsPlatformOverride + && it.isPlatformOverride + + val multipleRegularBindings = existingBinding != null + && !existingIsPlatformOverride + && !it.isPlatformOverride + + val platformOverrideAlreadyBound = existingIsPlatformOverride + && !it.isPlatformOverride + + val isValidBinding = existingBinding == null + || existingBinding == it + || (it.isPlatformOverride && !existingBinding.isPlatformOverride) + + when { + multiplePlatformOverrides -> error( + "Found multiple platform override bindings for ${it.keyType.qualifiedName}." + + " Please ensure that only one binding is provided for each key type." + ) + + multipleRegularBindings -> error( + "Found multiple bindings for ${it.keyType.qualifiedName}." + + " Please ensure that only one binding is provided for each key type, or use @PlatformOverride to override an existing binding for a specific platform." + ) + + platformOverrideAlreadyBound -> { + // If an existing binding is a platform override, and the new binding is not, + // then we should not replace the existing binding. + originalBindingsByKeyType[it.keyType] = it + return@forEach + } + + isValidBinding -> { + // If the existing binding is not a platform override, and the new binding is, + // then we should replace the existing binding. + bindingsByKeyType[it.keyType] = it + if (existingBinding != null) { + originalBindingsByKeyType[existingBinding.keyType] = existingBinding + } + } + + else -> { + error("An unknown error occurred while adding the binding for ${it.keyType.qualifiedName}.") + } + } + } + } + + fun bindingFor( + instance: NavigationKey.Instance, + ): NavigationBinding { + val binding = when { + NavigationBinding.usesOriginalBinding(instance) -> + originalBindingsByKeyType[instance.key::class] + ?: bindingsByKeyType[instance.key::class] + + else -> bindingsByKeyType[instance.key::class] + } + @Suppress("UNCHECKED_CAST") + return requireNotNull(binding) { + "No binding found for ${instance.key::class.qualifiedName}" + } as NavigationBinding + } + + fun destinationFor( + instance: NavigationKey.Instance, + ): NavigationDestination { + // Create the destination, and allow plugins to intercept and add + val binding = bindingFor(instance) + val destination = binding.provider.create(instance) + + val additionalMetadata = mutableMapOf() + plugins.onDestinationCreated( + destination = destination, + additionalMetadata = additionalMetadata, + ) + if (additionalMetadata.isEmpty()) return destination + + val updatedMetadata = (destination.metadata + additionalMetadata) + .mapNotNull { (key, value) -> + when (value) { + null -> null + else -> key to value + } + } + .toMap() + + return destination.copy( + metadata = updatedMetadata, + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/DecoratorRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/DecoratorRepository.kt new file mode 100644 index 000000000..2035b012d --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/DecoratorRepository.kt @@ -0,0 +1,24 @@ +package dev.enro.controller.repository + +import dev.enro.NavigationKey +import dev.enro.ui.decorators.NavigationDestinationDecorator + +internal class DecoratorRepository { + private val decoratorBuilders = mutableListOf<() -> NavigationDestinationDecorator>() + + fun addDecorator( + decorator: () -> NavigationDestinationDecorator, + ) { + decoratorBuilders.add(decorator) + } + + fun addDecorators( + decorators: List<() -> NavigationDestinationDecorator> + ) { + decoratorBuilders.addAll(decorators) + } + + fun getDecorators() : List> { + return decoratorBuilders.map { builder -> builder() } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/InterceptorRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/InterceptorRepository.kt new file mode 100644 index 000000000..3635084e3 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/InterceptorRepository.kt @@ -0,0 +1,25 @@ +package dev.enro.controller.repository + +import dev.enro.interceptor.AggregateNavigationInterceptor +import dev.enro.interceptor.NavigationInterceptor + +internal class InterceptorRepository( + private val interceptors: MutableList = mutableListOf() +) { + var aggregateInterceptor = AggregateNavigationInterceptor(interceptors) + + fun addInterceptors(interceptors: List) { + this.interceptors.addAll(interceptors) + aggregateInterceptor = AggregateNavigationInterceptor(this.interceptors) + } + + fun addInterceptor(interceptor: NavigationInterceptor) { + interceptors.add(interceptor) + aggregateInterceptor = AggregateNavigationInterceptor(this.interceptors) + } + + fun removeInterceptor(interceptor: NavigationInterceptor) { + interceptors.remove(interceptor) + aggregateInterceptor = AggregateNavigationInterceptor(this.interceptors) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PathRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PathRepository.kt new file mode 100644 index 000000000..9046acd8a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PathRepository.kt @@ -0,0 +1,35 @@ +package dev.enro.controller.repository + +import dev.enro.NavigationKey +import dev.enro.path.NavigationPathBinding +import dev.enro.path.ParsedPath + + +public class PathRepository { + private val bindings = mutableListOf>() + + public fun addPaths(paths: List>) { + this.bindings.addAll(paths) + } + + public fun addPath(path: NavigationPathBinding) { + this.bindings.add(path) + } + + public fun getPathBinding(): List> { + return bindings.filterIsInstance>() + } + + public fun getPathBinding(path: ParsedPath): NavigationPathBinding<*>? { + return NavigationPathBinding.resolveForPath(bindings, path) + } + + public fun getPathBindingForKey(key: T): NavigationPathBinding? { + @Suppress("UNCHECKED_CAST") + return bindings.firstOrNull { it.matches(key) } as NavigationPathBinding? + } + + public fun getPathBindingsForKey(key: NavigationKey): List> { + return bindings.filter { it.matches(key) } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PluginRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PluginRepository.kt new file mode 100644 index 000000000..038ea1a6b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PluginRepository.kt @@ -0,0 +1,66 @@ +package dev.enro.controller.repository + +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.plugin.NavigationPlugin +import dev.enro.ui.NavigationDestination + +internal class PluginRepository { + private val plugins: MutableList = mutableListOf() + private var attachedController: EnroController? = null + + fun addPlugins( + plugins: List + ) { + if (plugins.isEmpty()) return + this.plugins += plugins + attachedController?.let { attachedController -> + plugins.forEach { it.onAttached(attachedController) } + } + } + + fun removePlugins( + plugins: List, + ) { + this.plugins -= plugins + attachedController?.let { attachedController -> + plugins.forEach { it.onDetached(attachedController) } + } + } + + fun onAttached(controller: EnroController) { + require(attachedController == null) { + "This PluginContainer is already attached to a NavigationController!" + } + attachedController = controller + plugins.forEach { it.onAttached(controller) } + } + + fun onDetached(controller: EnroController) { + if (attachedController == null) return + plugins.forEach { it.onDetached(controller) } + attachedController = null + } + + fun onOpened(navigationHandle: NavigationHandle<*>) { + plugins.forEach { it.onOpened(navigationHandle) } + } + + fun onActive(navigationHandle: NavigationHandle<*>) { + plugins.forEach { it.onActive(navigationHandle) } + } + + fun onClosed(navigationHandle: NavigationHandle<*>) { + plugins.forEach { it.onClosed(navigationHandle) } + } + + fun onDestinationCreated( + destination: NavigationDestination, + additionalMetadata: MutableMap, + ) { + plugins.forEach { + it.onDestinationCreated(destination, additionalMetadata) + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/SerializerRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/SerializerRepository.kt new file mode 100644 index 000000000..a3d12a120 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/SerializerRepository.kt @@ -0,0 +1,77 @@ +package dev.enro.controller.repository + +import androidx.savedstate.serialization.ClassDiscriminatorMode +import androidx.savedstate.serialization.SavedStateConfiguration +import dev.enro.NavigationKey +import dev.enro.result.NavigationResultChannel +import dev.enro.result.flow.FlowStep +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.builtins.NothingSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +internal class SerializerRepository { + @OptIn(ExperimentalSerializationApi::class) + var serializersModule = + SerializersModule { + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + include(dev.enro.serialization.serializerModuleForWrapped) + + polymorphic(Any::class) { + subclass(Unit.serializer()) + subclass(FlowStep.serializer(NothingSerializer())) + subclass(NavigationResultChannel.Id.serializer()) + } + contextual>( + NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)) + ) + } + private set + + var savedStateConfiguration: SavedStateConfiguration = + SavedStateConfiguration { + serializersModule = this@SerializerRepository.serializersModule + classDiscriminatorMode = ClassDiscriminatorMode.ALL_OBJECTS + } + private set + + @OptIn(ExperimentalSerializationApi::class) + var jsonConfiguration: Json = + Json { + serializersModule = this@SerializerRepository.serializersModule + // Deliberately the default (POLYMORPHIC) discriminator mode. + // ALL_JSON_OBJECTS is unusable with kotlinx 1.11 for realistic + // NavigationKey shapes: under polymorphic dispatch, the STREAMING + // encoder leaks a value-class field's deferred discriminator into + // the next-opened object (corrupting Instance.metadata), and emits + // INVALID JSON for collection fields (a "type" key:value pair + // inside an array); the TREE encoder crashes outright + // (NumberFormatException) on collection fields. POLYMORPHIC mode + // writes discriminators exactly where polymorphic deserialization + // reads them (Instance.key, metadata values) and handles all of + // these shapes correctly. See HistoryStateSerializationTests, + // which also documents the ALL_JSON_OBJECTS failures so a kotlinx + // upgrade that fixes them is detected. Upstream: + // https://github.com/Kotlin/kotlinx.serialization/issues/3022 + ignoreUnknownKeys = true + } + private set + + fun registerSerializersModule( + serializersModule: SerializersModule, + ) { + this.serializersModule += serializersModule + this.savedStateConfiguration = SavedStateConfiguration(from = savedStateConfiguration) { + this@SavedStateConfiguration.serializersModule = this@SerializerRepository.serializersModule + } + this.jsonConfiguration = Json(from = jsonConfiguration) { + this@Json.serializersModule = this@SerializerRepository.serializersModule + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/ViewModelRepository.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/ViewModelRepository.kt new file mode 100644 index 000000000..3d8d4f148 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/ViewModelRepository.kt @@ -0,0 +1,22 @@ +package dev.enro.controller.repository + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.InitializerViewModelFactoryBuilder +import kotlin.reflect.KClass + +internal class ViewModelRepository { + private val builder = InitializerViewModelFactoryBuilder() + + internal fun register( + clazz: KClass, + initializer: CreationExtras.() -> T, + ) { + builder.addInitializer(clazz, initializer) + } + + internal fun getFactory(): ViewModelProvider.Factory { + return builder.build() + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/handle/DestinationNavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/DestinationNavigationHandle.kt new file mode 100644 index 000000000..2f03283b7 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/DestinationNavigationHandle.kt @@ -0,0 +1,99 @@ +package dev.enro.handle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.DestinationContext +import dev.enro.platform.EnroLog + +internal class DestinationNavigationHandle( + instance: NavigationKey.Instance, + override val savedStateHandle: SavedStateHandle, +) : NavigationHandle() { + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle = lifecycleRegistry + + private var context: DestinationContext? = null + override var instance: NavigationKey.Instance = instance + private set + + private val lifecycleObserver = LifecycleEventObserver { owner, event -> + when (event) { + Lifecycle.Event.ON_START -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + Lifecycle.Event.ON_STOP -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + Lifecycle.Event.ON_RESUME -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + Lifecycle.Event.ON_PAUSE -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + Lifecycle.Event.ON_CREATE -> { + // No op: ON_CREATE is handled through the bindContext function + } + + Lifecycle.Event.ON_DESTROY -> { + // No op: ON_DESTROY is handled through the onDestroy function + } + + Lifecycle.Event.ON_ANY -> { + // No op + } + } + } + + internal fun bindContext(context: DestinationContext) { + if (this.context === context) return + if (lifecycle.currentState == Lifecycle.State.DESTROYED) return + require(context.destination.instance.id == instance.id) { + "Cannot bind NavigationContext with instance ${context.destination.instance} to NavigationHandle with instance ${instance}" + } + this.context?.lifecycle?.removeObserver(lifecycleObserver) + this.context = context + this.instance = context.destination.instance + if (lifecycle.currentState == Lifecycle.State.INITIALIZED) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + this.context?.lifecycle?.addObserver(lifecycleObserver) + } + + internal fun onDestroy() { + if (lifecycle.currentState == Lifecycle.State.DESTROYED) return + context?.let { context -> + this.context = null + context.lifecycle.removeObserver(lifecycleObserver) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + context.controller.plugins.onClosed(this) + } + } + + override fun execute( + operation: NavigationOperation, + ) { + if (lifecycle.currentState == Lifecycle.State.DESTROYED) return + val context = context + if (context == null) { + EnroLog.warn("NavigationHandle with instance $instance has no context, ignoring operation: $operation") + return + } + val isInBackstack = context.parent.container.backstack.any { it.id == context.destination.instance.id } + if (!isInBackstack) { + // Some destinations (particularly overlay destinations that have animations) may not enter the + // DESTROYED state immediately after being removed from their parent container, so they may still + // receive NavigationOperations. An example is a Dialog's scrim being tapped multiple times during the + // dismiss animation. In these cases, we want to ignore the operations. We print a warning here, + // so that it's visible to developers, but this should not necessarily raise concerns. + EnroLog.warn("NavigationHandle with instance $instance is not in it's parent's backstack, ignoring operation: $operation") + return + } + val containerContext = findContainerForOperation( + fromContext = context.parent, + operation = operation, + ) + requireNotNull(containerContext) { + "Could not find a valid container for the navigation operation: $operation from context with instance: ${context.destination.instance}" + } + containerContext + .container + .execute(context, operation) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/handle/NavigationHandleHolder.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/NavigationHandleHolder.kt new file mode 100644 index 000000000..ba11516c4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/NavigationHandleHolder.kt @@ -0,0 +1,84 @@ +package dev.enro.handle + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.get +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.viewModelFactory +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.platform.EnroLog + +@PublishedApi +internal class NavigationHandleHolder( + navigationHandle: NavigationHandle, +) : ViewModel() { + @PublishedApi + internal var navigationHandle: NavigationHandle by mutableStateOf(navigationHandle) + private set + + override fun onCleared() { + when (val impl = navigationHandle) { + is DestinationNavigationHandle -> impl.onDestroy() + is RootNavigationHandle -> impl.onDestroy() + } + navigationHandle = ClearedNavigationHandle( + instance = navigationHandle.instance + ) + } + + private class ClearedNavigationHandle( + override val instance: NavigationKey.Instance + ) : NavigationHandle() { + override val savedStateHandle: SavedStateHandle = SavedStateHandle() + + override val lifecycle: Lifecycle = object : Lifecycle() { + override val currentState: State = State.DESTROYED + override fun addObserver(observer: LifecycleObserver) {} + override fun removeObserver(observer: LifecycleObserver) {} + } + + override fun execute( + operation: NavigationOperation, + ) { + EnroLog.warn("NavigationHandle with instance $instance has been cleared, but has received an operation which will be ignored") + } + } +} + +@PublishedApi +internal fun ViewModelStoreOwner.getOrCreateNavigationHandleHolder( + createNavigationHandle: CreationExtras.() -> NavigationHandle, +): NavigationHandleHolder { + return ViewModelProvider.create( + owner = this, + factory = viewModelFactory { + addInitializer(NavigationHandleHolder::class) { + NavigationHandleHolder(createNavigationHandle()) + } + }, + extras = (this as HasDefaultViewModelProviderFactory).defaultViewModelCreationExtras, + ).get>() +} + +@PublishedApi +internal fun ViewModelStoreOwner.getNavigationHandleHolder(): NavigationHandleHolder<*> { + return ViewModelProvider.create( + owner = this, + factory = viewModelFactory { + addInitializer(NavigationHandleHolder::class) { + error("Expected NavigationHandleHolder to be present in ViewModelStoreOwner ${this@getNavigationHandleHolder}, but it was missing") + } + }, + extras = CreationExtras.Empty, + ).get>() +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/handle/RootNavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/RootNavigationHandle.kt new file mode 100644 index 000000000..cc52f5fdb --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/RootNavigationHandle.kt @@ -0,0 +1,117 @@ +package dev.enro.handle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.lifecycleScope +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.RootContext +import dev.enro.platform.EnroLog +import dev.enro.result.NavigationResultChannel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal class RootNavigationHandle( + instance: NavigationKey.Instance, + override val savedStateHandle: SavedStateHandle, +) : NavigationHandle() { + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle = lifecycleRegistry + + internal var context: RootContext? = null + private set + + override var instance: NavigationKey.Instance = instance + private set + + private val lifecycleObserver = LifecycleEventObserver { owner, event -> + when (event) { + Lifecycle.Event.ON_START -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + Lifecycle.Event.ON_STOP -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + Lifecycle.Event.ON_RESUME -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + Lifecycle.Event.ON_PAUSE -> lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + Lifecycle.Event.ON_DESTROY -> { + context = null + } + Lifecycle.Event.ON_CREATE -> { + // No op: ON_CREATE is handled through the bindContext function + } + Lifecycle.Event.ON_ANY -> { + // No op + } + } + } + + init { + NavigationResultChannel.completedFromSignalFor(instance) + .onEach { + execute(NavigationOperation.Close(instance = instance, silent = true)) + } + .launchIn(lifecycleScope) + } + + internal fun bindContext(context: RootContext) { + if (lifecycle.currentState == Lifecycle.State.DESTROYED) return + this.context?.lifecycle?.removeObserver(lifecycleObserver) + this.context = context + if (lifecycle.currentState == Lifecycle.State.INITIALIZED) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + this.context?.lifecycle?.addObserver(lifecycleObserver) + context.controller.rootContextRegistry.register(context) + } + + internal fun onDestroy() { + if (lifecycle.currentState == Lifecycle.State.DESTROYED) return + context?.let { context -> + this.context = null + context.lifecycle.removeObserver(lifecycleObserver) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + context.controller.rootContextRegistry.unregister(context) + context.controller.plugins.onClosed(this) + } + } + + override fun execute( + operation: NavigationOperation, + ) { + if (lifecycle.currentState == Lifecycle.State.DESTROYED) return + val context = context + if (context == null) { + EnroLog.warn("NavigationHandle with instance $instance has no context") + return + } + val wasHandledByPlatform = handleNavigationOperationForPlatform( + operation = operation, + context = context, + ) + if (wasHandledByPlatform) return + val containerContext = findContainerForOperation( + fromContext = context, + operation = operation, + ) + requireNotNull(containerContext) { + "Could not find a valid container for the navigation operation: $operation from RootContext ${context.parent}" + } + containerContext + .container + .execute(context, operation) + } +} + +/** + * Handles navigation operations using platform-specific implementations for RootContexts. + * + * This function allows RootContexts to handle certain navigation operations with platform-specific + * logic. For example, on Android where the RootContext type is Activity, this function handles + * operations like close or complete by calling Activity.finish() or Activity.setResult() respectively. + * + * @return true if the operation was handled by platform-specific logic, false otherwise + */ +internal expect fun RootNavigationHandle.handleNavigationOperationForPlatform( + operation: NavigationOperation, + context: RootContext, +): Boolean \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/handle/findContainerForOperation.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/findContainerForOperation.kt new file mode 100644 index 000000000..f15b723d9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/handle/findContainerForOperation.kt @@ -0,0 +1,112 @@ +package dev.enro.handle + +import dev.enro.NavigationContainer +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.NavigationContext +import dev.enro.context.RootContext + +internal fun findContainerForOperation( + fromContext: NavigationContext<*, *>, + operation: NavigationOperation, +): ContainerContext? { + return findContainer( + fromContext = fromContext, + predicate = { container -> container.accepts(fromContext, operation) } + ) +} + + +internal fun findContainer( + fromContext: NavigationContext<*, *>, + predicate: (NavigationContainer) -> Boolean, + alreadyVisitedContainers: Set = emptySet() +): ContainerContext? { + val visited = alreadyVisitedContainers.toMutableSet() + // TODO isVisible + val containerContext = fromContext + .getActiveChildContainers(exclude = visited) + .onEach { visited.add(it.container.key) } + .firstOrNull { + predicate(it.container) +// /*it.isVisible &&*/ it.container.accepts(instruction) + } + ?: fromContext.getChildContainers(exclude = visited) + .onEach { visited.add(it.container.key) } +// .filter { it.isVisible } + .firstOrNull { predicate(it.container) } + + if (containerContext != null) return containerContext + val parent = fromContext.parent + if (parent is NavigationContext<*, *>) { + return findContainer( + fromContext = parent, + predicate = predicate, + alreadyVisitedContainers = visited, + ) + } + return null +} + +private fun NavigationContext<*, *>.getActiveChildContainer(): ContainerContext? { + return when (this) { + is ContainerContext -> activeChild?.activeChild + is DestinationContext<*> -> activeChild + is RootContext -> activeChild + } +} + +private fun NavigationContext<*, *>.getChildContainers(): List { + return when (this) { + is ContainerContext -> children.flatMap { it.children } + is DestinationContext<*> -> children + is RootContext -> children + } +} + +/** + * Returns a list of active child containers down from a particular NavigationContext, the results in the list + * should be in descending distance from the context that this was invoked on. This means that the first result will + * be the active container for this NavigationContext, and the next result will be the active container for that container's context, + * and so on. This method also takes an "exclude" parameter, which will exclude any containers in the set from the results, + * including their children. + */ +private fun NavigationContext<*, *>.getActiveChildContainers( + exclude: Set, +): List { + var activeContainer = getActiveChildContainer() + val result = mutableListOf() + while (activeContainer != null) { + if (exclude.contains(activeContainer.container.key)) { + break + } + result.add(activeContainer) + activeContainer = activeContainer.getActiveChildContainer() + } + return result +} + +/** + * Returns a list of all child containers down from a particular NavigationContext, the results in the list + * should be in descending distance from the context that this was invoked on. This is a breadth first search, + * and doesn't take into account the active context. This method also takes an "exclude" parameter, which will exclude any + * containers in the exclude set from the results, including the children of containers which are excluded. + */ +private fun NavigationContext<*, *>.getChildContainers( + exclude: Set, +): List { + val toVisit = mutableListOf() + toVisit.addAll(getChildContainers()) + + val result = mutableListOf() + while (toVisit.isNotEmpty()) { + val next = toVisit.removeAt(0) + if (exclude.contains(next.container.key)) { + continue + } + result.add(next) + toVisit.addAll(next.getChildContainers()) + } + return result +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/AggregateNavigationInterceptor.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/AggregateNavigationInterceptor.kt new file mode 100644 index 000000000..143cdc9a6 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/AggregateNavigationInterceptor.kt @@ -0,0 +1,94 @@ +package dev.enro.interceptor + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext + +internal class AggregateNavigationInterceptor( + interceptors: List, +) : NavigationInterceptor() { + private val interceptors = interceptors.flatMap { it.flatten() } + + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Open, + ): NavigationOperation? { + return interceptors.fold(operation) { currentOperation, interceptor -> + val result = interceptor.intercept( + fromContext = fromContext, + containerContext = containerContext, + operation = currentOperation, + ) + if (result == null) return null + if (result !is NavigationOperation.Open<*>) return result + if (result.instance.id != operation.instance.id) return result + return@fold result + } + } + + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Close, + ): NavigationOperation? { + return interceptors.fold(operation) { currentOperation, interceptor -> + val result = interceptor.intercept( + fromContext = fromContext, + containerContext = containerContext, + operation = currentOperation, + ) + if (result == null) return null + if (result !is NavigationOperation.Close<*>) return result + if (result.instance.id != operation.instance.id) return result + return@fold result + } + } + + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Complete, + ): NavigationOperation? { + return interceptors.fold(operation) { currentOperation, interceptor -> + val result = interceptor.intercept( + fromContext = fromContext, + containerContext = containerContext, + operation = currentOperation, + ) + if (result == null) return null + if (result !is NavigationOperation.Complete<*>) return result + if (result.instance.id != operation.instance.id) return result + return@fold result + } + } + + override fun beforeIntercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operations: List, + ): List { + return interceptors.fold(operations) { currentOperations, interceptor -> + interceptor.beforeIntercept( + fromContext = fromContext, + containerContext = containerContext, + operations = currentOperations, + ) + } + } + + operator fun plus(other: NavigationInterceptor) : AggregateNavigationInterceptor { + return AggregateNavigationInterceptor(interceptors + other) + } + + companion object { + fun NavigationInterceptor.flatten(): List { + return when (this) { + is AggregateNavigationInterceptor -> interceptors.flatMap { it.flatten() } + is NoOpNavigationInterceptor -> emptyList() + else -> listOf(this) + } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NavigationInterceptor.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NavigationInterceptor.kt new file mode 100644 index 000000000..38d519a59 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NavigationInterceptor.kt @@ -0,0 +1,181 @@ +package dev.enro.interceptor + +import androidx.compose.runtime.Stable +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext +import dev.enro.result.NavigationResultChannel + +/** + * A NavigationInterceptor is a class that can intercept a navigation transition and + * return a modified navigation backstack that will be used as the "to" property + * of the final transition. + */ +@Stable +public abstract class NavigationInterceptor { + // Allows the entire list of operations to be intercepted before + // any individual operation is intercepted. + public open fun beforeIntercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operations: List, + ) : List { + return operations + } + + // Intercept an individual open operation + public open fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Open, + ): NavigationOperation? { return operation } + + // Intercept an individual close operation + public open fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Close, + ): NavigationOperation? { return operation } + + // Intercept an individual complete operation + public open fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Complete, + ): NavigationOperation? { return operation } + + public companion object { + public fun processOperations( + fromContext: NavigationContext, + containerContext: ContainerContext, + operations: List, + interceptor: NavigationInterceptor, + ): List { + val result = mutableListOf() + val toProcess = interceptor.beforeIntercept( + fromContext = fromContext, + containerContext = containerContext, + operations = operations, + ).toMutableList() + + val backstackById = containerContext.container.backstack.associateBy { it.id } + // Guards against infinite interceptor loops -- chiefly synthetic + // destinations whose outcomes (directly or transitively) re-open + // the same synthetic. Real navigation passes rarely exceed a few + // dozen iterations; 256 leaves room for deep aggregates without + // letting a runaway loop hang the whole controller. + val maxIterations = 256 + var iterations = 0 + while (toProcess.isNotEmpty()) { + if (++iterations > maxIterations) { + error( + "Navigation interceptor processing exceeded $maxIterations iterations. " + + "This usually means a synthetic destination's outcome opens (directly or " + + "transitively) the same synthetic again, or an interceptor is rewriting " + + "an operation back to itself. Most recent operation: ${toProcess.first()}" + ) + } + val operation = toProcess.removeAt(0) + val intercepted = when (operation) { + // If we're getting an Open operation and the backstack already contains + // an instance with that id, we skip running the interceptor because this + // indicates that the goal of the operation is to re-order the backstack + is NavigationOperation.Open<*> -> when { + backstackById.containsKey(operation.instance.id) -> operation + else -> interceptor.intercept( + fromContext = fromContext, + containerContext = containerContext, + operation = operation, + ) + } + is NavigationOperation.Close<*> -> interceptor.intercept( + fromContext = fromContext, + containerContext = containerContext, + operation = operation, + ) + is NavigationOperation.Complete<*> -> interceptor.intercept( + fromContext = fromContext, + containerContext = containerContext, + operation = operation, + ) + else -> operation + } + when { + intercepted == null -> { + // Operation was consumed by interceptor, skip it + } + intercepted === operation -> { + // Same operation returned, add to result + result.add(operation) + } + // The AggregateOperation branch must come BEFORE the + // RootOperation branch -- on K/Native, `is RootOperation` + // currently (incorrectly) returns true for + // AggregateOperation instances even though + // AggregateOperation extends NavigationOperation directly, + // not RootOperation. Checking AggregateOperation first + // ensures the correct branch runs everywhere. + intercepted is NavigationOperation.AggregateOperation -> { + // if we get an aggregate operation that contains the SAME operation we started with, + // that means we still want to count that operation as being added to the result, + // and we don't want to process it again + val filtered = intercepted.operations.filter { it != operation } + if (filtered.size != intercepted.operations.size) { + result.add(operation) + } + // Different operation returned, add to processing queue + toProcess.addAll(0, filtered) + } + intercepted is NavigationOperation.RootOperation -> { + // Different operation returned, add to processing queue + toProcess.add(0, intercepted) + } + } + } + + val openedIds = mutableSetOf() + val closedIds = mutableSetOf() + val completedResultIds = mutableSetOf() + val filteredResult = result.mapNotNull { + when (it) { + is NavigationOperation.Close -> { + closedIds.add(it.instance.id) + // We don't filter Close operations whose instance isn't on + // the backstack — synthetic destinations legitimately + // dispatch a Close for an instance that never landed in + // any backstack so their result channel still fires. + } + is NavigationOperation.Complete -> { + closedIds.add(it.instance.id) + completedResultIds.add(it.instance.metadata.get(NavigationResultChannel.ResultIdKey)) + // Same reasoning as Close above — synthetics complete + // off-backstack instances; the result-channel side + // effect is the important bit and must still fire. + } + is NavigationOperation.Open -> { + openedIds.add(it.instance.id) + } + is NavigationOperation.SideEffect -> { + // No-op + } + } + return@mapNotNull it + } + + // Add all non-opened operations as Open operations at the start of the list + val updatedBackstack = containerContext.container.backstack + .mapNotNull { + val resultId = it.metadata.get(NavigationResultChannel.ResultIdKey) + if (resultId != null && completedResultIds.contains(resultId)) { + return@mapNotNull null + } + if (openedIds.contains(it.id)) return@mapNotNull null + if (closedIds.contains(it.id)) return@mapNotNull null + return@mapNotNull NavigationOperation.Open(it) + }.plus(filteredResult) + + return updatedBackstack + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NoOpNavigationInterceptor.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NoOpNavigationInterceptor.kt new file mode 100644 index 000000000..e29c01539 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NoOpNavigationInterceptor.kt @@ -0,0 +1,6 @@ +package dev.enro.interceptor + +/** + * A no-op interceptor that does nothing. + */ +public object NoOpNavigationInterceptor : NavigationInterceptor() \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/InterceptorBuilderResult.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/InterceptorBuilderResult.kt new file mode 100644 index 000000000..ba86a58d5 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/InterceptorBuilderResult.kt @@ -0,0 +1,40 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationOperation + +/** + * Represents the action to take when intercepting a navigation transition. + */ +@PublishedApi +internal sealed class InterceptorBuilderResult : RuntimeException() { + /** + * Continue with the original navigation transition. + */ + class Continue : InterceptorBuilderResult() + + /** + * Cancel the navigation transition entirely. + */ + class Cancel : InterceptorBuilderResult() + + /** + * Cancel the navigation transition and execute a block of code after the transition is cancelled. + */ + class CancelAnd(val block: () -> Unit) : InterceptorBuilderResult() + + /** + * Replace the current transition with a modified one. + */ + class ReplaceWith( + val operation: NavigationOperation, + ) : InterceptorBuilderResult() +} + +internal fun runForInterceptorBuilderResult(block: () -> Unit): InterceptorBuilderResult { + return try { + block() + return InterceptorBuilderResult.Continue() + } catch (interceptorResult: InterceptorBuilderResult) { + interceptorResult + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/NavigationInterceptorBuilder.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/NavigationInterceptorBuilder.kt new file mode 100644 index 000000000..bb991201d --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/NavigationInterceptorBuilder.kt @@ -0,0 +1,174 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext +import dev.enro.interceptor.AggregateNavigationInterceptor +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.interceptor.NoOpNavigationInterceptor +import kotlin.reflect.KClass + +/** + * A builder class that provides a DSL for creating NavigationInterceptors. + * + * Example usage: + * ``` + * val interceptor = navigationInterceptor { + * onClosed { key -> + * // Handle when MyNavigationKey is closed + * continueWith() + * } + * onOpened { key -> + * // Handle when MyNavigationKey is opened + * continueWith() + * } + * } + * ``` + */ +public class NavigationInterceptorBuilder internal constructor() { + + @PublishedApi + internal val interceptors: MutableList = mutableListOf() + + public inline fun onOpened( + noinline block: OnNavigationKeyOpenedScope.() -> Unit, + ) { + onOpened(KeyType::class, block) + } + + /** + * Register an interceptor that will be called when a navigation key of KeyType is opened. + */ + public fun onOpened( + keyType: KClass, + block: OnNavigationKeyOpenedScope.() -> Unit, + ) { + interceptors += object : NavigationInterceptor() { + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Open, + ): NavigationOperation? { + val instance = operation.instance + if (!keyType.isInstance(instance.key)) return operation + @Suppress("UNCHECKED_CAST") + instance as NavigationKey.Instance + val result = runForInterceptorBuilderResult { + OnNavigationKeyOpenedScope( + instance = instance, + fromContext = fromContext, + containerContext = containerContext, + ).block() + } + return when (result) { + is InterceptorBuilderResult.Cancel -> null + is InterceptorBuilderResult.CancelAnd -> NavigationOperation.SideEffect(result.block) + is InterceptorBuilderResult.Continue -> operation + is InterceptorBuilderResult.ReplaceWith -> result.operation + } + } + } + } + + /** + * Register an interceptor that will be called when a navigation key of KeyType is closed. + */ + public inline fun onClosed( + noinline block: OnNavigationKeyClosedScope.() -> Nothing, + ) { + onClosed(KeyType::class, block) + } + + public fun onClosed( + keyType: KClass, + block: OnNavigationKeyClosedScope.() -> Unit, + ) { + interceptors += object : NavigationInterceptor() { + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Close, + ): NavigationOperation? { + val instance = operation.instance + if (!keyType.isInstance(instance.key)) return operation + @Suppress("UNCHECKED_CAST") + instance as NavigationKey.Instance + val result = runForInterceptorBuilderResult { + OnNavigationKeyClosedScope( + isSilent = operation.silent, + instance = instance, + fromContext = fromContext, + containerContext = containerContext, + ).block() + } + + return when (result) { + is InterceptorBuilderResult.Cancel -> null + is InterceptorBuilderResult.CancelAnd -> NavigationOperation.SideEffect(result.block) + is InterceptorBuilderResult.Continue -> operation + is InterceptorBuilderResult.ReplaceWith -> result.operation + } + } + } + } + + /** + * Register an interceptor that will be called when a navigation key of KeyType is completed + * (either opened or closed). + */ + public inline fun onCompleted( + noinline block: OnNavigationKeyCompletedScope.() -> Unit, + ) { + onCompleted(KeyType::class, block) + } + + public fun onCompleted( + keyType: KClass, + block: OnNavigationKeyCompletedScope.() -> Unit, + ) { + interceptors += object : NavigationInterceptor() { + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Complete, + ): NavigationOperation? { + val instance = operation.instance + if (!keyType.isInstance(instance.key)) return operation + @Suppress("UNCHECKED_CAST") + instance as NavigationKey.Instance + val result = runForInterceptorBuilderResult { + OnNavigationKeyCompletedScope( + instance = instance, + data = operation.result, + fromContext = fromContext, + containerContext = containerContext, + ).block() + } + return when (result) { + is InterceptorBuilderResult.Cancel -> null + is InterceptorBuilderResult.CancelAnd -> NavigationOperation.SideEffect(result.block) + is InterceptorBuilderResult.Continue -> operation + is InterceptorBuilderResult.ReplaceWith -> result.operation + } + } + } + } + + internal fun build(): NavigationInterceptor { + return when (interceptors.size) { + 0 -> NoOpNavigationInterceptor + 1 -> interceptors.first() + else -> AggregateNavigationInterceptor(interceptors) + } + } +} + +/** + * Creates a NavigationInterceptor using the provided DSL block. + */ +public fun navigationInterceptor( + block: NavigationInterceptorBuilder.() -> Unit, +): NavigationInterceptor { + return NavigationInterceptorBuilder().apply(block).build() +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyClosedScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyClosedScope.kt new file mode 100644 index 000000000..92be58f18 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyClosedScope.kt @@ -0,0 +1,49 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationBackstack +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext + +/** + * Scope for handling when a navigation key is closed. + */ +public class OnNavigationKeyClosedScope @PublishedApi internal constructor( + public val isSilent: Boolean, + public val instance: NavigationKey.Instance, + public val fromContext: NavigationContext, + public val containerContext: ContainerContext, +) { + public val key: K get() = instance.key + + /** + * The current backstack of the container the operation is being applied to. + * Read this to make decisions based on what's already on the stack — e.g. + * to find entries above the one being closed and cascade additional closes + * into an `AggregateOperation`. + */ + public val backstack: NavigationBackstack get() = containerContext.container.backstack + + /** + * Continue with the navigation as normal. + */ + public fun continueWithClose(): Nothing = throw InterceptorBuilderResult.Continue() + + /** + * Cancel the navigation entirely. + */ + public fun cancel(): Nothing = throw InterceptorBuilderResult.Cancel() + + /** + * Cancel the navigation and execute the provided block after the navigation is canceled. + */ + public fun cancelAnd(block: () -> Unit): Nothing = + throw InterceptorBuilderResult.CancelAnd(block) + + /** + * Replace the current transition with a modified one. + */ + public fun replaceWith(operation: NavigationOperation): Nothing = + throw InterceptorBuilderResult.ReplaceWith(operation) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyCompletedScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyCompletedScope.kt new file mode 100644 index 000000000..588a0ca33 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyCompletedScope.kt @@ -0,0 +1,71 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationBackstack +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.ContainerContext +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel + +/** + * Scope for handling when a navigation key is completed (either opened or closed). + */ +public class OnNavigationKeyCompletedScope @PublishedApi internal constructor( + public val instance: NavigationKey.Instance, + internal val data: Any?, + public val fromContext: NavigationContext, + public val containerContext: ContainerContext, +) { + /** + * The current backstack of the container the operation is being applied to. + */ + public val backstack: NavigationBackstack get() = containerContext.container.backstack + + public val OnNavigationKeyCompletedScope>.result: R get() { + require(data != null) { + "Incorrect type, but got null" + } + @Suppress("UNCHECKED_CAST") + return data as R + } + + /** + * Continue with the navigation as normal. + */ + public fun continueWithComplete(): Nothing = + throw InterceptorBuilderResult.Continue() + + /** + * Deliver the "complete" result, but don't actually close the screen + */ + public fun deliverResultOnly(): Nothing { + cancelAnd { + NavigationResultChannel.registerResult( + NavigationResult.Completed( + instance, + data, + ) + ) + } + } + + /** + * Cancel the navigation entirely. + */ + public fun cancel(): Nothing = + throw InterceptorBuilderResult.Cancel() + + /** + * Cancel the navigation and execute the provided block after the navigation is canceled. + */ + public fun cancelAnd(block: () -> Unit): Nothing = + throw InterceptorBuilderResult.CancelAnd(block) + + /** + * Replace the current operation with a different operation. + */ + public fun replaceWith(operation: NavigationOperation): Nothing = + throw InterceptorBuilderResult.ReplaceWith(operation) + +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyOpenedScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyOpenedScope.kt new file mode 100644 index 000000000..52cf5e41a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyOpenedScope.kt @@ -0,0 +1,59 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationBackstack +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.asInstance +import dev.enro.context.ContainerContext + +/** + * Scope for handling when a navigation key is opened. + */ +public class OnNavigationKeyOpenedScope @PublishedApi internal constructor( + public val instance: NavigationKey.Instance, + public val fromContext: NavigationContext, + public val containerContext: ContainerContext, +) { + public val key: T get() = instance.key + + /** + * The current backstack of the container the operation is being applied to. + * Read this to make decisions based on what's already on the stack — e.g. + * to find an anchor entry and rewrite the operation into a `SetBackstack` + * or an `AggregateOperation`. + */ + public val backstack: NavigationBackstack get() = containerContext.container.backstack + + /** + * Continue with the navigation as normal. + */ + public fun continueWithOpen(): Nothing = throw InterceptorBuilderResult.Continue() + + /** + * Cancel the navigation entirely. + */ + public fun cancel(): Nothing = throw InterceptorBuilderResult.Cancel() + + /** + * Cancel the navigation and execute the provided block after the navigation is canceled. + */ + public fun cancelAnd(block: () -> Unit): Nothing = throw InterceptorBuilderResult.CancelAnd(block) + + public fun replaceWith(key: NavigationKey): Nothing = + replaceWith(instance = key.asInstance()) + + public fun replaceWith(key: NavigationKey.WithMetadata<*>): Nothing = + replaceWith(instance = key.asInstance()) + + public fun replaceWith(instance: NavigationKey.Instance<*>): Nothing = + throw InterceptorBuilderResult.ReplaceWith(NavigationOperation.Open(instance)) + + /** + * Replace this Open with an arbitrary [NavigationOperation] — for example + * an [NavigationOperation.AggregateOperation] that closes some existing + * entries before opening the new one, or a `SetBackstack` transition. + */ + public fun replaceWith(operation: NavigationOperation): Nothing = + throw InterceptorBuilderResult.ReplaceWith(operation) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getBackstackFromPath.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getBackstackFromPath.kt new file mode 100644 index 000000000..52b593dc6 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getBackstackFromPath.kt @@ -0,0 +1,30 @@ +package dev.enro.path + +import dev.enro.EnroController +import dev.enro.NavigationBackstack +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.asInstance +import dev.enro.backstackOf + +/** + * Resolves [path] to a single-entry [NavigationBackstack] using this controller's + * registered path bindings. Returns `null` if no binding matches the path. + * + * Useful for deriving an initial backstack from a deep-link URL (browser address + * bar on cold load, Android intent extra, iOS universal link payload, etc.). + * + * For most callers, the resulting backstack will be passed to a navigation + * container as its initial state, e.g.: + * + * ```kotlin + * val container = rememberNavigationContainer( + * backstack = controller.getBackstackFromPath(deeplinkUrl) + * ?: backstackOf(Home.asInstance()), + * ) + * ``` + */ +@ExperimentalEnroApi +public fun EnroController.getBackstackFromPath(path: String): NavigationBackstack? { + val key = runCatching { getNavigationKeyFromPath(path) }.getOrNull() ?: return null + return backstackOf(key.asInstance()) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getNavigationKeyFromPath.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getNavigationKeyFromPath.kt new file mode 100644 index 000000000..b96a2f3c4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getNavigationKeyFromPath.kt @@ -0,0 +1,17 @@ +package dev.enro.path + +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi + +/** + * Looks up a path binding registered on this controller for [path] and returns the + * resulting [NavigationKey], or `null` if no binding matches. + */ +@ExperimentalEnroApi +public fun EnroController.getNavigationKeyFromPath(path: String): NavigationKey? { + val parsedPath = ParsedPath.fromString(path) + @Suppress("UNCHECKED_CAST") + val binding = paths.getPathBinding(parsedPath) as? NavigationPathBinding + return binding?.fromPath(parsedPath) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getPathFromNavigationKey.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getPathFromNavigationKey.kt new file mode 100644 index 000000000..60840e6f4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/EnroController.getPathFromNavigationKey.kt @@ -0,0 +1,15 @@ +package dev.enro.path + +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi + +/** + * Returns a URL-style path string for [key] using the first registered binding whose + * keyType matches, or `null` if no binding exists for [key]'s type. + */ +@ExperimentalEnroApi +public fun EnroController.getPathFromNavigationKey(key: NavigationKey): String? { + val binding = paths.getPathBindingForKey(key) ?: return null + return binding.toPath(key) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getNavigationKeyFromPath.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getNavigationKeyFromPath.kt new file mode 100644 index 000000000..6d3c1165b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getNavigationKeyFromPath.kt @@ -0,0 +1,12 @@ +package dev.enro.path + +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.context.NavigationContext + +@ExperimentalEnroApi +public fun NavigationContext<*, *>.getNavigationKeyFromPath( + path: String, +): NavigationKey? { + return controller.getNavigationKeyFromPath(path) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getPathFromNavigationKey.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getPathFromNavigationKey.kt new file mode 100644 index 000000000..af2aee0f3 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getPathFromNavigationKey.kt @@ -0,0 +1,12 @@ +package dev.enro.path + +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.context.NavigationContext + +@ExperimentalEnroApi +public fun NavigationContext<*, *>.getPathFromNavigationKey( + key: NavigationKey, +): String? { + return controller.getPathFromNavigationKey(key) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationHandle.getNavigationKeyFromPath.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationHandle.getNavigationKeyFromPath.kt new file mode 100644 index 000000000..1bdfc8418 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationHandle.getNavigationKeyFromPath.kt @@ -0,0 +1,27 @@ +package dev.enro.path + +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi + +/** + * Resolves [path] against the controller's registered `@NavigationPath` + * bindings and returns the matching [NavigationKey], or `null` if no + * binding matches. + * + * Use this when a destination needs to react to an external URL / + * deep-link string — e.g. when handling a clicked notification, a + * `data:` intent, or a web-routed message. The resolved key can be + * handed to [open] / [closeAndReplaceWith] like any other key. + * + * The handle receiver scopes the lookup to the currently-installed + * [EnroController]; if you're outside a destination, use the controller + * extensions directly. + */ +@ExperimentalEnroApi +public fun NavigationHandle<*>.getNavigationKeyFromPath( + path: String, +): NavigationKey? { + return EnroController.requireInstance().getNavigationKeyFromPath(path) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationHandle.getPathFromNavigationKey.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationHandle.getPathFromNavigationKey.kt new file mode 100644 index 000000000..3d505645f --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationHandle.getPathFromNavigationKey.kt @@ -0,0 +1,22 @@ +package dev.enro.path + +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi + +/** + * Reverse of [getNavigationKeyFromPath]: serialises [key] back to its + * registered path string, or returns `null` if [key]'s type has no + * `@NavigationPath` binding registered on the controller. + * + * Use this when you need to emit a URL for the current navigation state + * — e.g. to update the browser URL bar, build a share link, or persist a + * deep-link target. + */ +@ExperimentalEnroApi +public fun NavigationHandle<*>.getPathFromNavigationKey( + key: NavigationKey, +): String? { + return EnroController.requireInstance().getPathFromNavigationKey(key) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/platform/EnroLog.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/platform/EnroLog.kt new file mode 100644 index 000000000..8d9f78ad6 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/platform/EnroLog.kt @@ -0,0 +1,9 @@ +package dev.enro.platform + +@PublishedApi +internal expect object EnroLog { + fun debug(message: String) + fun warn(message: String) + fun error(message: String) + fun error(message: String, throwable: Throwable) +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/platform/EnroPlatform.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/platform/EnroPlatform.kt new file mode 100644 index 000000000..7b5a89407 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/platform/EnroPlatform.kt @@ -0,0 +1,3 @@ +package dev.enro.platform + +internal interface EnroPlatform diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/platform/platformNavigationModule.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/platform/platformNavigationModule.kt new file mode 100644 index 000000000..46ded1dd5 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/platform/platformNavigationModule.kt @@ -0,0 +1,5 @@ +package dev.enro.platform + +import dev.enro.controller.NavigationModule + +internal expect val platformNavigationModule: NavigationModule diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/plugin/NavigationPlugin.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/plugin/NavigationPlugin.kt new file mode 100644 index 000000000..45ecc065b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/plugin/NavigationPlugin.kt @@ -0,0 +1,39 @@ +package dev.enro.plugin + +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.ui.NavigationDestination + +public abstract class NavigationPlugin { + public open fun onAttached(controller: EnroController) {} + public open fun onDetached(controller: EnroController) {} + + public open fun onOpened(navigationHandle: NavigationHandle<*>) {} + public open fun onActive(navigationHandle: NavigationHandle<*>) {} + public open fun onClosed(navigationHandle: NavigationHandle<*>) {} + + /** + * Called when a navigation destination is created, allowing plugins to modify its metadata + * before rendering. + * + * Plugins can use this to alter how destinations are rendered by adding or overriding metadata. + * For example, a compatibility plugin might change a destination to render as an overlay by + * setting the "directOverlay" metadata. + * + * Setting a value in the additionalMetadata map to null will remove it from the destination's + * metadata. + * + * **Warning:** This is an advanced API. Modifying metadata incorrectly can break the way that + * destinations are rendered. + * + * @param destination The newly created navigation destination + * @param additionalMetadata A mutable map for adding/modifying metadata. Values here override + * existing destination metadata with the same key. + */ + @AdvancedEnroApi + public open fun onDestinationCreated( + destination: NavigationDestination<*>, + additionalMetadata: MutableMap, + ) {} +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/NavigationResult.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/NavigationResult.kt new file mode 100644 index 000000000..2d9dbf780 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/NavigationResult.kt @@ -0,0 +1,33 @@ +package dev.enro.result + +import dev.enro.NavigationKey + +public sealed class NavigationResult { + internal abstract val instance: NavigationKey.Instance + + public class Closed( + override val instance: NavigationKey.Instance + ) : NavigationResult() + + public class Delegated( + override val instance: NavigationKey.Instance + ) : NavigationResult() + + public class Completed( + @PublishedApi + override val instance: NavigationKey.Instance, + + @PublishedApi + internal val data: Any? + ) : NavigationResult() { + public companion object { + public val Completed>.result: R get() { + require(data != null) { + "Incorrect type, but got null" + } + @Suppress("UNCHECKED_CAST") + return data as R + } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/NavigationResultChannel.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/NavigationResultChannel.kt new file mode 100644 index 000000000..6b7bb8b36 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/NavigationResultChannel.kt @@ -0,0 +1,190 @@ +package dev.enro.result + +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.asInstance +import dev.enro.platform.EnroLog +import dev.enro.result.NavigationResult.Completed.Companion.result +import dev.enro.result.NavigationResultChannel.ResultIdKey +import dev.enro.withMetadata +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.take +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmName +import kotlin.reflect.KClass + +public class NavigationResultChannel @PublishedApi internal constructor( + @PublishedApi + internal val id: Id, + @PublishedApi + internal val navigationHandle: NavigationHandle<*>, + @PublishedApi + internal val onClosed: NavigationResultScope.() -> Unit, + @PublishedApi + internal val onCompleted: NavigationResultScope.(Result) -> Unit, +) { + @Serializable + public data class Id( + val ownerId: String, + val resultId: String + ) + + internal object ResultIdKey : NavigationKey.MetadataKey(null) + + // @PublishedApi + public companion object { + // @PublishedApi + public val pendingResults: MutableStateFlow>> = MutableStateFlow(emptyMap()) + + @PublishedApi + internal val activeChannels: MutableSet = mutableSetOf() + + @PublishedApi + internal inline fun observe( + scope: CoroutineScope, + resultChannel: NavigationResultChannel, + ): Job { + return observe(T::class, scope, resultChannel) + } + + @PublishedApi + internal fun observe( + resultType: KClass, + scope: CoroutineScope, + resultChannel: NavigationResultChannel, + ): Job { + return pendingResults + .onStart { + require(!activeChannels.contains(resultChannel.id)) { + "NavigationResultChannel with id ${resultChannel.id} is already being observed" + } + activeChannels.add(resultChannel.id) + } + .map { pendingResults -> + resultChannel.id to pendingResults[resultChannel.id] + } + .distinctUntilChanged() + .onEach { (id, result) -> + if (result == null) return@onEach + + when (result) { + is NavigationResult.Delegated -> {} + is NavigationResult.Closed -> resultChannel.onClosed(NavigationResultScope(result.instance)) + is NavigationResult.Completed -> { + if (resultType == Unit::class) { + resultChannel.onCompleted(NavigationResultScope(result.instance), Unit as T) + } else { + @Suppress("UNCHECKED_CAST") + result as NavigationResult.Completed> + resultChannel.onCompleted(NavigationResultScope(result.instance), result.result) + } + } + } + pendingResults.value -= id + } + .onCompletion { + activeChannels.remove(resultChannel.id) + } + .launchIn(scope) + } + + internal fun registerResult( + result: NavigationResult, + ) { + val resultId = result.instance.metadata.get(ResultIdKey) + // If the NavigationKey.Instance does not have a value for ResultIdKey, + // then there is no NavigationResultChannel that is waiting for results + // from that NavigationKey.Instance, and we won't register the result + if (resultId == null) { + return + } + pendingResults.value += resultId to result + } + + internal fun hasCompletedResultFor( + instance: NavigationKey.Instance<*>, + ): Boolean { + val resultId = instance.metadata.get(ResultIdKey) + val pendingResults = pendingResults.value + return resultId != null && pendingResults[resultId] is NavigationResult.Completed<*> + } + + // Returns a flow that will emit a single Unit value whenever a + // NavigationResult.Completed is registered for the resultId associated with the + // NavigationKey.Instance passed as a parameter, but only if the result did not + // come from the instance itself (i.e. it launched another destination + // as completeFrom, and that destination returned a result) + internal fun completedFromSignalFor( + instance: NavigationKey.Instance<*>, + ): Flow { + val resultId = instance.metadata.get(ResultIdKey) + if (resultId == null) return emptyFlow() + return pendingResults + .mapNotNull { pendingResults -> + pendingResults[resultId] + } + .distinctUntilChanged() + .filterIsInstance>() + .distinctUntilChanged() + .filter { + it.instance.id != instance.id + } + .map { } + .take(1) + } + } +} + +public fun NavigationResultChannel.open(key: NavigationKey.WithResult) { + navigationHandle.execute( + operation = NavigationOperation.Open( + instance = key.withMetadata(ResultIdKey, id).asInstance() + ) + ) +} + +public fun NavigationResultChannel.open(key: NavigationKey.WithMetadata>) { + navigationHandle.execute( + operation = NavigationOperation.Open( + instance = key.withMetadata(ResultIdKey, id).asInstance() + ) + ) +} + +@JvmName("openAny") +public fun NavigationResultChannel.open(key: NavigationKey) { + navigationHandle.execute( + operation = NavigationOperation.Open( + instance = key.withMetadata(ResultIdKey, id).asInstance() + ) + ) +} + +@JvmName("openAny") +public fun NavigationResultChannel.open(key: NavigationKey.WithMetadata<*>) { + navigationHandle.execute( + operation = NavigationOperation.Open( + instance = key.withMetadata(ResultIdKey, id).asInstance() + ) + ) +} + +public class NavigationResultScope @PublishedApi internal constructor( + public val instance: NavigationKey.Instance, +) { + public val key: Key get() = instance.key +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowResultManager.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowResultManager.kt new file mode 100644 index 000000000..8682e6ef8 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowResultManager.kt @@ -0,0 +1,182 @@ +package dev.enro.result.flow + +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.SavedStateHandle +import androidx.savedstate.SavedState +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.platform.EnroLog +import dev.enro.result.flow.FlowResultManager.FlowStepResult +import dev.enro.serialization.unwrapForSerialization +import dev.enro.serialization.wrapForSerialization +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlin.uuid.Uuid + +public class FlowResultManager private constructor( + private val savedStateHandle: SavedStateHandle, +) { + private val results = mutableStateMapOf, FlowStepResult>() + .also { results -> + savedStateHandle.setSavedStateProvider("results") { + encodeFlowResults(results) + } + val savedResults = savedStateHandle.get("results") ?: return@also + val restoredResults = decodeFlowResults(savedResults) + results.putAll(restoredResults) + } + + private val defaultsInitialised = mutableSetOf>() + .also { defaultsInitialised -> + savedStateHandle.setSavedStateProvider("defaults") { + encodeToSavedState( + serializer = ListSerializer(String.serializer()), + value = defaultsInitialised.toList().map { it.value }, + configuration = EnroController.savedStateConfiguration, + ) + } + val savedDefaults = savedStateHandle.get("defaults") ?: return@also + val restoredDefaults = decodeFromSavedState( + savedState = savedDefaults, + deserializer = ListSerializer(String.serializer()), + configuration = EnroController.savedStateConfiguration, + ) + defaultsInitialised.addAll(restoredDefaults.map { FlowStep.Id(it) }) + } + + @PublishedApi + internal val suspendingResults: SnapshotStateMap = mutableStateMapOf() + + public fun get(step: FlowStep): T? { + val completedStep = results[step.id] ?: return null + val result = completedStep.result as? T + if (step.dependsOn != completedStep.dependsOn) { + results.remove(step.id) + return null + } + return result + } + + public fun set(step: FlowStep, result: T) { + results[step.id] = FlowStepResult( + id = step.id, + result = result, + dependsOn = step.dependsOn, + instanceId = Uuid.random().toString(), + ) + } + + public fun setDefault(step: FlowStep, result: T) { + if (defaultsInitialised.contains(step.id)) return + defaultsInitialised.add(step.id) + set(step, result) + } + + public fun clear(id: FlowStep.Id<*>) { + results.remove(id) + } + + public fun getResultInstanceId(id: FlowStep.Id<*>): String? { + return results[id]?.instanceId + } + + @Serializable + @PublishedApi + internal class FlowStepResult( + val id: FlowStep.Id<*>, + val result: T, + val dependsOn: Long, + // instanceId is should be set to a random UUID whenever a new result is published for a + // FlowStep. It is used by FlowStepOptions.AlwaysAfterPrevious to determine if + // the previous step has published a new result, even when the result itself is the same + // as the previous result + val instanceId: String, + ) + + @PublishedApi + internal class SuspendingStepResult( + val id: FlowStep.Id<*>, + val result: Deferred, + val job: Job, + val dependsOn: Long, + ) + + public companion object { + private object FlowResultManagerKey : NavigationKey.TransientMetadataKey(null) + + public fun create( + navigationHandle: NavigationHandle<*>, + ): FlowResultManager { + return navigationHandle.instance.metadata.get(FlowResultManagerKey) + ?: FlowResultManager(navigationHandle.savedStateHandle).also { + navigationHandle.instance.metadata.set(FlowResultManagerKey, it) + } + } + + public fun get( + navigationHandle: NavigationHandle<*>, + ): FlowResultManager? { + return navigationHandle.instance.metadata.get(FlowResultManagerKey) + } + } +} + +private fun encodeFlowResults( + results: Map, FlowResultManager.FlowStepResult>, +): SavedState { + // TODO: Provide an option to set "filterMissingSerializers" to true, which might be useful in some cases. + // it's probably also useful to clear the "forward" steps of the missing serializers, so that if something + // is skipped, then when the restore happens, we go straight back to that step, rather than staying on + // the current step and then going back to that step when the current step is completed + val filterMissingSerializers = false + val wrappedResults = results.values + .map { + FlowStepResult( + id = it.id, + result = it.result.wrapForSerialization(), + dependsOn = it.dependsOn, + instanceId = it.instanceId, + ) + } + .let { wrappedResults -> + if (!filterMissingSerializers) return@let wrappedResults + wrappedResults.filter { + val serializer = + EnroController.savedStateConfiguration.serializersModule.getPolymorphic(Any::class, it.result) + if (serializer == null) { + EnroLog.error("Could not find serializer for ${it.result::class.qualifiedName}, result will not be saved/restored in navigation flow") + } + return@filter serializer != null + } + } + + return encodeToSavedState( + serializer = ListSerializer(FlowStepResult.serializer(PolymorphicSerializer(Any::class))), + value = wrappedResults, + configuration = EnroController.savedStateConfiguration, + ) +} + +private fun decodeFlowResults(savedState: SavedState): Map, FlowStepResult> { + val restoredResults = decodeFromSavedState( + savedState = savedState, + deserializer = ListSerializer(FlowStepResult.serializer(PolymorphicSerializer(Any::class))), + configuration = EnroController.savedStateConfiguration, + ) + return restoredResults.associate { + it.id to FlowStepResult( + id = it.id, + result = it.result.unwrapForSerialization(), + dependsOn = it.dependsOn, + instanceId = it.instanceId, + ) + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStep.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStep.kt new file mode 100644 index 000000000..50d611927 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStep.kt @@ -0,0 +1,98 @@ +package dev.enro.result.flow + +import dev.enro.NavigationKey +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +@ConsistentCopyVisibility +public data class FlowStep private constructor( + @PublishedApi internal val id: Id, + @PublishedApi internal val key: @Contextual NavigationKey, + @PublishedApi internal val metadata: NavigationKey.Metadata, + @PublishedApi internal val dependsOn: Long, + @PublishedApi internal val options: Set, +) { + internal constructor( + id: Id, + key: NavigationKey.WithMetadata, + dependsOn: List, + options: Set, + ) : this( + id = id, + key = key.key, + metadata = key.metadata, + dependsOn = dependsOn.hashForDependsOn(), + options = options, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as FlowStep<*> + + if (id != other.id) return false + if (key != other.key) return false + if (metadata != other.metadata) return false + if (dependsOn != other.dependsOn) return false + if (options != other.options) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + key.hashCode() + result = 31 * result + metadata.hashCode() + result = 31 * result + dependsOn.hashCode() + result = 31 * result + options.hashCode() + return result + } + + public companion object {} + + @Serializable + public class Id @PublishedApi internal constructor( + @PublishedApi internal val value: String, + ) { + internal object MetadataKey : NavigationKey.MetadataKey(null) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Id<*> + + return value == other.value + } + + override fun hashCode(): Int { + return value.hashCode() + } + + override fun toString(): String { + return "FlowStep.Id(value=$value)" + } + } +} + +public val NavigationKey.Instance.flowStepId: FlowStep.Id? + get() { + val flowStepId = metadata.get(FlowStep.Id.MetadataKey) ?: return null + return FlowStep.Id(flowStepId) + } + +public inline fun flowStepId(): FlowStep.Id { + val provider = object : FlowStepIdProvider(T::class) { + override val id: String get() = this::class.toString().removePrefix("class ") + } + return FlowStep.Id(provider.id) +} + +public abstract class FlowStepIdProvider @PublishedApi internal constructor( + @PublishedApi internal val type: KClass +) { + public abstract val id: String +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepDefinition.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepDefinition.kt new file mode 100644 index 000000000..257b50a82 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepDefinition.kt @@ -0,0 +1,250 @@ +package dev.enro.result.flow + +import dev.enro.NavigationKey +import kotlin.jvm.JvmName +import kotlin.reflect.KClass + +public abstract class FlowStepDefinition @PublishedApi internal constructor() { + public abstract val keyWithMetadata: NavigationKey.WithMetadata + public abstract val result: KClass + + @PublishedApi + internal var providedId: FlowStep.Id? = null + @PublishedApi + internal var defaultResult: R? = null + private val dependencies = mutableListOf() + private val configuration = mutableSetOf() + + @PublishedApi + internal fun buildStep( + navigationFlowScope: NavigationFlowScope, + ): FlowStep { + val steps = navigationFlowScope.steps + val id = when(val providedId = providedId) { + null -> { + val baseId = this::class.qualifiedName ?: this::class.toString() + val count = steps.count { it.id.value.startsWith(baseId) } + FlowStep.Id("$baseId@$count") + } + else -> { + require(steps.none { it.id == providedId }) { + "Step with id $providedId already exists in the flow." + } + providedId + } + } + if (configuration.contains(FlowStepOptions.AlwaysAfterPrevious)) { + steps.lastOrNull()?.let { + val previousResult = navigationFlowScope.resultManager.getResultInstanceId(it.id) + dependencies.add(previousResult) + } + } + return FlowStep( + id = id, + key = keyWithMetadata, + dependsOn = dependencies, + options = configuration, + ) + } + + public class ConfigurationScope( + @PublishedApi internal val definition: FlowStepDefinition, + ) { + public val key: T get() = definition.keyWithMetadata.key + + /** + * Sets an exact FlowStep.Id for this flow step. FlowStep.Id instances must be unique within a flow, + * and re-using a FlowStep.Id will result in an exception being thrown when the flow is built. + * + * Setting a FlowStep.Id is useful when you want to get a reference to a flow step using + * [NavigationFlow.getStep] or [NavigationFlow.requireStep], to perform actions on the step, + * such as [FlowStepReference.editStep] or [FlowStepReference.clearResult]. + * + * FlowStep.Id instances can be created using the [flowStepId] function. + * + * Example: + * + * ``` + * + * val firstStepId = flowStepId() + * val secondStepId = flowStepId() + * + * val flow = registerForFlowResult { + * val firstResult = open(FirstStepScreen) { + * id(firstStepId) + * } + * val secondResult = open(SecondStepScreen) { + * id(secondStepId) + * } + * ... + * } + * + * fun onEditFirstStep() { + * flow.getStep(firstStepId)?.editStep() + * } + * + * fun onEditSecondStep() { + * flow.getStep(secondStepId)?.editStep() + * } + * + * ``` + * + */ + public fun id(id: FlowStep.Id) { + definition.providedId = id + } + + /** + * Configure this step to be considered a "transient" step in the flow. This means that the step will be: + * a) skipped when navigating back + * b) skipped when navigating forward if the step already has a result, and the [dependsOn] values have not changed. + * + * This can be useful for displaying confirmation steps as part of the flow. For example, when a user completes a step of + * the flow, you might want to confirm the user's action before proceeding to the next step. The confirmation step can + * be marked as transient, and depend on the result of the previous step. This way, the user will be shown the confirmation + * when they initially set the result, but will skip the confirmation when they navigate backwards through the flow, and + * will also skip the confirmation when navigating forward if the result of the original step has not changed. + * + * Example: + * Given a flow with three destinations, A, B, and C, where B is a transient step: + * 1. When A returns a result, the user will be sent to B, and the backstack will be A -> B + * 2. When B returns a result, the user will be sent to C, but the backstack will become A -> C + * 3. When the user navigates back from C, they will be sent to A, skipping B + * 4. When A returns a result for the second time, B may or may not be skipped, depending on whether or not it has a [dependsOn] + * a. If B has a [dependsOn] value, and the value has not changed, B will be skipped + * b. If B has a [dependsOn] value, and the value has changed, B will be shown + * c. If B does not have a [dependsOn] value, B will be skipped + */ + public fun transient() { + definition.configuration.add(FlowStepOptions.Transient) + } + + /** + * Adds a dependency for this step. When a NavigationFlow updates the backstack, it will normally + * skip steps that have already been completed. If a step has dependencies, the step will only be + * skipped if the dependencies have not changed. + * + * Example: + * Given a flow with destinations A, B, C and D, where no steps have any dependencies: + * If the backstack for the flow is A -> B -> C -> D, and the user is moved back to A (for example, by + * calling [FlowStepReference.editStep] or directly manipulating the backstack), after the user sets a result + * for A, both B and C will be skipped and the user will be moved back to D. + * + * Given a flow with destinations A, B, C and D, where B depends on the result of A: + * If the backstack for the flow is A -> B -> C -> D, and the user is moved back to A (for example, by + * calling [FlowStepReference.editStep] or directly manipulating the backstack), after the user sets a result + * for A, B will be re-executed if the result of A has changed, but once B is completed, + * C will be skipped and the user will be moved to D. + * + * ``` + * val firstResult = open(FirstNavigationKey()) + * val secondResult = open(SecondNavigationKey()) { + * // if firstResult changes, get a new result from SecondNavigationKey() + * dependsOn(firstResult) + * } + * ``` + * + * If the NavigationKey for this step properly implements equals/hashCode, then it may be useful + * to add a dependency on the NavigationKey itself, which will cause the step to be re-executed if the + * NavigationKey changes. + * + * ``` + * val firstResult = open(FirstNavigationKey()) + * + * // If data from firstResult is used to construct SecondNavigationKey, + * // it may be useful to add a dependsOn for "key" + * val secondResult = open( + * key = SecondNavigationKey( + * data = firstResult.data, + * otherData = firstResult.otherData, + * ) + * ) { + * dependsOn(key) + * } + * ``` + */ + public fun dependsOn(dependency: Any?) { + definition.dependencies.add(dependency) + } + + /** + * alwaysAfterPreviousStep causes this step to run whenever the previous step is completed, + * even if the result of the previous step has not changed. + * + * This is useful in NavigationFlows that use [FlowStepReference.editStep], where you want to ensure that a + * step is always run after the previous step, even if the result of the previous step has not changed. + * + * An example of where this could be used is when a NavigationFlow branches based on the result of a step, + * and you want to ensure that the branch is always run after the previous step, even if the result of the previous + * step has not changed. + * + * In the example below, we run the "SelectRepaymentType" step after the "SelectLoanType" step, + * even if the result of the "SelectLoanType" step has not changed. This means that if the user is + * navigated back to the "SelectLoanType" step (for example, through [FlowStepReference.editStep]), + * the user will always be presented with the "SelectRepaymentType" step, no matter what the result of the + * "SelectLoanType" step is: + * + * Example: + * ``` + * val loanType = open(SelectLoanType()) + * val repayments = open(SelectRepaymentType(loanType)) { + * alwaysAfterPreviousStep() + * } + * ``` + * + */ + public fun alwaysAfterPreviousStep() { + definition.configuration.add(FlowStepOptions.AlwaysAfterPrevious) + } + } +} + + +/** + * Sets a default result for the step. This means that a result will be returned for this step when the user navigates to + * this step for the first time, which means the step will be added to the backstack, but the user will skip over that step + * and go directly to the next step. If the user then navigates back to this step, the step will not be skipped and they + * will be able to interact with the screen that this step represents. + * + * This can be useful for pre-filling steps in a flow that is built from a form. For example, a user might be offered the + * option to edit some form, where there may or may not be data available for some of the steps. The flow can be launched + * with those steps pre-filled with the data that is available, but if the user was to navigate backwards through the flow, + * or the backstack was manipulated to jump back to any of the previous steps, those steps would be available for editing. + * + * Defaults are only configured once per execution of a NavigationFlow + */ +public fun FlowStepDefinition.ConfigurationScope.default() { + @Suppress("UNCHECKED_CAST") + definition as FlowStepDefinition + definition.defaultResult = Unit +} + +@Suppress("UnusedReceiverParameter") +@JvmName("defaultWithoutResult") +@Deprecated( + message = "default() is not supported for steps with a result type. Use default(result: R) instead.", + level = DeprecationLevel.ERROR, +) +public fun , R: Any> FlowStepDefinition.ConfigurationScope.default() { + error("default() is not supported for steps with a result type. Use default(result: R) instead.") +} + +/** + * Sets a default result for the step. This means that a result will be returned for this step when the user navigates to + * this step for the first time, which means the step will be added to the backstack, but the user will skip over that step + * and go directly to the next step. If the user then navigates back to this step, the step will not be skipped and they + * will be able to interact with the screen that this step represents. + * + * This can be useful for pre-filling steps in a flow that is built from a form. For example, a user might be offered the + * option to edit some form, where there may or may not be data available for some of the steps. The flow can be launched + * with those steps pre-filled with the data that is available, but if the user was to navigate backwards through the flow, + * or the backstack was manipulated to jump back to any of the previous steps, those steps would be available for editing. + * + * Defaults are only configured once per execution of a NavigationFlow, and changing the value provided to this function + * will not result in the defa + */ +public fun , R: Any> FlowStepDefinition.ConfigurationScope.default(result: R) { + @Suppress("UNCHECKED_CAST") + definition as FlowStepDefinition + definition.defaultResult = result +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepOptions.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepOptions.kt new file mode 100644 index 000000000..ab2f34eca --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepOptions.kt @@ -0,0 +1,16 @@ +package dev.enro.result.flow + +import kotlinx.serialization.Serializable + +@Serializable +public sealed interface FlowStepOptions { + @Serializable + public data object Transient : FlowStepOptions + + @Serializable + public data object AlwaysAfterPrevious : FlowStepOptions +} + + +internal val FlowStep<*>.isTransient: Boolean + get() = options.contains(FlowStepOptions.Transient) diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepReference.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepReference.kt new file mode 100644 index 000000000..35de8c208 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/FlowStepReference.kt @@ -0,0 +1,114 @@ +package dev.enro.result.flow + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.result.flow.FlowStepReference.Companion.getResult +import dev.enro.result.flow.FlowStepReference.Companion.setCompleted +import kotlin.jvm.JvmName + +/** + * A reference to a specific step in a [NavigationFlow] that allows manual manipulation of the step's result. + * + * This class is typically obtained through the [NavigationFlow.getStep] or [NavigationFlow.requireStep] functions. + * It provides the ability to: + * - Clear the result of the step using [clearResult] + * - Set a result for the step using [setResult] + * - Get the current result of the step using [getResult] + * - Trigger editing of the step using [editStep], which clears the result and updates the flow, + * which will cause the flow to return to this step + * + * This is useful for advanced flow management scenarios where you need to programmatically control + * the execution state of individual steps within a navigation flow. + */ +@AdvancedEnroApi +public class FlowStepReference( + private val flow: NavigationFlow<*>, + private val resultManager: FlowResultManager, + private val step: FlowStep, +) { + private fun setResultUnsafe(result: Any) { + @Suppress("UNCHECKED_CAST") + resultManager.set(step, result) + } + + private fun getResultUnsafe(): Any? { + return resultManager.get(step) + } + + /** + * Checks whether this step has been completed. + * + * A step is considered completed if it has a result stored in the [FlowResultManager]. + * This can happen either through normal flow execution, or by manually setting a result + * using [setCompleted] or [FlowStepReference.Companion.setCompleted]. + * + * @return true if the step has a result, false otherwise + */ + public fun isCompleted(): Boolean { + return resultManager.get(step) != null + } + + /** + * Clears the result for this step. + * + * This won't cause the NavigationFlow to update, but next time it does update, the user will be returned to this step. + */ + public fun clearResult() { + resultManager.clear(step.id) + } + + /** + * Triggers editing of the step in the NavigationFlow. This clears the result, and immediately triggers an [update] on + * the flow. + * + * If you want to cause multiple steps to be cleared before editing, you should call [clearResult] on each step before + * calling [editStep] on the step that should be edited. + */ + public fun editStep() { + clearResult() + flow.update() + } + + public companion object Companion { + /** + * Gets the current result for the step, which may be null if the result has been cleared or the step has not been + * executed yet. + */ + public fun FlowStepReference>.getResult(): R? { + val result = getResultUnsafe() ?: return null + @Suppress("UNCHECKED_CAST") + return result as R + } + + /** + * Marks the step as completed for steps that do not have a result type. + * + * This method sets the result to [Unit], which signifies completion of the step + * without returning any meaningful result data. It's primarily used for steps + * that don't require a result to be passed back to the flow. + */ + public fun FlowStepReference<*>.setCompleted() { + setResultUnsafe(Unit) + } + + @JvmName("setCompletedWithoutResult") + @Deprecated( + message = "A NavigationKey.WithResult should not be completed without a result, doing so will result in an error", + level = DeprecationLevel.ERROR, + ) + public fun FlowStepReference>.setCompleted() { + error("${step.key} is a NavigationKey.WithResult and cannot be completed without a result") + } + + /** + * Sets the result for this step and marks it as completed. + * + * This method is used for steps that have a result type ([NavigationKey.WithResult]). It stores the provided result + * and signals completion of the step. The NavigationFlow will then proceed to the next step that + * has not yet been completed. + */ + public fun FlowStepReference>.setCompleted(result: R) { + setResultUnsafe(result) + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/List.hashForDependsOn.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/List.hashForDependsOn.kt new file mode 100644 index 000000000..5a7870fbc --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/List.hashForDependsOn.kt @@ -0,0 +1,7 @@ +package dev.enro.result.flow + +@PublishedApi +internal fun List.hashForDependsOn(): Long = fold(0L) { result, it -> + val hash = if (it is List<*>) it.hashForDependsOn() else it.hashCode().toLong() + return@fold 31L * result + hash +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.getStep.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.getStep.kt new file mode 100644 index 000000000..48527f2c2 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.getStep.kt @@ -0,0 +1,79 @@ +package dev.enro.result.flow + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import kotlin.jvm.JvmName + +@AdvancedEnroApi +public inline fun NavigationFlow<*>.getStep( + id: FlowStep.Id, +): FlowStepReference? { + return getSteps() + .firstOrNull { + it.key is T && it.id.value == id.value + } + ?.let { + FlowStepReference(this, getResultManager(), it) + } +} + +@AdvancedEnroApi +@JvmName("getStepTyped") +public inline fun NavigationFlow<*>.getStep( + block: (T) -> Boolean = { true }, +): FlowStepReference? { + return getSteps() + .firstOrNull { + it.key is T && block(it.key) + } + ?.let { + FlowStepReference(this, getResultManager(), it) + } +} + +@AdvancedEnroApi +public fun NavigationFlow<*>.getStep( + block: (NavigationKey) -> Boolean = { true }, +): FlowStepReference? { + return getSteps() + .firstOrNull { + block(it.key) + } + ?.let { + FlowStepReference(this, getResultManager(), it) + } +} + +@AdvancedEnroApi +public inline fun NavigationFlow<*>.requireStep( + block: (T) -> Boolean = { true }, +): FlowStepReference { + return requireNotNull(getStep(block)) +} + +@AdvancedEnroApi +public inline fun NavigationFlowScope.getStep( + block: (T) -> Boolean = { true }, +): FlowStepReference? { + return steps + .firstOrNull { + it.key is T && block(it.key) + } + ?.let { + FlowStepReference(flow, resultManager, it) + } +} + +@AdvancedEnroApi +public inline fun NavigationFlowScope.requireStep( + block: (T) -> Boolean = { true }, +): FlowStepReference { + return requireNotNull(getStep(block)) +} + +@AdvancedEnroApi +public inline fun NavigationFlow<*>.requireStep( + id: FlowStep.Id, +): FlowStepReference { + return requireNotNull(getStep(id)) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.kt new file mode 100644 index 000000000..d1a4b1762 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.kt @@ -0,0 +1,157 @@ +package dev.enro.result.flow + +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.asBackstack +import dev.enro.asInstance +import dev.enro.platform.EnroLog +import dev.enro.result.NavigationResultChannel +import dev.enro.ui.NavigationContainerState +import dev.enro.withMetadata +import kotlinx.coroutines.CoroutineScope + +@ExperimentalEnroApi +public class NavigationFlow internal constructor( + internal val reference: NavigationFlowReference, + private val navigationHandle: NavigationHandle<*>, + private val coroutineScope: CoroutineScope, + internal var flow: NavigationFlowScope.() -> T, + internal var onCompleted: (T) -> Unit, +) { + private var steps: List> = emptyList() + + private val resultManager = FlowResultManager.create(navigationHandle) + + internal var container: NavigationContainerState? = null + set(value) { + if (field == value) return + field = value + if (value == null) return + update(fromContainerChange = true) + } + + internal fun onStepCompleted(id: FlowStep.Id<*>, result: Any) { + val step = steps.firstOrNull { it.id == id } + if (step == null) { + EnroLog.error("Received result for id ${id.value}, but no active steps had that id") + } + step as FlowStep + resultManager.set(step, result) + } + + internal fun onStepClosed(id: FlowStep.Id<*>) { + resultManager.clear(id) + } + + /** + * This method is used to cause the flow to re-evaluate it's current state. + */ + public fun update() { + update( + fromContainerChange = false + ) + } + + private fun update( + fromContainerChange: Boolean + ) { + val flowScope = NavigationFlowScope( + coroutineScope = coroutineScope, + flow = this, + resultManager = resultManager, + navigationFlowReference = reference + ) + val result = runCatching { + flowScope.flow() + }.recover { + when (it) { + is NavigationFlowScope.NoResult -> null + is NavigationFlowScope.Escape -> return + else -> throw it + } + }.getOrThrow() + + val oldSteps = steps + steps = flowScope.steps + val container = container ?: return + + val existingInstances = container.backstack + .mapNotNull { instance -> + val step = instance.flowStepId ?: return@mapNotNull null + step to instance + } + .groupBy { it.first } + .mapValues { it.value.lastOrNull() } + + if (fromContainerChange && existingInstances.isNotEmpty()) { + // If the update is being caused by a container change, that might mean the NavigationFlow + // is being restored from a saved state. If we're being restored from a saved state, + // we don't actually want to change what's in the backstack, we just want to make sure + // that the steps list is up to date, so we can return here after the steps list is updated + return + } + if (result != null) { + onCompleted(result) + return + } + + val updatedBackstack = steps + .filterIndexed { index, flowStep -> + if (index == steps.lastIndex) return@filterIndexed true + !flowStep.isTransient + } + .map { step -> + val existingStep = existingInstances[step.id]?.second?.takeIf { + oldSteps + .firstOrNull { it.id == step.id } + ?.dependsOn == step.dependsOn + } + existingStep ?: step.key + .withMetadata(FlowStep.Id.MetadataKey, step.id.value) + .withMetadata(NavigationFlowReference.MetadataKey, this) + .withMetadata( + NavigationResultChannel.ResultIdKey, NavigationResultChannel.Id( + ownerId = "NavigationFlow", + resultId = step.id.value, + ) + ) + .asInstance() + .apply { + metadata.addFrom(step.metadata) + } + .copy(id = step.id.value) + } + .asBackstack() + + container.execute( + NavigationOperation.AggregateOperation( + NavigationOperation + .SetBackstack( + currentBackstack = container.backstack, + targetBackstack = updatedBackstack, + ) + .operations + .map { + when (it) { + is NavigationOperation.Close<*> -> it.copy(silent = true) + else -> it + } + } + ) + ) + } + + @PublishedApi + internal fun getSteps(): List> = steps + + @PublishedApi + internal fun getResultManager(): FlowResultManager = resultManager + + public companion object { + internal object ResultFlowKey : NavigationKey.TransientMetadataKey?>(null) + internal object ResultFlowIdKey : NavigationKey.MetadataKey(null) + } +} + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowReference.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowReference.kt new file mode 100644 index 000000000..24fc6c5ba --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowReference.kt @@ -0,0 +1,55 @@ +package dev.enro.result.flow + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.navigationHandle +import kotlinx.serialization.Serializable + +/** + * NavigationFlowReference is a reference to a NavigationFlow, and is available in NavigationFlowScope when building a + * NavigationFlow. It can be passed to a NavigationKey to allow the screen that the NavigationKey represents to interact + * with the navigation flow and perform actions such as returning to previous steps within the flow to edit items. + */ +@Serializable +@ExperimentalEnroApi +public class NavigationFlowReference internal constructor( + internal val id: String, +) { + internal object MetadataKey : NavigationKey.TransientMetadataKey?>(null) +} + +/** + * Resolves [reference] back to the live [NavigationFlow] from this + * destination's metadata. The reference is the serialisation-safe handle + * you pass into a destination's [NavigationKey] when you want that + * destination to be able to call back into the flow (e.g. to jump back + * to an earlier step for editing). + * + * Throws if the destination wasn't opened as part of a flow, or if the + * reference's id doesn't match the attached flow. + */ +@ExperimentalEnroApi +public fun NavigationHandle<*>.getNavigationFlow(reference: NavigationFlowReference): NavigationFlow<*> { + val flow = instance.metadata.get(NavigationFlowReference.MetadataKey) + requireNotNull(flow) { + "NavigationFlow with ${reference.id} is not attached to NavigationHandle: $reference" + } + require(flow.reference.id == reference.id) { + "NavigationFlowReference does not match the current flow" + } + return flow +} + +@Composable +@ExperimentalEnroApi +public fun rememberNavigationFlowReference( + reference: NavigationFlowReference, +): NavigationFlow<*> { + val navigationHandle = navigationHandle() + return remember(navigationHandle) { + navigationHandle.getNavigationFlow(reference) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowScope.kt new file mode 100644 index 000000000..402853a55 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowScope.kt @@ -0,0 +1,175 @@ +package dev.enro.result.flow + +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.withMetadata +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.jvm.JvmName + +public open class NavigationFlowScope internal constructor( + @PublishedApi + internal val flow: NavigationFlow<*>, + @PublishedApi + internal val coroutineScope: CoroutineScope, + @PublishedApi + internal val resultManager: FlowResultManager, + public val navigationFlowReference: NavigationFlowReference, + @PublishedApi + internal val steps: MutableList> = mutableListOf(), + @PublishedApi + internal val suspendingSteps: MutableList = mutableListOf(), +) { + + public inline fun open( + key: K, + noinline block: FlowStepDefinition.ConfigurationScope.() -> Unit = {}, + ) { + return open(key.withMetadata(), block) + } + + public inline fun open( + key: NavigationKey.WithMetadata, + noinline block: FlowStepDefinition.ConfigurationScope.() -> Unit = {}, + ) { + return step( + stepDefinition = object : FlowStepDefinition() { + override val keyWithMetadata = key + override val result = Unit::class + + init { ConfigurationScope(this).block() } + }, + ) + } + + public inline fun , reified R : Any> open( + key: K, + noinline block: FlowStepDefinition.ConfigurationScope.() -> Unit = {}, + ): R { + return open(key.withMetadata(), block) + } + + public inline fun , reified R : Any> open( + key: NavigationKey.WithMetadata, + noinline block: FlowStepDefinition.ConfigurationScope.() -> Unit = {}, + ): R { + return step( + stepDefinition = object : FlowStepDefinition() { + override val keyWithMetadata = key + override val result = R::class + + init { ConfigurationScope(this).block() } + }, + ) + } + + /** + * See documentation on the other [async] function for more information on how this function works. + */ + @Suppress("NOTHING_TO_INLINE") // required for using block's name as an identifier + public inline fun async( + vararg dependsOn: Any?, + noinline block: suspend () -> T, + ): T { + if (dependsOn.size == 1 && dependsOn[0] is List<*>) { + return async(dependsOn = dependsOn[0] as List, block = block) + } + return async(dependsOn.toList(), block) + } + + /** + * [async] allows the execution of suspending functions during a Navigation Flow. This is a delicate API and should be used + * with care. In many cases, it would likely provide a better user experience to implement a NavigationDestination that provides + * UI to the user (such as a loading spinner) while the suspending function is executing, and then pushing or presenting + * that Navigation Destination into the flow, rather than using [async], which provides no UI. + * + * Suspending steps are never saved when application process death occurs, and will always be re-executed. + * + * Examples of when to use [async] include: + * - Small and fast suspending functions that are known to be quick to execute. For example, fetching a value from a local database. + * - Waiting for external state, where there is UI provided by the screen that is hosting the flow. For example, using an + * [async] call as the first step of a flow, to delay starting the flow while some external state is loaded, where the + * screen hosting the flow shows a loading spinner. + * + * @param dependsOn A list of objects that this suspending step depends on. If any of these objects change, the suspending + * function will be re-executed. This is used to ensure that the result of the suspending function is valid. + * + * @param block The suspending function to execute. + */ + @AdvancedEnroApi + @Suppress("NOTHING_TO_INLINE") // required for using block's name as an identifier + public inline fun async( + dependsOn: List = emptyList(), + noinline block: suspend () -> T, + ): T { + val baseId = block::class.qualifiedName ?: block::class.toString() + val count = suspendingSteps.count { it.startsWith(baseId) } + val stepId = "$baseId@$count" + suspendingSteps.add(stepId) + + val dependencyHash = dependsOn.hashForDependsOn() + + val existing = resultManager.suspendingResults[stepId]?.let { + when { + it.dependsOn != dependencyHash -> { + it.job.cancel() + it.result.cancel() + null + } + + else -> it + } + } + if (existing != null && !existing.result.isCancelled) { + if (!existing.result.isCompleted) escape() + + @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("UNCHECKED_CAST") + return existing.result.getCompleted() as T + } + + val deferredResult = coroutineScope.async(start = CoroutineStart.LAZY) { + block() + } + val job = coroutineScope.launch(start = CoroutineStart.LAZY) { + deferredResult.await() + flow.update() + } + resultManager.suspendingResults[stepId] = FlowResultManager.SuspendingStepResult( + id = FlowStep.Id(stepId), + result = deferredResult, + job = job, + dependsOn = dependencyHash, + ) + job.start() + escape() + } + + @PublishedApi + @JvmName("stepWithMetadata") + internal inline fun step( + stepDefinition: FlowStepDefinition, + ): R { + val step = stepDefinition.buildStep(this) + val defaultResult = stepDefinition.defaultResult + if (defaultResult != null) { + resultManager.setDefault(step, defaultResult) + } + steps.add(step) + val result = resultManager.get(step) + return result ?: throw NoResult(step) + } + + public fun escape(): Nothing { + throw Escape() + } + + @PublishedApi + internal class NoResult(val step: FlowStep) : RuntimeException() + + @PublishedApi + internal class Escape : RuntimeException() +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationHandle.navigationFlow.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationHandle.navigationFlow.kt new file mode 100644 index 000000000..681115bd4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationHandle.navigationFlow.kt @@ -0,0 +1,20 @@ +package dev.enro.result.flow + +import dev.enro.NavigationHandle +import dev.enro.annotations.AdvancedEnroApi + +/** + * The [NavigationFlow] that owns this destination's entry, if the + * destination was opened as part of one — or `null` if the destination + * is running outside of any flow. + * + * Advanced surface: most flow consumers should reach for the typed + * accessors on `NavigationFlowScope` instead. Use this only when you + * need to introspect the flow metadata from outside the flow's own + * scope (e.g. a custom interceptor / plugin). + */ +@AdvancedEnroApi +public val NavigationHandle<*>.navigationFlow: NavigationFlow<*>? + get() { + return instance.metadata.get(NavigationFlow.Companion.ResultFlowKey) + } \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/ViewModel.navigationFlow.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/ViewModel.navigationFlow.kt new file mode 100644 index 000000000..2769b7955 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/ViewModel.navigationFlow.kt @@ -0,0 +1,11 @@ +package dev.enro.result.flow + +import androidx.lifecycle.ViewModel +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.getNavigationHandle + +@AdvancedEnroApi +public val ViewModel.navigationFlow: NavigationFlow<*>? + get() { + return getNavigationHandle().navigationFlow + } \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/registerForFlowResult.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/registerForFlowResult.kt new file mode 100644 index 000000000..ab650982c --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/registerForFlowResult.kt @@ -0,0 +1,39 @@ +package dev.enro.result.flow + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.getNavigationHandle +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty + +/** + * This method creates a NavigationFlow in the scope of a ViewModel. There can only be one NavigationFlow created within each + * NavigationDestination. The [flow] lambda will be invoked multiple times over the lifecycle of the NavigationFlow, and should + * generally not cause external side effects. The [onCompleted] lambda will be invoked when the flow completes and returns a + * result. + */ +@ExperimentalEnroApi +public fun ViewModel.registerForFlowResult( + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return PropertyDelegateProvider { thisRef, property -> + val navigation = thisRef.getNavigationHandle() + val resultFlowId = property.name + val boundResultFlowId = navigation.instance.metadata.get(NavigationFlow.Companion.ResultFlowIdKey) + require(boundResultFlowId == null || boundResultFlowId == resultFlowId) { + "Only one registerForFlowResult can be created per NavigationHandle. Found an existing result flow for $boundResultFlowId." + } + navigation.instance.metadata.set(NavigationFlow.Companion.ResultFlowIdKey, resultFlowId) + val navigationFlow = NavigationFlow( + reference = NavigationFlowReference(resultFlowId), + navigationHandle = navigation, + coroutineScope = thisRef.viewModelScope, + flow = flow, + onCompleted = onCompleted, + ) + navigation.instance.metadata.set(NavigationFlow.Companion.ResultFlowKey, navigationFlow) + ReadOnlyProperty { _, _ -> navigationFlow } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/rememberNavigationContainerForFlow.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/rememberNavigationContainerForFlow.kt new file mode 100644 index 000000000..1560fb340 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/rememberNavigationContainerForFlow.kt @@ -0,0 +1,73 @@ +package dev.enro.result.flow + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.lifecycle.ViewModel +import dev.enro.NavigationContainer +import dev.enro.NavigationContainerFilter +import dev.enro.NavigationKey +import dev.enro.emptyBackstack +import dev.enro.interceptor.builder.navigationInterceptor +import dev.enro.result.NavigationResultChannel +import dev.enro.ui.NavigationContainerState +import dev.enro.ui.rememberNavigationContainer +import kotlin.uuid.Uuid + +@Composable +public fun rememberNavigationContainerForFlow( + flow: NavigationFlow<*>, +): NavigationContainerState { + return rememberNavigationContainer( + backstack = emptyBackstack(), + filter = NavigationContainerFilter( + fromChildrenOnly = true, + block = { true }, + ), + interceptor = remember { + navigationInterceptor { + onClosed { + val stepId = instance.flowStepId + if (stepId != null && !isSilent) { + flow.onStepClosed(stepId) + } + continueWithClose() + } + onCompleted { + val stepId = instance.flowStepId + ?: instance.metadata.get(NavigationResultChannel.ResultIdKey) + ?.let { resultId -> + flow.getSteps() + .firstOrNull { it.id.value == resultId.resultId } + ?.id + } + if (stepId == null) continueWithComplete() + cancelAnd { + flow.onStepCompleted(stepId, data ?: Unit) + flow.update() + } + } + } + } + ).apply { + val state = this + DisposableEffect(this) { + flow.container = state + onDispose { + flow.container = null + } + } + } +} + +@Composable +public fun rememberNavigationContainerForFlow( + viewModel: ViewModel, +): NavigationContainerState { + return rememberNavigationContainerForFlow( + flow = remember(viewModel) { + viewModel.navigationFlow ?: error("No NavigationFlow found on ViewModel $viewModel") + } + ) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/registerForNavigationResult.composable.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/registerForNavigationResult.composable.kt new file mode 100644 index 000000000..ae99d54a2 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/registerForNavigationResult.composable.kt @@ -0,0 +1,70 @@ +package dev.enro.result + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.remember +import dev.enro.NavigationKey +import dev.enro.ui.LocalNavigationHandle + + +// TODO this needs much more documentation, it's too complex, maybe a separate file +@Composable +public inline fun registerForNavigationResult( + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onCompleted: NavigationResultScope>.(R) -> Unit, +): NavigationResultChannel { + val hashKey = currentCompositeKeyHash + val navigationHandle = LocalNavigationHandle.current + val channel = remember(hashKey) { + NavigationResultChannel( + id = NavigationResultChannel.Id( + ownerId = navigationHandle.instance.id, + resultId = hashKey.toString(), + ), + navigationHandle = navigationHandle, + onClosed = { + @Suppress("UNCHECKED_CAST") + this as NavigationResultScope> + onClosed(this) + }, + onCompleted = { + @Suppress("UNCHECKED_CAST") + this as NavigationResultScope> + onCompleted(it) + } + ) + } + LaunchedEffect(hashKey) { + NavigationResultChannel.observe(this, channel) + } + return channel +} + +// TODO this needs much more documentation, it's too complex, maybe a separate file +@Composable +public fun registerForNavigationResult( + onClosed: NavigationResultScope.() -> Unit = {}, + onCompleted: NavigationResultScope.() -> Unit, +): NavigationResultChannel { + val hashKey = currentCompositeKeyHash + val navigationHandle = LocalNavigationHandle.current + val channel = remember(hashKey) { + NavigationResultChannel( + id = NavigationResultChannel.Id( + ownerId = navigationHandle.instance.id, + resultId = hashKey.toString(), + ), + navigationHandle = navigationHandle, + onClosed = onClosed, + onCompleted = { + onCompleted() + } + ) + } + LaunchedEffect(hashKey) { + NavigationResultChannel.observe(this, channel) + } + @Suppress("UNCHECKED_CAST") + return channel +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/result/registerForNavigationResult.viewmodel.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/result/registerForNavigationResult.viewmodel.kt new file mode 100644 index 000000000..c6fb7a3c8 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/registerForNavigationResult.viewmodel.kt @@ -0,0 +1,87 @@ +package dev.enro.result + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.enro.NavigationKey +import dev.enro.getNavigationHandle +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass + + +public inline fun ViewModel.registerForNavigationResult( + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onCompleted: NavigationResultScope>.(R) -> Unit, +): PropertyDelegateProvider>> { + return registerForNavigationResult( + resultType = R::class, + onClosed = onClosed, + onCompleted = onCompleted, + ) +} + +public fun ViewModel.registerForNavigationResult( + resultType: KClass, + onClosed: NavigationResultScope>.() -> Unit = {}, + onCompleted: NavigationResultScope>.(R) -> Unit, +): PropertyDelegateProvider>> { + return PropertyDelegateProvider { thisRef, property -> + val resultId = "${thisRef::class.qualifiedName}.${property.name}" + val navigation = getNavigationHandle() + val scope = viewModelScope + @Suppress("UNCHECKED_CAST") + val channel = NavigationResultChannel( + id = NavigationResultChannel.Id( + ownerId = navigation.instance.id, + resultId = resultId, + ), + onClosed = { + this as NavigationResultScope> + onClosed() + }, + onCompleted = { + this as NavigationResultScope> + onCompleted(it) + }, + navigationHandle = navigation, + ) + NavigationResultChannel.observe(resultType, scope, channel) + + ReadOnlyProperty { vm, _ -> + require(vm === this) + channel + } + } +} + +public fun ViewModel.registerForNavigationResult( + onClosed: NavigationResultScope.() -> Unit = {}, + onCompleted: NavigationResultScope.() -> Unit, +): PropertyDelegateProvider>> { + return PropertyDelegateProvider { thisRef, property -> + val resultId = "${thisRef::class.qualifiedName}.${property.name}" + + val navigation = getNavigationHandle() + val scope = viewModelScope + @Suppress("UNCHECKED_CAST") + val channel = NavigationResultChannel( + id = NavigationResultChannel.Id( + ownerId = navigation.instance.id, + resultId = resultId, + ), + onClosed = { + onClosed() + }, + onCompleted = { + onCompleted() + }, + navigationHandle = navigation, + ) + NavigationResultChannel.observe(Unit::class, scope, channel) + + ReadOnlyProperty { vm, _ -> + require(vm === this) + channel + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/Any.unwrapForSerialization.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/Any.unwrapForSerialization.kt new file mode 100644 index 000000000..ff63a1728 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/Any.unwrapForSerialization.kt @@ -0,0 +1,7 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") +package dev.enro.serialization + +@PublishedApi +internal fun Any?.unwrapForSerialization(): Any { + return this.internalUnwrapForSerialization() +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/Any.wrapForSerialization.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/Any.wrapForSerialization.kt new file mode 100644 index 000000000..c5a11c573 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/Any.wrapForSerialization.kt @@ -0,0 +1,7 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") +package dev.enro.serialization + +@PublishedApi +internal fun Any?.wrapForSerialization(): Any { + return this.internalWrapForSerialization() +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/enroSaver.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/enroSaver.kt new file mode 100644 index 000000000..f9d3a6863 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/serialization/enroSaver.kt @@ -0,0 +1,106 @@ +package dev.enro.serialization + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.saveable.Saver +import androidx.savedstate.SavedState +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer +import androidx.savedstate.read +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import androidx.savedstate.write +import dev.enro.EnroController +import kotlinx.serialization.KSerializer +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.builtins.nullable + +/** + * Creates a [Saver] that uses Enro's savedStateConfiguration to serialize and deserialize objects. + * + * This function provides a Saver implementation backed by the savedStateConfiguration registered + * with Enro, based on classes that are registered in a NavigationModule's serializersModule. + * This means the serializers are registered with the global Enro Controller. + * + * This is particularly useful when you want to remember saved data that: + * - Belongs to a NavigationKey + * - Comes from a NavigationKey in some way + * - Is a NavigationKey or NavigationKey.Instance that you want to remember explicitly + * + * @return A [Saver] that can save and restore objects of type [T] using Enro's serialization configuration + */ +public inline fun enroSaver(): Saver { + return Saver( + save = { value -> + val enroSaverType = EnroSaverType.fromValue(value) + val saved = encodeToSavedState( + serializer = enroSaverType.serializer, + value = value, + configuration = EnroController.savedStateConfiguration, + ) + saved.write { + putString(EnroSaverType.TYPE_KEY, enroSaverType.typeName) + } + saved + }, + restore = { saved -> + val enroSaverType = EnroSaverType.forSavedState(saved) + decodeFromSavedState( + deserializer = enroSaverType.serializer, + savedState = saved, + configuration = EnroController.savedStateConfiguration, + ) + } + ) +} + +@PublishedApi +internal class EnroSaverType( + val typeName: String, + val serializer: KSerializer +) { + companion object { + const val TYPE_KEY = "\$\$enroSaverType" + + fun fromValue( + value: T, + ): EnroSaverType { + lateinit var typeName: String + + val serializer = when(value) { + is MutableState<*> -> { + typeName = "MutableState" + MutableStateSerializer(PolymorphicSerializer(Any::class).nullable) + } + else -> { + typeName = "Any" + PolymorphicSerializer(Any::class) + } + } + + @Suppress("UNCHECKED_CAST") + return EnroSaverType( + typeName = typeName, + serializer = serializer as KSerializer + ) + } + + fun forSavedState(savedState: SavedState): EnroSaverType { + val typeName = savedState.read { + getString(TYPE_KEY) + } + requireNotNull(typeName) { + "SavedState does not contain an EnroSaverType" + } + @Suppress("UNCHECKED_CAST") + return when(typeName) { + "MutableState" -> EnroSaverType( + typeName = typeName, + serializer = MutableStateSerializer(PolymorphicSerializer(Any::class).nullable) + ) + else -> EnroSaverType( + typeName = typeName, + serializer = PolymorphicSerializer(Any::class) + ) + } as EnroSaverType + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/EmbeddedDestination.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/EmbeddedDestination.kt new file mode 100644 index 000000000..feaa50465 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/EmbeddedDestination.kt @@ -0,0 +1,86 @@ +package dev.enro.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import dev.enro.NavigationKey +import dev.enro.acceptNone +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.backstackOf +import dev.enro.interceptor.builder.navigationInterceptor + +@Composable +@ExperimentalEnroApi +public fun EmbeddedDestination( + instance: NavigationKey.Instance, + onClosed: () -> Unit, + onCompleted: () -> Unit, + modifier: Modifier = Modifier, +) { + val rememberedOnClosed = rememberUpdatedState(onClosed) + + val container = rememberNavigationContainer( + backstack = backstackOf(instance), + filter = acceptNone(), + interceptor = navigationInterceptor { + onOpened { + cancel() + } + onClosed { + if (this.instance.id != instance.id) continueWithClose() + cancelAnd { + rememberedOnClosed.value.invoke() + } + } + onCompleted { + if (this.instance.id != instance.id) continueWithComplete() + cancelAnd { + rememberedOnClosed.value.invoke() + } + } + }, + ) + Box(modifier = modifier) { + NavigationDisplay(container) + } +} + +@Composable +@ExperimentalEnroApi +public inline fun EmbeddedDestination( + instance: NavigationKey.Instance>, + noinline onClosed: () -> Unit, + noinline onCompleted: (T) -> Unit, + modifier: Modifier = Modifier, +) { + val rememberedOnClosed = rememberUpdatedState(onClosed) + val rememberedOnResult = rememberUpdatedState(onCompleted) + + val container = rememberNavigationContainer( + backstack = backstackOf(instance), + filter = acceptNone(), + interceptor = navigationInterceptor { + onOpened { + cancel() + } + onClosed { + if (this.instance.id != instance.id) continueWithClose() + cancelAnd { + rememberedOnClosed.value.invoke() + } + } + onCompleted> { + if (this.instance.id != instance.id) continueWithComplete() + cancelAnd { + rememberedOnResult.value.invoke(result) + } + } + } + ) + Box(modifier = modifier) { + NavigationDisplay(container) + } +} + + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/EmptyBehavior.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/EmptyBehavior.kt new file mode 100644 index 000000000..7ce0bd779 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/EmptyBehavior.kt @@ -0,0 +1,106 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import dev.enro.NavigationBackstack +import dev.enro.NavigationContainer +import dev.enro.NavigationTransition +import dev.enro.close +import dev.enro.navigationHandle + +@Immutable +@Stable +@ConsistentCopyVisibility +public data class EmptyBehavior internal constructor( + private val isBackHandlerEnabled: () -> Boolean, + private val onPredictiveBackProgress: (Float) -> Boolean, + private val onEmpty: Scope.() -> NavigationContainer.EmptyInterceptor.Result, +) { + internal val interceptor = object : NavigationContainer.EmptyInterceptor() { + override fun onEmpty( + transition: NavigationTransition, + ): Result { + return this@EmptyBehavior.onEmpty(Scope(transition)) + } + } + + internal fun isBackHandlerEnabled(backstack: NavigationBackstack): Boolean { + if (backstack.isEmpty()) return false + return isBackHandlerEnabled() + } + + // returns true if the progress is "consumed" and should not be used in animations + internal fun onPredictiveBackProgress( + backstack: NavigationBackstack, + progress: Float + ): Boolean { + if (backstack.isNotEmpty()) return false + return onPredictiveBackProgress(progress) + } + + public class Scope internal constructor( + public val transition: NavigationTransition, + ) { + public fun allowEmpty(): NavigationContainer.EmptyInterceptor.Result { + return NavigationContainer.EmptyInterceptor.Result.AllowEmpty + } + + public fun denyEmpty(): NavigationContainer.EmptyInterceptor.Result { + return NavigationContainer.EmptyInterceptor.Result.DenyEmpty {} + } + + public fun denyEmptyAnd(block: () -> Unit): NavigationContainer.EmptyInterceptor.Result { + return NavigationContainer.EmptyInterceptor.Result.DenyEmpty( + block = block + ) + } + } + + public companion object { + // Allows the container to become empty, including predictive back animations, + // allows an OnNavigationTransitionScope to be invoked when the container would + // otherwise become empty + public fun allowEmpty( + onEmpty: () -> Unit = {}, + ): EmptyBehavior { + return EmptyBehavior( + isBackHandlerEnabled = { true }, + onPredictiveBackProgress = { true }, + onEmpty = { + onEmpty() + allowEmpty() + }, + ) + } + + // Stops the container becoming empty, passing events through to the parent container, + // will still deliver "complete" events from the last destination + public fun preventEmpty(): EmptyBehavior { + return EmptyBehavior( + isBackHandlerEnabled = { false }, + onPredictiveBackProgress = { false }, + onEmpty = { denyEmpty() }, + ) + } + + @Composable + public fun closeParent(): EmptyBehavior { + val navigation = navigationHandle() + return remember(navigation) { + EmptyBehavior( + isBackHandlerEnabled = { true }, + onPredictiveBackProgress = { true }, + onEmpty = { + denyEmptyAnd { navigation.close() } + }, + ) + } + } + + public fun default(): EmptyBehavior { + return preventEmpty() + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalEntriesToExcludeFromCurrentScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalEntriesToExcludeFromCurrentScene.kt new file mode 100644 index 000000000..40378af09 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalEntriesToExcludeFromCurrentScene.kt @@ -0,0 +1,18 @@ +package dev.enro.ui + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * The destination IDs that should be SKIPPED when rendering in the current + * [NavigationScene]. This is used by [movableContentDecorator] to ensure that + * an entry which appears in more than one scene during a transition is only + * actually composed by the scene that "wins" it under the z-order rules in + * [NavigationDisplay]. + * + * Mirrors Nav3's `LocalEntriesToExcludeFromCurrentScene`. If nothing has been + * provided (e.g. a destination is rendered outside a `NavigationDisplay`), + * the empty default means "exclude nothing" — every destination renders. + */ +public val LocalEntriesToExcludeFromCurrentScene: ProvidableCompositionLocal> = + compositionLocalOf { emptySet() } diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationAnimatedVisibilityScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationAnimatedVisibilityScope.kt new file mode 100644 index 000000000..fae914fe4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationAnimatedVisibilityScope.kt @@ -0,0 +1,21 @@ +package dev.enro.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +public val LocalNavigationAnimatedVisibilityScope: ProvidableCompositionLocal = + compositionLocalOf { error("AnimatedContentScope not provided") } + +/** + * Nullable mirror of [LocalNavigationAnimatedVisibilityScope] for + * callers that want to gracefully degrade when no overlay scope is + * available (e.g. design-system snapshot tests rendering a dialog + * standalone, outside any `NavigationDisplay`). + * + * Provided alongside the strict local everywhere the strict local is + * provided — reading this returns `null` when nothing's been set, + * rather than throwing. + */ +public val LocalNavigationAnimatedVisibilityScopeOrNull: ProvidableCompositionLocal = + compositionLocalOf { null } diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContainer.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContainer.kt new file mode 100644 index 000000000..894983c02 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContainer.kt @@ -0,0 +1,27 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.staticCompositionLocalOf + +public object LocalNavigationContainer { + private val LocalNavigationContainer: ProvidableCompositionLocal = staticCompositionLocalOf { + null + } + + public val current: NavigationContainerState + @Composable get() { + return LocalNavigationContainer.current ?: error("No LocalNavigationContainer (you might be calling this from a RootContext)") + } + + public val currentOrNull: NavigationContainerState? + @Composable get() = LocalNavigationContainer.current + + public infix fun provides( + navigationContainerState: NavigationContainerState + ): ProvidedValue { + @Suppress("UNCHECKED_CAST") + return LocalNavigationContainer.provides(navigationContainerState) as ProvidedValue + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContext.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContext.kt new file mode 100644 index 000000000..ba1a63c1e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContext.kt @@ -0,0 +1,38 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import dev.enro.NavigationContext +import dev.enro.context.RootContext +import dev.enro.ui.LocalNavigationContext.current + +public object LocalNavigationContext { + private val LocalNavigationContext = compositionLocalOf { null } + + public val current: NavigationContext + @Composable get() { + val current = LocalNavigationContext.current ?: findRootNavigationContext() + return remember { current } + } + + /** + * Null-safe sibling of [current] that returns `null` when no navigation + * context is available, instead of falling through to + * [findRootNavigationContext] (which throws on platforms / surfaces with + * no host activity — e.g. Paparazzi snapshot tests). + */ + public val currentOrNull: NavigationContext? + @Composable get() = LocalNavigationContext.current + + public infix fun provides( + navigationContext: NavigationContext + ): ProvidedValue { + @Suppress("UNCHECKED_CAST") + return LocalNavigationContext.provides(navigationContext) as ProvidedValue + } +} + +@Composable +internal expect fun findRootNavigationContext(): RootContext \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationHandle.kt new file mode 100644 index 000000000..6d5cef236 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationHandle.kt @@ -0,0 +1,14 @@ +package dev.enro.ui + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import dev.enro.NavigationHandle +import dev.enro.NavigationKey + +// TODO update to work like LocalNavigationContext, and look for root context +@PublishedApi +internal val LocalNavigationHandle: ProvidableCompositionLocal> = + staticCompositionLocalOf { + error("No LocalNavigationHandle") + } + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationSharedTransitionScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationSharedTransitionScope.kt new file mode 100644 index 000000000..b1b4ac732 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationSharedTransitionScope.kt @@ -0,0 +1,22 @@ +package dev.enro.ui + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +@OptIn(ExperimentalSharedTransitionApi::class) +public val LocalNavigationSharedTransitionScope: ProvidableCompositionLocal = + compositionLocalOf { error("SharedTransitionScope not provided")} + +/** + * Nullable mirror of [LocalNavigationSharedTransitionScope] for + * callers that want to gracefully degrade when no scope is available + * (e.g. design-system snapshot tests rendering a destination + * standalone, outside any `NavigationDisplay`). Provided alongside + * the strict local everywhere the strict local is provided — reading + * this returns `null` when nothing's been set, rather than throwing. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +public val LocalNavigationSharedTransitionScopeOrNull: ProvidableCompositionLocal = + compositionLocalOf { null } diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationAnimations.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationAnimations.kt new file mode 100644 index 000000000..cb4bc3214 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationAnimations.kt @@ -0,0 +1,71 @@ +package dev.enro.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally + +public data class NavigationAnimations( + val transitionSpec: TransitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInHorizontally { it / 3 }, + initialContentExit = slideOutHorizontally { -it / 4 }, + ) + }, + val popTransitionSpec: TransitionSpec = { + ContentTransform( + targetContentEnter = slideInHorizontally { -it / 4 }, + initialContentExit = fadeOut(spring(stiffness = Spring.StiffnessMedium)) + slideOutHorizontally { it / 3 }, + ) + }, + /** + * The lambda now receives the `NavigationEvent.SwipeEdge` reported by + * the back gesture, mirroring Nav3's `predictivePopTransitionSpec(swipeEdge)`. + */ + val predictivePopTransitionSpec: PredictivePopTransitionSpec = { _ -> + popTransitionSpec() + }, + val containerTransitionSpec: TransitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(spring(stiffness = Spring.StiffnessMedium)), + initialContentExit = fadeOut(), + ) + }, + // If this is set to true, transitions to-and-from an empty backstack will use the container transform, + // instead of the normal transitionSpec/popTransitionSpec. + val emptyUsesContainerTransition: Boolean = true, +) { + public companion object { + public val Default: NavigationAnimations = NavigationAnimations( + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInHorizontally { it / 3 }, + initialContentExit = slideOutHorizontally { -it / 4 }, + ) + }, + popTransitionSpec = { + ContentTransform( + targetContentEnter = slideInHorizontally { -it / 4 }, + initialContentExit = fadeOut(spring(stiffness = Spring.StiffnessMedium)) + slideOutHorizontally { it / 3 }, + ) + }, + predictivePopTransitionSpec = { _ -> + ContentTransform( + targetContentEnter = slideInHorizontally { -it / 4 }, + initialContentExit = fadeOut(spring(stiffness = Spring.StiffnessMedium)) + slideOutHorizontally { it / 3 }, + ) + }, + containerTransitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(), + initialContentExit = fadeOut(), + ) + }, + emptyUsesContainerTransition = true, + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationContainerState.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationContainerState.kt new file mode 100644 index 000000000..763892a73 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationContainerState.kt @@ -0,0 +1,93 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import androidx.savedstate.write +import dev.enro.EnroController +import dev.enro.NavigationBackstack +import dev.enro.NavigationContainer +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.asBackstack +import dev.enro.context.ContainerContext +import dev.enro.ui.decorators.NavigationSavedStateHolder +import kotlinx.serialization.PolymorphicSerializer + +public class NavigationContainerState( + public val container: NavigationContainer, + public val emptyBehavior: EmptyBehavior, + public val context: ContainerContext, + public val savedStateHolder: NavigationSavedStateHolder, +) { + + public val key: NavigationContainer.Key = container.key + + /** Whether the navigation state is settled (no animations running) */ + public var isSettled: Boolean by mutableStateOf(true) + internal set + + public var destinations: List> by mutableStateOf(emptyList()) + internal set + + public val backstack: NavigationBackstack by derivedStateOf { + container.backstack + } + + public fun updateBackstack(block: (NavigationBackstack) -> NavigationBackstack) { + container.updateBackstack(context, block) + } + + public fun execute(operation: NavigationOperation) { + container.execute(context, operation) + } + + public fun saveState(): SavedState { + val savedBackstack = container.backstack.map { instance -> + encodeToSavedState( + serializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)), + value = instance, + configuration = EnroController.instance!!.serializers.savedStateConfiguration + ) + }.toList() + return savedStateHolder.saveState().also { + it.write { + putSavedStateList("backstack", savedBackstack) + } + } + } + + public fun restoreState(savedState: SavedState) { + val restoredBackstack = savedState.read { + getSavedStateList("backstack").map { + decodeFromSavedState( + deserializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)), + savedState = it, + configuration = EnroController.instance!!.serializers.savedStateConfiguration, + ) + } + } + savedStateHolder.restoreState(savedState) + container.setBackstackDirect(restoredBackstack.asBackstack()) + } + + @Deprecated( + message = "Use NavigationDisplay(state) instead. This method only exists for migration ergonomics and will be removed before 3.0 stable.", + replaceWith = ReplaceWith( + expression = "NavigationDisplay(this)", + imports = ["dev.enro.ui.NavigationDisplay"], + ), + level = DeprecationLevel.WARNING, + ) + @Composable + public fun Render() { + NavigationDisplay(this) + } +} + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDestination.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDestination.kt new file mode 100644 index 000000000..f27cada32 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDestination.kt @@ -0,0 +1,216 @@ +package dev.enro.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.navigationHandle + +public open class NavigationDestinationProvider( + private val metadata: NavigationDestination.MetadataBuilder.() -> Unit = {}, + private val content: @Composable NavigationDestinationScope.() -> Unit, +) { + public fun peekMetadata(instance: NavigationKey.Instance): Map { + return NavigationDestination.MetadataBuilder(instance).apply(metadata).build() + } + + public fun create(instance: NavigationKey.Instance): NavigationDestination { + return NavigationDestination.create( + instance = instance, + metadata = NavigationDestination.MetadataBuilder(instance).apply(metadata).build(), + content = content, + ) + } +} + +@Stable +@Immutable +@ConsistentCopyVisibility +public data class NavigationDestination private constructor( + public val instance: NavigationKey.Instance, + public val metadata: Map = emptyMap(), + private val content: @Composable () -> Unit, +) { + public val id: String get() = instance.id + public val key: T get() = instance.key + + /** + * Renders this destination's content. Mirrors Nav3's `NavEntry.Content()` + * naming so a Nav3-style scene that calls `entry.Content()` translates + * directly to `destination.Content()` in Enro. + */ + @Composable + public fun Content() { + content() + } + + /** + * Creates a copy of this NavigationDestination with updated metadata. + * + * This function preserves the exact same instance and content references from the original + * NavigationDestination, only replacing the metadata map. This is useful for plugins or + * other components that need to enhance or modify the metadata associated with a destination + * without affecting its core behavior or content. + * + * @param metadata The new metadata map to use in the copied NavigationDestination + * @return A new NavigationDestination with the same instance and content, but updated metadata + */ + internal fun copy( + metadata: Map, + ): NavigationDestination { + return NavigationDestination( + instance = instance, + metadata = metadata, + content = content, + ) + } + + public class MetadataBuilder internal constructor( + public val instance: NavigationKey.Instance, + ) { + public val key: T get() = instance.key + + private val builder: MutableMap = mutableMapOf() + + public fun add(key: String, value: Any) { + builder[key] = value + } + public fun add(metadata: Pair) { + builder[metadata.first] = metadata.second + } + + public fun addAll(metadata: Map) { + builder.putAll(metadata) + } + + /** + * Typed accessor for setting metadata associated with a + * [MetadataKey]. Mirrors Nav3's `metadata { put(key, value) }` + * DSL while keeping symmetry with [NavigationKey.MetadataKey]. + * Use [add] for string-keyed metadata. + * + * If [value] is `null` the entry is left absent from the + * underlying map (`metadata[key]` will then resolve to the + * key's default). This means `MetadataKey(default = null)` + * works as a "key not set / value present" toggle. + */ + public fun add(key: MetadataKey, value: V) { + if (value == null) return + @Suppress("UNCHECKED_CAST") + builder[key.name] = value as Any + } + + internal fun build(): Map = builder.toMap() + } + + /** + * A typed key for values stored in a [NavigationDestination]'s + * metadata map. Mirrors Nav3's `NavMetadataKey` and is named in + * parallel with [NavigationKey.MetadataKey] (which serves the same + * role for navigation-key metadata). + * + * The [default] is returned when the metadata map does not contain + * a value for this key — i.e. for "key not set" semantics, declare + * `MetadataKey(default = null)`. + * + * Example: + * ``` + * object IsBottomSheet : NavigationDestination.MetadataKey(default = false) + * val isBottomSheet = destination.metadata[IsBottomSheet] + * ``` + */ + public abstract class MetadataKey( + public val default: T, + ) { + public val name: String by lazy { + this::class.qualifiedName ?: error("MetadataKeys must have a valid qualifiedName") + } + } + + public companion object { + @OptIn(ExperimentalSharedTransitionApi::class) + internal fun create( + instance: NavigationKey.Instance, + metadata: Map = emptyMap(), + content: @Composable NavigationDestinationScope.() -> Unit, + ): NavigationDestination { + return NavigationDestination( + instance = instance, + metadata = metadata, + content = { + @Suppress("UNCHECKED_CAST") + val navigation = navigationHandle() as NavigationHandle + val animatedVisibilityScope = LocalNavigationAnimatedVisibilityScope.current + val sharedTransitionScope = LocalNavigationSharedTransitionScope.current + val scope = remember(animatedVisibilityScope, sharedTransitionScope) { + NavigationDestinationScope( + destinationMetadata = metadata, + navigation = navigation, + animatedVisibilityScope = animatedVisibilityScope, + sharedTransitionScope = sharedTransitionScope + ) + } + content.invoke(scope) + }, + ) + } + + // createWithoutScope is used to create a NavigationDestination that does not include a NavigationDestinationScope + // as part of the content lambda; this is important for creating decorators, as some elements required to create + // the NavigationScope need to be provided by some of the internal decorators (e.g. lifecycle, context, etc) + internal fun createWithoutScope( + instance: NavigationKey.Instance, + metadata: Map = emptyMap(), + content: @Composable () -> Unit, + ): NavigationDestination { + return NavigationDestination( + instance = instance, + metadata = metadata, + content = { + content.invoke() + }, + ) + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +public class NavigationDestinationScope( + public val destinationMetadata: Map, + public val navigation: NavigationHandle, + private val animatedVisibilityScope: AnimatedVisibilityScope, + private val sharedTransitionScope: SharedTransitionScope, +) : SharedTransitionScope by sharedTransitionScope, AnimatedVisibilityScope by animatedVisibilityScope + +// We probably want to get rid of push/present and let scenes handle those + +public fun navigationDestination( + metadata: NavigationDestination.MetadataBuilder.() -> Unit = { }, + content: @Composable NavigationDestinationScope.() -> Unit, +): NavigationDestinationProvider { + return NavigationDestinationProvider(metadata, content) +} + +/** + * Typed accessor for a [NavigationDestination.MetadataKey] stored in a + * metadata map. Returns [NavigationDestination.MetadataKey.default] if + * no value is present. Mirrors Nav3's `Map.get(NavMetadataKey)` + * extension. + */ +@Suppress("UNCHECKED_CAST") +public operator fun Map.get(key: NavigationDestination.MetadataKey): T { + return get(key.name) as T? ?: key.default +} + +/** + * Returns `true` if this metadata map contains a value for [key]. + * Mirrors Nav3's `Map.contains(NavMetadataKey)` extension. + */ +public operator fun Map.contains(key: NavigationDestination.MetadataKey): Boolean { + return containsKey(key.name) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDisplay.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDisplay.kt new file mode 100644 index 000000000..fa9aba827 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDisplay.kt @@ -0,0 +1,827 @@ +package dev.enro.ui + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.NavigationEventState +import androidx.navigationevent.compose.rememberNavigationEventState +import dev.enro.NavigationContainer +import dev.enro.NavigationKey +import dev.enro.platform.EnroLog +import dev.enro.requestClose +import dev.enro.ui.scenes.DialogSceneStrategy +import dev.enro.ui.scenes.DirectOverlaySceneStrategy +import dev.enro.ui.scenes.OverlayTransitions +import dev.enro.ui.scenes.SinglePaneSceneStrategy +import dev.enro.viewmodel.getNavigationHandle +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +/** + * NavigationDisplay is the main composable for rendering a navigation container's content. + * + * It renders and animates between different [NavigationScene]s, each of which can render one + * or more [NavigationDestination]s. Overlay scenes (like dialogs) are rendered on top. + * + * The [NavigationScene]s are calculated with the given [NavigationSceneStrategy], which may be + * an assembled chain of strategies. If no scene is calculated, the fallback will be to a + * [SinglePaneSceneStrategy]. + * + * It is allowable for different scenes to render the same entries, perhaps on some conditions + * as determined by the [sceneStrategy] based on window size, form factor, or other logic. + * + * If this happens, and these scenes are rendered at the same time due to animation or predictive + * back, then the content for the entry will only be rendered in the most recent scene that + * is the target for being the current scene. This enforces a unique invocation of each entry, + * even if it is displayable by two different scenes. + * + * @param state The navigation container state whose backstack will be displayed + * @param modifier Modifier to be applied to the root content + * @param sceneStrategy Strategy for organizing destinations into scenes (e.g., dialogs, overlays, single pane) + * @param contentAlignment Alignment of content within the display + * @param sizeTransform Transform to apply when content size changes during transitions + * @param animations Animation specs for navigation transitions + */ +@OptIn(ExperimentalSharedTransitionApi::class) +public object NavigationDisplay { + /** + * Scene metadata key for overriding the enter transition used when + * pushing onto the backstack. Looked up on the resulting scene's + * metadata before falling back to the [NavigationAnimations] + * supplied at the display level. + * + * Mirrors Nav3's `NavDisplay.TransitionKey`. + */ + public object TransitionKey : + NavigationScene.MetadataKey(default = null) + + /** + * Scene metadata key for overriding the pop transition. Looked up + * the same way as [TransitionKey]. + */ + public object PopTransitionKey : + NavigationScene.MetadataKey(default = null) + + /** + * Scene metadata key for overriding the predictive-back transition. + * The lambda receives the [androidx.navigationevent.NavigationEvent.SwipeEdge] + * so the spec can vary by left/right edge. + */ + public object PredictivePopTransitionKey : + NavigationScene.MetadataKey(default = null) +} + +/** + * Type alias for the per-scene transition spec lambda used with + * [NavigationDisplay.TransitionKey] and [NavigationDisplay.PopTransitionKey]. + */ +public typealias TransitionSpec = + AnimatedContentTransitionScope.() -> ContentTransform + +/** + * Type alias for the per-scene predictive pop spec lambda used with + * [NavigationDisplay.PredictivePopTransitionKey]. The integer argument is + * the `NavigationEvent.SwipeEdge` reported by the back gesture. + */ +public typealias PredictivePopTransitionSpec = + AnimatedContentTransitionScope.(swipeEdge: Int) -> ContentTransform + +@Composable +public fun NavigationDisplay( + state: NavigationContainerState, + modifier: Modifier = Modifier, + sceneStrategy: NavigationSceneStrategy = remember { + NavigationSceneStrategy.from( + DialogSceneStrategy(), + DirectOverlaySceneStrategy(), + SinglePaneSceneStrategy(), + ) + }, + sceneDecoratorStrategies: List = emptyList(), + sharedTransitionScope: SharedTransitionScope? = null, + contentAlignment: Alignment = Alignment.TopStart, + sizeTransform: SizeTransform? = null, + animations: NavigationAnimations = NavigationAnimations.Default, +) { + DisposableEffect(state) { + state.context.parent.registerVisibility(state.context, true) + onDispose { + state.context.parent.registerVisibility(state.context, false) + } + } + + // Forward-ref to the scene state so the onBack lambda can read the + // current scene's previousEntries at click time. The Ref is updated + // immediately after rememberNavigationSceneState computes the state. + val sceneStateRef = remember { mutableStateOf(null) } + val onBackInternal: () -> Unit = remember(state) { + { + val current = sceneStateRef.value?.currentScene + if (current != null) { + val previousIds = current.previousEntries.map { it.instance.id }.toSet() + state.context.children + .filter { it.id !in previousIds } + .forEach { it.getNavigationHandle().requestClose() } + } + } + } + + // Resolve the full scene hierarchy (current scene, overlays, predictive-back + // previous scenes) via the hoistable rememberNavigationSceneState — same + // shape as Nav3's rememberSceneState. + val sceneState = rememberNavigationSceneState( + containerState = state, + sceneStrategy = sceneStrategy, + onBack = onBackInternal, + sceneDecoratorStrategies = sceneDecoratorStrategies, + ) + sceneStateRef.value = sceneState + val scene = sceneState.currentScene + val overlayScenes = sceneState.overlayScenes + + SceneRecompositionDebugger( + scene = scene, + overlayScenes = overlayScenes, + destinations = sceneState.entries, + ) + + // The SceneTransitionFrame wraps the scene with the data needed by transition + // specs (containerKey/visible/previouslyVisible). It is what flows + // through SeekableTransitionState, but AnimatedContent's `contentKey` + // collapses it down to a SceneIdentity, so two SceneTransitionFrames that + // share a (sceneType, scene.key, containerKey) reuse the same + // AnimatedContent slot and do NOT trigger a scene-level enter/exit + // animation. This mirrors Nav3's AnimatedSceneKey indirection and + // lets the scene strategy author control re-animation through + // `scene.key` alone. + val sceneFrame = SceneTransitionFrame(scene, state.container.key) + val sceneIdentity = sceneFrame.identity + + // Scene tracking maps (like NavDisplay's sceneMap and zIndices) + val sceneMap = remember { mutableStateMapOf() } + val zIndices = remember { mutableMapOf() } + sceneMap[sceneIdentity] = scene + + // Provide the current scene to the navigation event system so back + // handlers can reason about it. Mirrors Nav3's SceneInfo plumbing. + val navigationEventState = rememberNavigationEventState(NavigationSceneInfo(scene)) + + // Set up predictive back gesture handling + HandlePredictiveBack( + navigationEventState = navigationEventState, + scene = overlayScenes.lastOrNull() ?: scene, + state = state, + ) + + // Create the transition state that manages animations between scenes + val transitionState = remember { SeekableTransitionState(sceneFrame) } + val transition = rememberTransition(transitionState, label = "scene") + + // Track entries from the transition's current state for isPop detection + // (like NavDisplay's transitionCurrentStateEntries) + val transitionCurrentStateEntries = remember(transition.currentState) { + state.backstack.map { it.id } + } + + // Gesture state + val gestureTransition = navigationEventState.transitionState + val inPredictiveBack = gestureTransition is NavigationEventTransitionState.InProgress + // Raw gesture progress is remapped so each gesture starts cleanly from rest before + // being fed into transitionState.seekTo below — see rememberRemappedBackProgress for + // why this matters on iOS (and why it's a no-op on Android). + val progress = rememberRemappedBackProgress(gestureTransition) + + // Calculate previous scene for predictive back (like NavDisplay's previousScene). + // Picks the first non-overlay scene from the eagerly-computed + // sceneState.previousScenes — same semantics as the old calculatePreviousScene. + val activeScene = overlayScenes.lastOrNull() ?: scene + val previousSceneFrame = if (inPredictiveBack && activeScene !is NavigationScene.Overlay) { + sceneState.previousScenes + .firstOrNull { it !is NavigationScene.Overlay } + ?.let { previousScene -> + SceneTransitionFrame(previousScene, state.container.key).also { + sceneMap[it.identity] = previousScene + } + } + } else null + + // Determine if this is a pop (back) navigation (like NavDisplay's isPop) + val isPop = isPop( + transitionCurrentStateEntries, + state.backstack.map { it.id }, + ) + + // Z-index management (like NavDisplay) — keyed by identity, not the + // SceneTransitionFrame wrapper, so the z-index survives across SceneTransitionFrame + // instances that share an identity (i.e. same scene type+key, new + // entries inside). + val initialIdentity = transition.currentState.identity + val targetIdentity = transition.targetState.identity + val initialZIndex = zIndices.getOrPut(initialIdentity) { 0f } + val targetZIndex = when { + initialIdentity == targetIdentity -> initialZIndex + isPop || inPredictiveBack -> initialZIndex - 1f + else -> initialZIndex + 1f + } + zIndices[targetIdentity] = targetZIndex + + // Transition handling (like NavDisplay). We drive on `sceneFrame`, + // not on `sceneIdentity`, so a same-identity scene whose entries + // changed still propagates to the transition's currentState (and + // hence into the AnimatedContent content lambda, so the scene's + // content() runs with the new entries). AnimatedContent will not + // run an enter/exit animation in that case because the contentKey + // (the identity) is unchanged. + if (inPredictiveBack && previousSceneFrame != null) { + // During predictive back, seek to the previous scene based on gesture progress + if (transition.currentState != previousSceneFrame) { + LaunchedEffect(previousSceneFrame, progress) { + transitionState.seekTo(progress, previousSceneFrame) + } + } + } else { + LaunchedEffect(sceneFrame) { + if (transitionState.currentState != sceneFrame) { + // Animate to the new scene + transitionState.animateTo(sceneFrame) + } else { + // Predictive back has either been completed or cancelled + // so now we need to seekTo+snapTo the final state + // (like NavDisplay's settle animation) + + // convert from nanoseconds to milliseconds + val totalDuration = transition.totalDurationNanos / 1000000 + // Which way we have to seek depends on whether the + // predictive back was completed or cancelled + val predictiveBackCompleted = transition.targetState == sceneFrame + val (finalFraction, remainingDuration) = if (predictiveBackCompleted) { + // If it completed, animate to the state we were + // already seeking to with the remaining duration + 1f to ((1f - transitionState.fraction) * totalDuration).toInt() + } else { + // If it got cancelled, animate back to the + // initial state, reversing what we seeked to + 0f to (transitionState.fraction * totalDuration).toInt() + } + animate( + transitionState.fraction, + finalFraction, + animationSpec = tween(remainingDuration), + ) { value, _ -> + this@LaunchedEffect.launch { + if (value != finalFraction) { + // Seek the transition towards the finalFraction + transitionState.seekTo(value) + } + if (value == finalFraction) { + // Once the animation finishes, we need to snap to the right state. + transitionState.snapTo(sceneFrame) + } + } + } + } + } + } + + // Build the per-scene exclusion sets. Mirrors Nav3's + // sceneToExcludedEntryMap exactly: each scene's value is the set + // of entry ids that this scene must NOT render (because a higher-z + // scene already owns them — or, during a pop, because the target + // scene underneath needs to render them so a shared element has + // somewhere to bridge to). + val sceneToExcludedEntryMap = remember( + sceneMap.entries.toList(), + transition.targetState.identity, + zIndices.toString(), + ) { + buildMap { + val scenesByZDescending = sceneMap.entries + .sortedByDescending { zIndices[it.key] ?: 0f } + .toList() + + // If the target isn't the highest-z scene, we're in a pop: + // a higher-z scene is animating out on top of the target. + val highestIdentity = scenesByZDescending.firstOrNull()?.key + val targetIdentity = transition.targetState.identity + val isPopTransition = highestIdentity != null && highestIdentity != targetIdentity + val targetEntryIds = transition.targetState.scene.entries + .map { it.instance.id } + .toSet() + + // `coveredEntryIds` accumulates the entries that have been + // claimed by a higher-z scene as we walk the z-order. Each + // scene's exclusion set is exactly this accumulator at the + // time it's processed. + val coveredEntryIds = mutableSetOf() + scenesByZDescending.forEach { (identity, scene) -> + val sceneEntryIds = scene.entries.map { it.instance.id } + if (isPopTransition && identity != targetIdentity) { + // Popping (higher-z) scene during a pop: exclude + // the target's entries so the target underneath + // renders them. + put(identity, targetEntryIds) + } else { + put(identity, coveredEntryIds.toSet()) + } + // Newly-rendered entries (this scene's entries not yet + // covered, minus what we just excluded) become covered + // for any lower-z scene. + val newlyCovered = sceneEntryIds.filterNot { it in coveredEntryIds } + if (isPopTransition && identity != targetIdentity) { + coveredEntryIds.addAll(newlyCovered.filterNot { it in targetEntryIds }) + } else { + coveredEntryIds.addAll(newlyCovered) + } + } + + // During a pop the target's exclusion set was computed + // ignoring its own swap rule above; override it to empty + // so the target renders everything in its entries list, + // including entries that the popping scene above it lists. + if (isPopTransition) { + put(targetIdentity, emptySet()) + } + } + } + + // Capture the swipe edge from the latest predictive-back event so + // per-scene PredictivePopTransitionKey specs and the NavigationAnimations + // default both have access to it. Mirrors Nav3's swipeEdge plumbing. + val swipeEdge = when (val gt = gestureTransition) { + is NavigationEventTransitionState.Idle -> NavigationEvent.EDGE_NONE + is NavigationEventTransitionState.InProgress -> gt.latestEvent.swipeEdge + } + + // The scene whose metadata governs the transition: when the target has + // a higher z-index (push) we read off the target; when the initial is + // higher (pop) we read off the initial. Matches Nav3's `transitionScene`. + val transitionScene = if (initialZIndex >= targetZIndex) { + transition.currentState.scene + } else { + transition.targetState.scene + } + + // Select the appropriate transition spec based on navigation type. + // Priority: per-scene metadata override > container-level NavigationAnimations default. + val contentTransform: AnimatedContentTransitionScope.() -> ContentTransform = { + val isDifferentContainer = initialState.containerKey != targetState.containerKey + val useContainerTransition = when { + isDifferentContainer -> true + !animations.emptyUsesContainerTransition -> false + initialState.visible.isEmpty() && targetState.visible.isNotEmpty() -> true + initialState.visible.isNotEmpty() && targetState.visible.isEmpty() -> true + else -> false + } + when { + useContainerTransition -> animations.containerTransitionSpec(this) + inPredictiveBack -> { + transitionScene.metadata[NavigationDisplay.PredictivePopTransitionKey] + ?.invoke(this, swipeEdge) + ?: animations.predictivePopTransitionSpec(this, swipeEdge) + } + + isPop -> { + transitionScene.metadata[NavigationDisplay.PopTransitionKey] + ?.invoke(this) + ?: animations.popTransitionSpec(this) + } + + else -> { + transitionScene.metadata[NavigationDisplay.TransitionKey] + ?.invoke(this) + ?: animations.transitionSpec(this) + } + } + } + + // Render the navigation content + CompositionLocalProvider( + LocalNavigationContainer provides state, + LocalNavigationContext provides state.context, + ) { + WithSharedTransitionScope(sharedTransitionScope) sharedScope@{ + transition.AnimatedContent( + // contentKey collapses SceneTransitionFrame -> SceneIdentity, so + // a new SceneTransitionFrame with the same identity (e.g. a + // TwoPaneScene where the right-pane entry changed) + // reuses the same AnimatedContent slot and does not + // run an enter/exit transition. Within that slot, + // Compose still recomposes with the new SceneTransitionFrame's + // scene.content(), so updated entries render. + contentKey = { it.identity }, + contentAlignment = contentAlignment, + modifier = modifier, + transitionSpec = { + ContentTransform( + targetContentEnter = contentTransform(this).targetContentEnter, + initialContentExit = contentTransform(this).initialContentExit, + // z-index increases during navigate and decreases during pop + targetContentZIndex = zIndices[transition.targetState.identity] ?: 0f, + sizeTransform = sizeTransform, + ) + } + ) { targetState -> + val targetScene = targetState.scene + val targetIdentityForContent = targetState.identity + // Provide necessary composition locals for the scene content + CompositionLocalProvider( + LocalNavigationAnimatedVisibilityScope provides this@AnimatedContent, + LocalNavigationAnimatedVisibilityScopeOrNull provides this@AnimatedContent, + LocalNavigationSharedTransitionScope provides this@sharedScope, + LocalNavigationSharedTransitionScopeOrNull provides this@sharedScope, + LocalEntriesToExcludeFromCurrentScene provides + (sceneToExcludedEntryMap[targetIdentityForContent] ?: emptySet()) + ) { + targetScene.content() + } + } + } + + // Clean up scene book-keeping once the transition is finished (like NavDisplay) + LaunchedEffect(transition) { + snapshotFlow { transition.isRunning } + .filter { !it } + .collect { + val currentTargetIdentity = transition.targetState.identity + // Creating a copy to avoid ConcurrentModificationException + sceneMap.keys.toList().forEach { key -> + if (key != currentTargetIdentity) { + sceneMap.remove(key) + } + } + // Creating a copy to avoid ConcurrentModificationException + zIndices.keys.toList().forEach { key -> + if (key != currentTargetIdentity) { + zIndices.remove(key) + } + } + } + } + + // Update settled state based on transition progress + LaunchedEffect(transition.currentState, transition.targetState) { + val settled = transition.currentState == transition.targetState + state.isSettled = settled + } + + // Show all overlay scenes above the AnimatedContent (like NavDisplay) + RenderOverlayScenes(overlayScenes) + } +} + +/** + * Runs [content] inside a [SharedTransitionScope]. If [external] is + * supplied, the caller already owns a `SharedTransitionLayout` and we + * just use their scope (so shared elements bridge across whatever they + * wrap — typically multiple displays in a tab layout). Otherwise we + * create our own internal `SharedTransitionLayout` around the content. + * + * Mirrors Nav3's `NavDisplay(sharedTransitionScope = null)` default + * versus user-provided overload. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun WithSharedTransitionScope( + external: SharedTransitionScope?, + content: @Composable SharedTransitionScope.() -> Unit, +) { + if (external != null) { + with(external) { content() } + } else { + SharedTransitionLayout { content() } + } +} + +@Composable +internal fun SceneRecompositionDebugger( + scene: NavigationScene, + overlayScenes: List, + destinations: List>, +) { + val sceneHashes = remember { + mutableStateOf( + SceneHash( + scene = scene, + overlayScenes = overlayScenes, + destinationIds = destinations.map { it.id }.toSet() + ) + ) + } + val recompositionCount = remember { mutableStateOf(0) } + SideEffect { + val updatedIds = destinations.map { it.id }.toSet() + val isSameDestinations = sceneHashes.value.destinationIds == updatedIds + val isSameScenes = sceneHashes.value.scene == scene && sceneHashes.value.overlayScenes == overlayScenes + if (isSameDestinations && !isSameScenes) { + recompositionCount.value++ + } else { + recompositionCount.value = 0 + } + if (recompositionCount.value > 10) { + EnroLog.error("Scenes have changed but destinations have not, causing a recomposition. This may be a bug, caused by a SceneStrategy.calculateScene returning a different scene instance for the same destinations.") + recompositionCount.value = 0 + } + sceneHashes.value = SceneHash( + scene = scene, + overlayScenes = overlayScenes, + destinationIds = updatedIds, + ) + } +} + +private data class SceneHash( + val scene: NavigationScene, + val overlayScenes: List, + val destinationIds: Set, +) + +/** + * Sets up handling for predictive back gestures. + * Monitors back gesture events and updates the state accordingly. + * + * @param scene The current scene (including overlays) + * @param state The navigation container state to update + */ +@Composable +private fun HandlePredictiveBack( + navigationEventState: NavigationEventState, + scene: NavigationScene, + state: NavigationContainerState, +) { + val backstack = state.backstack + val isEnabled = remember(scene.previousEntries, backstack) { + if (scene.previousEntries.isNotEmpty()) return@remember true + state.emptyBehavior.isBackHandlerEnabled(backstack) + } + + NavigationBackHandler( + state = navigationEventState, + isBackEnabled = isEnabled, + onBackCancelled = { + // Process the canceled back gesture + }, + onBackCompleted = { + val previousIds = scene.previousEntries + .map { it.instance.id } + .toSet() + + val toCloseDestinations = state.context.children.filter { + !previousIds.contains(it.id) + } + toCloseDestinations.forEach { + it.getNavigationHandle().requestClose() + } + } + ) +} + +/** + * Remaps the raw predictive-back gesture progress carried by [gestureTransition] so that + * every gesture begins from rest (0f) and tracks the finger 1:1 from there, returning 0f + * whenever no gesture is in progress. + * + * The remapped value is fed directly into + * [androidx.compose.animation.core.SeekableTransitionState.seekTo], which positions the + * transition *instantly* rather than animating towards it. On Android the platform supplies + * progress that eases up from 0, so seeking is smooth. On iOS (and any platform whose + * gesture is sourced from a UIKit screen-edge pan recognizer) the recognizer only begins + * emitting events *after* its recognition slop has been crossed, so the first non-zero + * progress arrives as a discrete jump (often ~0.1–0.25). Seeking straight to that value + * snaps the scene to ~20% in a single frame, which reads as the gesture "doing nothing, + * then jumping". + * + * To smooth this, the first non-zero progress of each gesture is captured as an anchor and + * the remainder is rescaled to `[0, 1]` via `(progress - anchor) / (1 - anchor)`. The + * animation therefore starts at rest on the first frame that produces motion and follows + * the finger for the rest of the drag. On Android the anchor is effectively 0, so the + * rescale is a no-op. + */ +@Composable +private fun rememberRemappedBackProgress( + gestureTransition: NavigationEventTransitionState, +): Float { + val inProgress = gestureTransition is NavigationEventTransitionState.InProgress + val rawProgress = when (gestureTransition) { + is NavigationEventTransitionState.Idle -> 0f + is NavigationEventTransitionState.InProgress -> gestureTransition.latestEvent.progress + } + // Gesture-scoped anchor: the first non-zero raw progress seen during the current + // gesture, or null when no gesture is active. Updated during composition (matching the + // surrounding NavigationDisplay's existing write-during-composition style); the write + // happens at most once per gesture edge. + val anchor = remember { mutableStateOf(null) } + if (!inProgress) { + if (anchor.value != null) anchor.value = null + } else if (anchor.value == null && rawProgress > 0f) { + anchor.value = rawProgress + } + return when (val anchorValue = anchor.value) { + null -> 0f + // coerceAtLeast guards the degenerate case where the gesture is first reported + // already at (or past) full progress, which would otherwise divide by zero. + else -> ((rawProgress - anchorValue) / (1f - anchorValue).coerceAtLeast(1e-4f)) + .coerceIn(0f, 1f) + } +} + +public interface SceneTransitionData { + public val containerKey: NavigationContainer.Key + public val visible: List> + public val previouslyVisible: List> +} + +/** + * Identifies a scene's slot in AnimatedContent. Combines the scene type + * with its strategy-controlled key and the owning container. Two scenes + * with the same SceneIdentity reuse the same AnimatedContent slot — no + * enter/exit animation fires when transitioning between them, even if + * the underlying NavigationScene instance (and its entries) have + * changed. This mirrors Nav3's `AnimatedSceneKey` and gives scene + * strategies control over re-animation through `scene.key`. + */ +private data class SceneIdentity( + val sceneType: KClass<*>, + val key: Any, + val containerKey: NavigationContainer.Key, +) + +/** + * The transition state flowing through the AnimatedContent for the + * NavigationDisplay. Carries the live scene reference plus the + * SceneTransitionData fields the transition spec inspects. Equality is + * based on the scene reference + container key, so a `remember`-stable + * scene (returned by a typical scene strategy) won't churn the + * transition state across recompositions. + */ +private class SceneTransitionFrame( + val scene: NavigationScene, + override val containerKey: NavigationContainer.Key, +) : SceneTransitionData { + override val visible: List> + get() = scene.entries.map { it.instance } + override val previouslyVisible: List> + get() = scene.previousEntries.map { it.instance } + + val identity: SceneIdentity = SceneIdentity( + sceneType = scene::class, + key = scene.key, + containerKey = containerKey, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SceneTransitionFrame) return false + return scene == other.scene && containerKey == other.containerKey + } + + override fun hashCode(): Int = 31 * scene.hashCode() + containerKey.hashCode() +} + +/** + * Renders overlay scenes (like dialogs) on top of the main content. + * Each overlay gets its own SharedTransitionLayout for independent animations. + * + * @param overlayScenes List of overlay scenes to render + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun RenderOverlayScenes(overlayScenes: List) { + // Track overlay scenes across recompositions. A scene that has just + // left `overlayScenes` (e.g. its destination was popped from the + // backstack) still appears in `rendered` until its exit transition + // settles — so the renderer can keep calling its `content()` and the + // destination's lifecycle decorator can run the proper teardown + // sequence after the animation, not in the middle of it. + val rendered = remember { mutableStateMapOf() } + // Stacking order is insertion order: later-added scenes render on + // top. We retain entries here even after they've left the active + // overlay list so the per-scene `AnimatedVisibility` can play its + // exit transition; cleanup happens via `onFullyHidden` below. + val keysInOrder = remember { mutableStateListOf() } + + overlayScenes.forEach { scene -> + if (scene.key !in rendered) keysInOrder.add(scene.key) + // Always refresh the stored scene — its `previousEntries` / + // `entries` may have changed even when the key stayed the same. + rendered[scene.key] = scene + } + val activeKeys = overlayScenes.map { it.key }.toSet() + + // Iterate over a snapshot of the ordered list so removals inside + // `onFullyHidden` don't perturb the current pass. + keysInOrder.toList().forEach { key -> + val scene = rendered[key] ?: return@forEach + key(scene.key) { + OverlaySceneRenderer( + scene = scene, + visible = key in activeKeys, + onFullyHidden = { + rendered.remove(key) + keysInOrder.remove(key) + }, + ) + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun OverlaySceneRenderer( + scene: NavigationScene.Overlay, + visible: Boolean, + onFullyHidden: () -> Unit, +) { + val visibleState = remember { mutableStateOf(false) } + val transitions = scene.overlayTransitions() + val transition = updateTransition(targetState = visibleState.value, label = "OverlayVisibility") + + LaunchedEffect(visible) { + visibleState.value = visible + } + // The exit transition has settled once `transition.currentState` is + // false AND the transition is no longer running. Before dropping the + // scene from tracking, invoke its suspending onRemove() — mirrors + // Nav3's OverlayScene.onRemove contract (runs after the scene is + // popped from the backstack, before it leaves composition). + // + // `visible` (the incoming parameter) acts as the pre-entry guard: + // `visibleState` starts at false and is only promoted to true by the + // LaunchedEffect above, so initial composition lands at + // (currentState=false, isRunning=false) before the visibility setter + // has had a chance to run. Without the `visible` check, that + // transient triggers the onRemove path spuriously on every entry. + // When the overlay is genuinely being popped, the caller passes + // visible=false, the keys change again, and the block proceeds. + LaunchedEffect(transition.currentState, transition.isRunning) { + if (transition.currentState) return@LaunchedEffect + if (transition.isRunning) return@LaunchedEffect + if (visible) return@LaunchedEffect + scene.onRemove() + onFullyHidden() + } + + SharedTransitionLayout { + transition.AnimatedVisibility( + visible = { it }, + enter = transitions?.enter ?: EnterTransition.None, + exit = transitions?.exit ?: ExitTransition.None, + ) { + CompositionLocalProvider( + LocalNavigationAnimatedVisibilityScope provides this@AnimatedVisibility, + LocalNavigationAnimatedVisibilityScopeOrNull provides this@AnimatedVisibility, + LocalNavigationSharedTransitionScope provides this@SharedTransitionLayout, + LocalNavigationSharedTransitionScopeOrNull provides this@SharedTransitionLayout, + // Overlay scenes render all their entries (no z-order + // contention with sibling overlays in this composition). + LocalEntriesToExcludeFromCurrentScene provides emptySet(), + ) { + scene.content() + } + } + } +} + +/** + * Reads the optional [OverlayTransitions] off the scene's top entry, + * if it opted in via `directOverlay(enter, exit)` / + * `directOverlayWithFade()`. Returns null when the scene wants the + * legacy snap-in / snap-out treatment. + */ +private fun NavigationScene.Overlay.overlayTransitions(): OverlayTransitions? { + val entry = entries.lastOrNull() ?: return null + return entry.metadata[DirectOverlaySceneStrategy.OverlayTransitionsKey] +} + +/** + * Determines if a navigation operation is a "pop" (back navigation). + * + * A pop is detected when: + * - The backstacks share the same root + * - The new backstack is shorter than the old one + * - The new backstack is a prefix of the old backstack + * + * @param oldBackStack The previous backstack state + * @param newBackStack The new backstack state + * @return true if this is a back navigation, false otherwise + */ +private fun isPop(oldBackStack: List, newBackStack: List): Boolean { + if (oldBackStack.isEmpty() || newBackStack.isEmpty()) return false + // entire stack replaced + if (oldBackStack.first() != newBackStack.first()) return false + // navigated + if (newBackStack.size > oldBackStack.size) return false + + val divergingIndex = newBackStack.indices.firstOrNull { index -> + newBackStack[index] != oldBackStack[index] + } + // if newBackStack never diverged from oldBackStack, then it is a clean subset of the oldStack + // and is a pop + return divergingIndex == null && newBackStack.size != oldBackStack.size +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationScene.kt new file mode 100644 index 000000000..d7ab122f3 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationScene.kt @@ -0,0 +1,95 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import dev.enro.NavigationKey + +@Immutable +@Stable +public interface NavigationScene { + public val key: Any + public val entries: List> + public val previousEntries: List> + public val content: @Composable () -> Unit + + /** + * Optional scene-level metadata. Used by [NavigationDisplay] to look up + * per-scene transition overrides (e.g. [NavigationDisplay.TransitionKey]) + * before falling back to the container-level animations. + * + * Defaults to the metadata of the last entry, mirroring Nav3's + * `Scene.metadata` default. Scene strategies that want to expose their + * own metadata (or compose it from the entries) should override. + */ + public val metadata: Map + get() = entries.lastOrNull()?.metadata ?: emptyMap() + + /** + * A typed key for values stored in a [NavigationScene]'s metadata map. + * Parallel to [NavigationDestination.MetadataKey] — same shape, just + * scoped to scene-level metadata. + * + * Example: + * ``` + * object MyKey : NavigationScene.MetadataKey(default = null) + * scene.metadata[MyKey] // typed access + * ``` + */ + public abstract class MetadataKey( + public val default: T, + ) { + public val name: String by lazy { + this::class.qualifiedName ?: error("MetadataKeys must have a valid qualifiedName") + } + } + + /** + * A specific scene to render 1 or more NavigationDestination instances as an overlay. + * + * It is expected that the [content] is rendered in one or more separate windows (e.g., a dialog, + * popup window, etc.) that are visible above any additional [NavigationScene] instances calculated from the + * [overlaidEntries]. + * + * When processing [overlaidEntries], expect processing of each [NavigationSceneStrategy] to restart from the + * first strategy. This may result in multiple instances of the same [OverlayNavigationScene] to be shown + * simultaneously, making a unique [key] even more important. + */ + public interface Overlay : NavigationScene { + + /** + * The NavigationDestination entries that should be handled by another [NavigationScene] that sits below this Scene. + * + * This *must* always be a non-empty list to correctly display entries below the overlay. + */ + public val overlaidEntries: List> + + /** + * Invoked after this overlay has been popped from the backstack but + * **before** it leaves composition. The overlay renderer awaits the + * returned suspending value so the overlay can play its dismissal + * animation (or any other suspending teardown work) without being + * yanked out of composition mid-animation. + * + * Mirrors Nav3's `OverlayScene.onRemove`. Default is a no-op. + */ + public suspend fun onRemove() {} + } +} + +/** + * Typed accessor for a value stored under a [NavigationScene.MetadataKey]. + * Returns [NavigationScene.MetadataKey.default] when the key is absent. + */ +@Suppress("UNCHECKED_CAST") +public operator fun Map.get(key: NavigationScene.MetadataKey): T { + return get(key.name) as T? ?: key.default +} + +/** + * Returns `true` if this metadata map contains a value for [key]. + */ +public operator fun Map.contains(key: NavigationScene.MetadataKey): Boolean { + return containsKey(key.name) +} + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneInfo.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneInfo.kt new file mode 100644 index 000000000..6a0f91b26 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneInfo.kt @@ -0,0 +1,23 @@ +package dev.enro.ui + +import androidx.compose.runtime.Immutable +import androidx.navigationevent.NavigationEventInfo + +/** + * A snapshot of the active [NavigationScene] for use with the navigation + * event system. Mirrors Nav3's `SceneInfo`: a typed [NavigationEventInfo] + * that lets back handlers (predictive or otherwise) reason about which scene + * is currently rendering. + */ +@Immutable +public class NavigationSceneInfo(public val scene: NavigationScene) : NavigationEventInfo() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is NavigationSceneInfo) return false + return scene == other.scene + } + + override fun hashCode(): Int = scene.hashCode() + + override fun toString(): String = "NavigationSceneInfo(scene=$scene)" +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneState.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneState.kt new file mode 100644 index 000000000..357336005 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneState.kt @@ -0,0 +1,194 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import dev.enro.NavigationKey +import dev.enro.ui.scenes.EmptyNavigationScene +import dev.enro.ui.scenes.calculateSceneWithSinglePaneFallback + +/** + * A snapshot of the scene hierarchy for a [NavigationContainerState]. + * + * Computed by [rememberNavigationSceneState], consumed by + * [NavigationDisplay]. Mirrors Nav3's `SceneState` — same fields, + * same semantics, same eager `previousScenes` walk for predictive back. + * + * Hoist it from your composable if you want to inspect what + * [NavigationDisplay] would render before it actually renders (e.g. + * for tests, conditional UI, or to share scene calculation across + * multiple displays). Otherwise [NavigationDisplay] computes its own + * internally. + * + * @property entries the decorated destinations that fed into the scene + * calculation + * @property overlayScenes any overlay scenes layered on top of + * [currentScene] (e.g. dialogs, bottom sheets) + * @property currentScene the bottom-most non-overlay scene to render + * @property previousScenes the chain of scenes produced by repeatedly + * walking `previousEntries` from [currentScene]. Used by predictive + * back to know what to reveal underneath the popping scene. Ordered + * from "one step back" first to "deepest" last. + */ +@Immutable +public class NavigationSceneState internal constructor( + public val entries: List>, + public val overlayScenes: List, + public val currentScene: NavigationScene, + public val previousScenes: List, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is NavigationSceneState) return false + return entries == other.entries && + overlayScenes == other.overlayScenes && + currentScene == other.currentScene && + previousScenes == other.previousScenes + } + + override fun hashCode(): Int { + var result = entries.hashCode() + result = 31 * result + overlayScenes.hashCode() + result = 31 * result + currentScene.hashCode() + result = 31 * result + previousScenes.hashCode() + return result + } + + override fun toString(): String { + return "NavigationSceneState(entries=$entries, overlayScenes=$overlayScenes, " + + "currentScene=$currentScene, previousScenes=$previousScenes)" + } +} + +/** + * Computes the [NavigationSceneState] for a [NavigationContainerState] by + * resolving the overlay chain (top-down until a non-overlay scene is found) + * and eagerly walking `previousEntries` for predictive back. Mirrors Nav3's + * `rememberSceneState`. + * + * [onBack] is what the [SceneStrategyScope.onBack] handed to each strategy + * invokes. Typically connected to the surrounding [NavigationDisplay]'s + * navigation event system so scene-internal back affordances (drag-to-dismiss + * sheets, custom close gestures) feed back into the regular backstack. + */ +@Composable +public fun rememberNavigationSceneState( + containerState: NavigationContainerState, + sceneStrategy: NavigationSceneStrategy, + onBack: () -> Unit, + sceneDecoratorStrategies: List = emptyList(), +): NavigationSceneState { + val currentOnBack by rememberUpdatedState(onBack) + val scope = remember { SceneStrategyScope(onBack = { currentOnBack() }) } + val decoratorScope = remember { SceneDecoratorStrategyScope(onBack = { currentOnBack() }) } + val destinations = containerState.destinations + + // Containers configured with `EmptyBehavior.allowEmpty()` legitimately + // hit a state with no destinations. Short-circuit to a no-op scene + // here so every `NavigationSceneStrategy.calculateScene` implementation + // is guaranteed a non-empty entries list — same invariant Nav3 enforces + // at the `NavDisplay(backStack)` boundary, just pushed one frame in. + if (destinations.isEmpty()) { + return NavigationSceneState( + entries = emptyList(), + overlayScenes = emptyList(), + currentScene = EmptyNavigationScene, + previousScenes = emptyList(), + ) + } + + val resolved = resolveSceneChain(scope, destinations, sceneStrategy) + val overlayScenes = resolved.dropLast(1).filterIsInstance() + val currentScene = applyDecorators(decoratorScope, resolved.last(), sceneDecoratorStrategies, containerState) + val previousScenes = computePreviousScenes(scope, decoratorScope, currentScene, sceneStrategy, sceneDecoratorStrategies, containerState) + return NavigationSceneState( + entries = destinations, + overlayScenes = overlayScenes, + currentScene = currentScene, + previousScenes = previousScenes, + ) +} + +/** + * Folds the chain of [SceneDecoratorStrategy] over [scene] in order + * (first decorator becomes the outermost wrapper). Overlay scenes are + * skipped — mirroring Nav3, which only applies scene decorators to + * non-overlay scenes. + * + * Each `decorateScene` invocation runs inside a `CompositionLocalProvider` + * for [LocalNavigationContainer] / [LocalNavigationContext] so a decorator + * can read either local directly — e.g. to branch on the live backstack + * before deciding what scene to return. Without this, the decorator + * composes outside the provider [NavigationDisplay] sets up later for + * scene-content rendering, so `LocalNavigationContainer.current` would + * throw. + */ +@Composable +private fun applyDecorators( + scope: SceneDecoratorStrategyScope, + scene: NavigationScene, + sceneDecoratorStrategies: List, + containerState: NavigationContainerState, +): NavigationScene { + if (scene is NavigationScene.Overlay) return scene + var result = scene + for (decorator in sceneDecoratorStrategies) { + var decorated: NavigationScene = result + CompositionLocalProvider( + LocalNavigationContainer provides containerState, + LocalNavigationContext provides containerState.context, + ) { + decorated = with(decorator) { scope.decorateScene(result) } + } + result = decorated + } + return result +} + +/** + * Resolves the overlay chain starting from `destinations`, returning a list + * ordered [top-most ... bottom-most]. The last element is always a + * non-overlay scene. + */ +@Composable +private fun resolveSceneChain( + scope: SceneStrategyScope, + destinations: List>, + sceneStrategy: NavigationSceneStrategy, +): List { + val allScenes = mutableListOf() + allScenes += sceneStrategy.calculateSceneWithSinglePaneFallback(scope, destinations) + while (true) { + val last = allScenes.last() + if (last !is NavigationScene.Overlay || last.overlaidEntries.isEmpty()) break + allScenes += sceneStrategy.calculateSceneWithSinglePaneFallback(scope, last.overlaidEntries) + } + return allScenes +} + +/** + * Walks `previousEntries` until empty, producing the list of scenes that + * would be revealed by successive pops. + */ +@Composable +private fun computePreviousScenes( + scope: SceneStrategyScope, + decoratorScope: SceneDecoratorStrategyScope, + scene: NavigationScene, + sceneStrategy: NavigationSceneStrategy, + sceneDecoratorStrategies: List, + containerState: NavigationContainerState, +): List { + val result = mutableListOf() + var entries = scene.previousEntries + while (entries.isNotEmpty()) { + val previous = sceneStrategy.calculateSceneWithSinglePaneFallback(scope, entries) + val decorated = applyDecorators(decoratorScope, previous, sceneDecoratorStrategies, containerState) + result += decorated + entries = decorated.previousEntries + } + return result +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneStrategy.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneStrategy.kt new file mode 100644 index 000000000..fef55d130 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneStrategy.kt @@ -0,0 +1,43 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import dev.enro.NavigationKey + +@Stable +public fun interface NavigationSceneStrategy { + /** + * Try to construct a [NavigationScene] from the given [entries]. Return + * `null` to defer to the next strategy in the chain. The receiver + * [SceneStrategyScope] gives the strategy a callback to dispatch back + * navigation back into the surrounding [NavigationDisplay]. + * + * Mirrors Nav3's `SceneStrategy.calculateScene` shape (receiver + + * entries argument). + */ + @Composable + public fun SceneStrategyScope.calculateScene( + entries: List>, + ): NavigationScene? + + public companion object { + /** + * Chains a list of strategies so the first non-null result wins. + * Mirrors the `List>` overloads of `NavDisplay`. + */ + public fun from( + sceneStrategies: List, + ): NavigationSceneStrategy { + return NavigationSceneStrategy { entries -> + val scope = this + sceneStrategies.firstNotNullOfOrNull { strategy -> + with(strategy) { scope.calculateScene(entries) } + } + } + } + + public fun from( + vararg sceneStrategies: NavigationSceneStrategy, + ): NavigationSceneStrategy = from(sceneStrategies.toList()) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/SceneDecoratorStrategy.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/SceneDecoratorStrategy.kt new file mode 100644 index 000000000..e782226b6 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/SceneDecoratorStrategy.kt @@ -0,0 +1,40 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable + +/** + * Scope passed into [SceneDecoratorStrategy.decorateScene]. Extends + * [SceneStrategyScope] so a scene decorator strategy can plug its own + * back-handling into the surrounding [NavigationDisplay] (e.g. a + * navigation-drawer decorator that handles its own drawer-close + * gesture). + * + * Mirrors Nav3's `SceneDecoratorStrategyScope`. + */ +public class SceneDecoratorStrategyScope internal constructor( + onBack: () -> Unit, +) : SceneStrategyScope(onBack) { + public constructor() : this(onBack = {}) +} + +/** + * A strategy that wraps a [NavigationScene] in another [NavigationScene], + * typically to layer chrome around it (navigation drawer, app bar, nav + * rail, side sheet — anything that owns layout space but defers actual + * entry rendering to the wrapped inner scene). + * + * Decorator strategies are applied AFTER [NavigationSceneStrategy] + * chains have selected the scene for the current entries, and ONLY to + * non-overlay scenes. Overlays are animated separately from the main + * scene so wrapping them in chrome wouldn't compose meaningfully. + * + * Mirrors Nav3's `SceneDecoratorStrategy`. + */ +public fun interface SceneDecoratorStrategy { + /** + * Decorates [scene] and returns the (possibly wrapped) scene to + * render. Return [scene] unchanged to skip decoration. + */ + @Composable + public fun SceneDecoratorStrategyScope.decorateScene(scene: NavigationScene): NavigationScene +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/SceneStrategyScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/SceneStrategyScope.kt new file mode 100644 index 000000000..e04ce6203 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/SceneStrategyScope.kt @@ -0,0 +1,36 @@ +package dev.enro.ui + +import androidx.compose.runtime.Immutable + +/** + * Scope passed into [NavigationSceneStrategy.calculateScene] so the + * strategy can plug back-handling done inside its scene (custom + * gestures, "X" close buttons in a dialog, drag-to-dismiss sheets) + * into the surrounding [NavigationDisplay]'s navigation event system. + * + * Mirrors Nav3's `SceneStrategyScope`. + */ +@Immutable +public open class SceneStrategyScope internal constructor( + /** + * Invoke when the scene wants to pop the current top entry — + * typically because the user dismissed the scene through its + * internal affordance (drag-down on a sheet, tap-outside on a + * dialog, custom back gesture). + * + * The surrounding [NavigationDisplay] receives the event and + * applies it to the backstack the same way a hardware back press + * would. If you need different semantics (close a flow, navigate + * to a sibling), use a [dev.enro.NavigationHandle] directly + * instead. + */ + public val onBack: () -> Unit, +) { + /** + * Constructs a [SceneStrategyScope] suitable for calling a + * strategy in isolation (e.g. tests). Real consumers get one + * automatically from [NavigationDisplay] / + * [rememberNavigationSceneState]. + */ + public constructor() : this(onBack = {}) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/Modifier.animateNavigationEnterExit.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/Modifier.animateNavigationEnterExit.kt new file mode 100644 index 000000000..444de1fce --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/Modifier.animateNavigationEnterExit.kt @@ -0,0 +1,38 @@ +package dev.enro.ui.animation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import dev.enro.ui.LocalNavigationAnimatedVisibilityScopeOrNull + +/** + * Attaches enter / exit transitions to a composable, tied to the + * surrounding navigation destination's [AnimatedVisibilityScope][androidx.compose.animation.AnimatedVisibilityScope]. + * + * Use this from inside a navigation destination when a particular + * piece of its content needs its own staggered animation alongside + * the destination-level transition (for example, fading the scrim + * at a different rate than the card it sits behind). The destination + * itself drives the visibility flip; this modifier just plumbs the + * caller's transitions into the active scope. + * + * Reads [LocalNavigationAnimatedVisibilityScopeOrNull]. Normally that + * local is provided by `NavigationDisplay`'s overlay renderer, so + * in-app the modifier always finds a scope. When it isn't provided — + * typical of Paparazzi / snapshot tests that render a composable in + * isolation without an Enro container — the modifier degrades to a + * no-op rather than crashing, so design-system surfaces (dialogs, + * popups) can still be rendered standalone for documentation + * snapshots. + */ +public fun Modifier.animateNavigationEnterExit( + enter: EnterTransition = EnterTransition.None, + exit: ExitTransition = ExitTransition.None, +): Modifier = composed { + val scope = LocalNavigationAnimatedVisibilityScopeOrNull.current + ?: return@composed this@composed + scope.run { + this@composed.animateEnterExit(enter = enter, exit = exit) + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/NavigationAnimatedVisibility.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/NavigationAnimatedVisibility.kt new file mode 100644 index 000000000..c7816394e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/NavigationAnimatedVisibility.kt @@ -0,0 +1,60 @@ +package dev.enro.ui.animation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.enro.ui.LocalNavigationAnimatedVisibilityScopeOrNull + +/** + * Wraps [content] in an `AnimatedVisibility` whose visibility is tied + * to the surrounding navigation destination's + * [AnimatedVisibilityScope][LocalNavigationAnimatedVisibilityScopeOrNull]. + * + * When the scope is provided (the normal in-app case under + * `NavigationDisplay`), the AnimatedVisibility's `visible` flag is + * driven by the destination's transition — content animates in/out + * alongside the surrounding scene. + * + * When the scope isn't provided (e.g. Paparazzi snapshot tests + * rendering a piece standalone), [content] is rendered directly in a + * [Box] applying [modifier] — there's nothing to animate against, so + * we just show it. Without this fallback design-system snapshots for + * any composable that uses this wrapper would crash. + */ +@Composable +public fun NavigationAnimatedVisibility( + modifier: Modifier = Modifier, + enter: EnterTransition = fadeIn() + expandIn(), + exit: ExitTransition = shrinkOut() + fadeOut(), + content: @Composable AnimatedVisibilityScope.() -> Unit, +) { + val scope = LocalNavigationAnimatedVisibilityScopeOrNull.current + if (scope == null) { + // Snapshot / standalone path — render the content directly, + // anchoring to the same `AnimatedVisibilityScope` contract by + // delegating to a self-contained `AnimatedVisibility(true)`. + AnimatedVisibility( + visible = true, + modifier = modifier, + enter = enter, + exit = exit, + content = content, + ) + return + } + scope.transition.AnimatedVisibility( + visible = { it == EnterExitState.Visible }, + modifier = modifier, + enter = enter, + exit = exit, + content = content, + ) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/rememberTransitionCompat.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/rememberTransitionCompat.kt new file mode 100644 index 000000000..b932681b9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/animation/rememberTransitionCompat.kt @@ -0,0 +1,53 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro.ui.animation + +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.TransitionState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume + +/** + * For some reason, it appears that rememberTransition does not correctly work in Compose for Web, + * and will call the "remember" function multiple times, even though the key is the same. This + * appears to be a bug in Compose for Web, and not in Enro. This function is a workaround for that, + * and is copied from the implementation of rememberTransition in the Compose libraries, but uses + * the hashCode of the TransitionState as the key, rather than the TransitionState itself. + * + * Copied from androidx.compose.animation.core.rememberTransition + */ +@Composable +internal fun rememberTransitionCompat( + transitionState: TransitionState, + label: String? = null +): Transition { + // ! USING transitionState.hashCode() AS KEY TO AVOID BUG IN COMPOSE FOR WEB ! + val transition = remember(transitionState.hashCode()) { + Transition(transitionState = transitionState, label) + } + if (transitionState is SeekableTransitionState) { + LaunchedEffect(transitionState.currentState, transitionState.targetState) { + transitionState.observeTotalDuration() + transitionState.compositionContinuationMutex.withLock { + transitionState.composedTargetState = transitionState.targetState + transitionState.compositionContinuation?.resume(transitionState.targetState) + transitionState.compositionContinuation = null + } + } + } else { + transition.animateTo(transitionState.targetState) + } + DisposableEffect(transition) { + onDispose { + // Clean up on the way out, to ensure the observers are not stuck in an in-between + // state. + transition.onDisposed() + } + } + return transition +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationDestinationDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationDestinationDecorator.kt new file mode 100644 index 000000000..9cf57f853 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationDestinationDecorator.kt @@ -0,0 +1,65 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination + +/** + * A decorator that wraps navigation destinations to provide additional functionality + * such as lifecycle management, state preservation, or visual effects. + * + * Parameter names ([onPop], [decorate]) mirror Nav3's `NavEntryDecorator` so a + * decorator written against Nav3 can almost copy-paste over. See + * `docs/NAV3-COMPARISON.md` for the broader alignment rationale. + * + * @param T The type of NavigationKey this decorator can handle + * @property onPop Called when the destination is popped from the backstack and + * has left composition. Mirrors Nav3's `NavEntryDecorator.onPop`. + * @property decorate The composable function that wraps the destination content. + * Mirrors Nav3's `NavEntryDecorator.decorate`. + */ +public open class NavigationDestinationDecorator( + internal val onPop: (key: NavigationKey.Instance) -> Unit, + internal val decorate: @Composable (destination: NavigationDestination) -> Unit, +) + +/** + * Creates a [NavigationDestinationDecorator] with the provided lifecycle callback and decorate function. + * + * @param onPop Called when the destination is popped from the backstack and has left composition. + * @param decorate The composable function that wraps the destination content. + */ +public fun navigationDestinationDecorator( + onPop: (key: NavigationKey.Instance) -> Unit = {}, + decorate: @Composable (destination: NavigationDestination) -> Unit, +): NavigationDestinationDecorator = NavigationDestinationDecorator(onPop, decorate) + +/** + * Applies a list of decorators to a navigation destination, wrapping it in the order provided. + * Decorators are applied from first to last, meaning the first decorator in the list will be + * the outermost wrapper. + * + * For composition-tracking + `onPop`-firing semantics, append a `compositionTrackingDecorator` + * to the decorator list as the **last** element (innermost wrap). See + * `docs/NAV3-COMPARISON.md` for why Enro tracks composition from inside the chain rather than + * from an outer `DisposableEffect`, the way Nav3's `decorateEntry` does. + * + * @param destination The destination to decorate + * @param decorators The list of decorators to apply + * @return The decorated navigation destination + */ +public fun decorateNavigationDestination( + destination: NavigationDestination, + decorators: List>, +): NavigationDestination { + @Suppress("UNCHECKED_CAST") + return (decorators as List>) + .distinct() + .foldRight(initial = destination) { decorator, dest -> + NavigationDestination.createWithoutScope( + instance = destination.instance, + metadata = destination.metadata, + content = { decorator.decorate(dest) } + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationSavedStateHolder.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationSavedStateHolder.kt new file mode 100644 index 000000000..c5b9f77ab --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationSavedStateHolder.kt @@ -0,0 +1,185 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.savedstate.SavedState +import androidx.savedstate.read +import androidx.savedstate.savedState +import androidx.savedstate.write + +/** + * A holder that manages both SavedStateRegistry and SaveableStateRegistry for navigation destinations. + * This allows external control over saving and restoring state for all destinations. + */ +@Stable +public class NavigationSavedStateHolder( + savedState: SavedState +) { + private val savedStateRegistryMap = mutableMapOf() + private val saveableStateRegistryMap = mutableMapOf() + private var savedState by mutableStateOf(savedState) + + /** + * Gets or creates a [DestinationSavedStateRegistry] for the given destination ID. + */ + @Composable + internal fun getSavedStateRegistry(destinationId: String): DestinationSavedStateRegistry { + return remember(destinationId, savedState) { + val saved = savedState.read { + getSavedStateOrNull(destinationId + "_saved") + } + savedState.write { + remove(destinationId + "_saved") + } + savedStateRegistryMap.getOrPut(destinationId) { + DestinationSavedStateRegistry(saved) + } + } + } + + /** + * Gets or creates a [DestinationSaveableStateRegistry] for the given destination ID. + * + * @param canBeSaved A function to determine if a value can be saved + */ + @Composable + internal fun getSaveableStateRegistry( + destinationId: String, + ): DestinationSaveableStateRegistry { + val parentSaveableStateRegistry = LocalSaveableStateRegistry.current + + val registry = remember(destinationId, savedState) { + val saved = savedState.read { + getSavedStateOrNull(destinationId + "_saveable")?.read { + toMap().mapNotNull { (k, v) -> + if (v !is List<*>) return@mapNotNull null + k to (v as List) + }.toMap() + } + } + savedState.write { + remove(destinationId + "_saveable") + } + saveableStateRegistryMap.getOrPut(destinationId) { + DestinationSaveableStateRegistry( + restoredValues = saved, + ) + } + } + return registry + } + + @Composable + internal fun DestinationDisposedEffect(destinationId: String) { + val savedStateRegistry = savedStateRegistryMap[destinationId] + savedStateRegistry?.lifecycle?.currentState = Lifecycle.State.RESUMED + DisposableEffect(destinationId, savedState) { + onDispose { + // Defensive: this DisposableEffect lives inside the movable + // content set up by movableContentDecorator, which Compose + // tears down later than the outer DisposableEffect in + // decorateNavigationDestination (the one that fires + // savedStateDecorator.onPop -> removeState -> DESTROYED). + // By the time this onDispose runs after a pop, the registry + // may already be DESTROYED — and a DESTROYED -> CREATED + // transition throws in LifecycleRegistry. Treat the + // already-destroyed case as "nothing to do". + val registry = savedStateRegistry ?: return@onDispose + if (registry.lifecycle.currentState != Lifecycle.State.DESTROYED) { + registry.lifecycle.currentState = Lifecycle.State.CREATED + } + } + } + DisposableEffect(destinationId, savedState) { + val saveableStateRegistry = saveableStateRegistryMap[destinationId] + val savedState = savedState + onDispose { + if (saveableStateRegistryMap[destinationId] != saveableStateRegistry) return@onDispose + saveableStateRegistryMap.remove(destinationId) + if (saveableStateRegistry == null) return@onDispose + savedState.write { + putSavedState(destinationId + "_saveable", savedState(saveableStateRegistry.performSave())) + } + } + } + } + + /** + * Saves the state for all destinations. + * + * @return A [SavedState] containing all destination states, where each destination ID is a key + * mapped to another [SavedState] with "saved" and "saveable" entries. + */ + internal fun saveState(): SavedState { + return savedState(savedState) { + // Get all destination IDs from both maps + val allDestinationIds = (savedStateRegistryMap.keys + saveableStateRegistryMap.keys).toSet() + + allDestinationIds.forEach { destinationId -> + val savedStateRegistry = savedStateRegistryMap[destinationId] + val saveableStateRegistry = saveableStateRegistryMap[destinationId] + + // Save the SavedStateRegistry state + savedStateRegistry?.let { + val state = savedState() + it.savedStateRegistryController.performSave(state) + putSavedState(destinationId+"_saved", state) + } + + // Save the SaveableStateRegistry state + saveableStateRegistry?.let { + putSavedState(destinationId+"_saveable", savedState(it.performSave())) + } + } + } + } + + internal fun restoreState(savedState: SavedState) { + this.savedState = savedState + savedStateRegistryMap.clear() + saveableStateRegistryMap.clear() + } + + /** + * Removes and cleans up state for a specific destination. + */ + public fun removeState(destinationId: String) { + savedStateRegistryMap[destinationId]?.let { + it.lifecycle.currentState = Lifecycle.State.DESTROYED + } + savedStateRegistryMap.remove(destinationId) + saveableStateRegistryMap.remove(destinationId) + savedState.write { + remove(destinationId+"_saved") + remove(destinationId+"_saveable") + } + } + + /** + * Clears all state. + */ + public fun clear() { + savedStateRegistryMap.keys.toList().forEach { removeState(it) } + } + + internal object Saver : androidx.compose.runtime.saveable.Saver { + override fun restore(value: SavedState): NavigationSavedStateHolder? { + return NavigationSavedStateHolder(value) + } + + override fun SaverScope.save(value: NavigationSavedStateHolder): SavedState? { + return value.saveState() + } + } +} + + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/compositionTrackingDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/compositionTrackingDecorator.kt new file mode 100644 index 000000000..2373f8492 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/compositionTrackingDecorator.kt @@ -0,0 +1,71 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import dev.enro.NavigationKey + +/** + * The **innermost** [NavigationDestinationDecorator] in the chain. + * Registers a [DisposableEffect] keyed on the destination instance id + * that tracks composition presence in `idsInComposition`, and fires + * `onPop` on every other decorator (in reverse decoration order) when + * the destination is both no longer in the backstack and no longer in + * composition. + * + * Pairs with `PrepareBackStack` in `rememberDecoratedDestinations` + * (which catches the inverse case: entry leaves the backstack while + * its composition was already gone). + * + * **Why this lives inside the chain rather than in + * `decorateNavigationDestination`'s outer wrap** — see + * `docs/NAV3-COMPARISON.md`. In short: because it's the innermost + * decorator, this `DisposableEffect` is composed inside the + * `movableContent` set up by [movableContentDecorator]. Its + * `onDispose` therefore fires in the same slot-table-teardown pass as + * the inner `CompositionLocalProvider`s (saved state, view-model + * store, navigation context). Some Enro decorators' `onPop` callbacks + * tear down state that those inner providers expose — clearing the + * destination's child `ViewModelStore`, transitioning a `LifecycleRegistry` + * to `DESTROYED`. If `onPop` fired from an outer `DisposableEffect` + * that disposes EARLIER than the movable content's discard (which is + * deferred via `disposeUnusedMovableContent`), a recompose could land + * between "onPop fired" and "inner chain disposed" and read state + * mid-teardown. Doing the tracking from the innermost decorator + * guarantees that everything happens in one synchronous slot-table + * teardown. + */ +@Composable +internal fun rememberCompositionTrackingDecorator( + decoratorsToInvokeOnPop: List>, + idsInBackstack: MutableSet, + idsInComposition: MutableSet, +): NavigationDestinationDecorator = + remember(decoratorsToInvokeOnPop, idsInBackstack, idsInComposition) { + compositionTrackingDecorator(decoratorsToInvokeOnPop, idsInBackstack, idsInComposition) + } + +internal fun compositionTrackingDecorator( + decoratorsToInvokeOnPop: List>, + idsInBackstack: MutableSet, + idsInComposition: MutableSet, +): NavigationDestinationDecorator { + return navigationDestinationDecorator { destination -> + val id = destination.instance.id + DisposableEffect(id) { + idsInComposition.add(id) + onDispose { + val notInComposition = idsInComposition.remove(id) + val popped = id !in idsInBackstack + if (popped && notInComposition) { + @Suppress("UNCHECKED_CAST") + (decoratorsToInvokeOnPop as List>) + .distinct() + .asReversed() + .forEach { it.onPop(destination.instance) } + } + } + } + destination.Content() + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/movableContentDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/movableContentDecorator.kt new file mode 100644 index 000000000..8a8fdaf32 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/movableContentDecorator.kt @@ -0,0 +1,102 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import dev.enro.NavigationKey +import dev.enro.ui.LocalEntriesToExcludeFromCurrentScene + +/** + * Returns a [NavigationDestinationDecorator] that wraps each destination in a [movableContentOf] + * to allow navigation displays to arbitrarily place destinations in different places in the + * composable call hierarchy. + * + * This ensures that the same destination content is not composed multiple times in different + * places of the hierarchy, and that the destination's state is preserved when it moves between + * different parts of the UI. + * + * **Important:** This should typically be the first decorator applied to ensure that other + * stateful decorators are moved properly inside the [movableContentOf]. + */ +@Composable +public fun rememberMovableContentDecorator(): NavigationDestinationDecorator = + remember { + movableContentDecorator() + } + +/** + * Creates a [NavigationDestinationDecorator] that wraps destinations in [movableContentOf]. + * + * This decorator maintains two maps: + * - A map of content holders that store the actual destination content + * - A map of movable content wrappers that allow the content to be moved + * + * The decorator only renders destinations that are NOT excluded by + * [LocalEntriesToExcludeFromCurrentScene] (mirroring Nav3's + * `LocalEntriesToExcludeFromCurrentScene`). + */ +internal fun movableContentDecorator(): NavigationDestinationDecorator { + val movableContentContentHolderMap: MutableMap Unit>> = mutableMapOf() + val movableContentHolderMap: MutableMap Unit> = mutableMapOf() + + return navigationDestinationDecorator { destination -> + val key = destination.instance.id + + // Get or create the content holder for this destination + movableContentContentHolderMap.getOrPut(key) { + key(key) { + remember { + mutableStateOf( + @Composable { + error( + "Should not be called, this should always be updated in " + + "DecorateDestination with the real content" + ) + } + ) + } + } + } + + // Get or create the movable content wrapper for this destination + movableContentHolderMap.getOrPut(key) { + key(key) { + remember { + movableContentOf { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // rendering it while we are animating out. + remember { movableContentContentHolderMap.getValue(key) }.value() + } + } + } + } + + // Skip rendering if a higher-z scene has claimed this entry for itself + if (!LocalEntriesToExcludeFromCurrentScene.current.contains(destination.instance.id)) { + key(key) { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // updating it while we are animating out. + val movableContentContentHolder = remember { + movableContentContentHolderMap.getValue(key) + } + // Update the state holder with the actual destination content + movableContentContentHolder.value = { + key(destination.instance.id) { + destination.Content() + } + } + // In case the key is removed from the backstack while this is still + // being rendered, we remember the movableContent directly to allow + // rendering it while we are animating out. + val movableContentHolder = remember { movableContentHolderMap.getValue(key) } + // Finally, render the destination content via the movableContentOf + movableContentHolder() + } + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationContextDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationContextDecorator.kt new file mode 100644 index 000000000..30128991f --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationContextDecorator.kt @@ -0,0 +1,135 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.asBackstack +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.handle.DestinationNavigationHandle +import dev.enro.handle.getOrCreateNavigationHandleHolder +import dev.enro.result.NavigationResultChannel +import dev.enro.ui.LocalNavigationContext +import dev.enro.ui.LocalNavigationHandle + +/** + * Returns a [NavigationDestinationDecorator] that provides navigation context to destinations. + * + * This decorator establishes the navigation context for each destination, including: + * - Navigation handle binding + * - Container hierarchy + * - Access to lifecycle and ViewModelStore owners from parent decorators + * + * **Note:** This decorator requires the following decorators to be applied before it: + * - [navigationLifecycleDecorator] or [rememberLifecycleDecorator] for lifecycle management + * - [viewModelStoreDecorator] or [rememberViewModelStoreDecorator] for ViewModel support + */ +@Composable +public fun rememberNavigationContextDecorator(): NavigationDestinationDecorator = remember { + navigationContextDecorator() +} + +/** + * Creates a [NavigationDestinationDecorator] that provides navigation context. + * + * This decorator creates and binds the [NavigationContext] for each destination, + * providing access to navigation functionality through composition locals. + */ +internal fun navigationContextDecorator(): NavigationDestinationDecorator { + return navigationDestinationDecorator { destination -> + val parentContext = LocalNavigationContext.current + require(parentContext is ContainerContext) { + "Parent context must be a NavigationContext.Container" + } + val lifecycleOwner = LocalLifecycleOwner.current + val viewModelStoreOwner = LocalViewModelStoreOwner.current + + requireNotNull(viewModelStoreOwner) { + "No ViewModelStoreOwner available. Ensure ViewModelStoreDecorator is applied before NavigationContextDecorator." + } + require(viewModelStoreOwner is HasDefaultViewModelProviderFactory) { + "ViewModelStoreOwner must implement HasDefaultViewModelProviderFactory" + } + + val activeContainerId = rememberSaveable { mutableStateOf(null) } + // Create the navigation context for this destination + val context = remember(parentContext, destination) { + DestinationContext( + lifecycleOwner = lifecycleOwner, + viewModelStoreOwner = viewModelStoreOwner, + defaultViewModelProviderFactory = viewModelStoreOwner, + destination = destination, + activeChildId = activeContainerId, + parent = parentContext, + ) + } + // Get or create the NavigationHandleHolder for this destination + val navigationHandle = remember(context) { + val holder = context.getOrCreateNavigationHandleHolder { + DestinationNavigationHandle( + instance = destination.instance, + savedStateHandle = createSavedStateHandle(), + ) + } + val navigationHandle = holder.navigationHandle + require(navigationHandle is DestinationNavigationHandle) + return@remember navigationHandle + } + navigationHandle.bindContext(context) + + DisposableEffect(parentContext, context) { + parentContext.registerChild(context) + parentContext.registerVisibility(context, true) + onDispose { + parentContext.registerVisibility(context, false) + parentContext.unregisterChild(context) + } + } + + val isActiveInRoot = context.isActiveInRoot + val isFirstOpen = rememberSaveable { mutableStateOf(true) } + + // Provide navigation-specific composition locals + CompositionLocalProvider( + LocalNavigationContext provides context, + LocalNavigationHandle provides navigationHandle, + ) { + val isOpened = remember (isFirstOpen.value) { + if (isFirstOpen.value) { + context.controller.plugins.onOpened(navigationHandle) + } + isFirstOpen.value = false + return@remember true + } + if (isOpened) { + destination.Content() + } + DisposableEffect(isActiveInRoot) { + if (isActiveInRoot) { + context.controller.plugins.onActive(navigationHandle) + } + onDispose {} + } + } + + // TODO this appears to work, but probably not ideal + DisposableEffect(LocalLifecycleOwner.current.lifecycle.currentStateAsState().value == Lifecycle.State.RESUMED) { + val resultId = destination.instance.metadata.get(NavigationResultChannel.ResultIdKey) + if (resultId != null && NavigationResultChannel.hasCompletedResultFor(destination.instance)) { + context.parent.container.setBackstackDirect( + context.parent.container.backstack.filter { + it.metadata.get(NavigationResultChannel.ResultIdKey) != resultId + }.asBackstack() + ) + } + onDispose { } + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationLifecycleDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationLifecycleDecorator.kt new file mode 100644 index 000000000..feae08f13 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationLifecycleDecorator.kt @@ -0,0 +1,137 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import dev.enro.NavigationBackstack +import dev.enro.NavigationKey + +/** + * Returns a [NavigationDestinationDecorator] that manages the lifecycle of navigation destinations + * based on their position in the backstack and the current navigation state. + * + * The lifecycle states are determined as follows: + * - **RESUMED**: The destination is in the backstack and navigation has settled (no animations) + * - **STARTED**: The destination is in the backstack but navigation is transitioning + * - **CREATED**: The destination is not in the backstack (e.g., being animated out) + * + * @param backstack The current navigation backstack + * @param isSettled Whether the navigation state has settled (no animations in progress) + */ +@Composable +public fun rememberLifecycleDecorator( + backstack: NavigationBackstack, + isSettled: Boolean, +): NavigationDestinationDecorator = remember(backstack, isSettled) { + navigationLifecycleDecorator(backstack, isSettled) +} + +/** + * Creates a [NavigationDestinationDecorator] that manages destination lifecycle. + * + * This decorator provides each destination with its own [androidx.lifecycle.LifecycleOwner] + * that reflects the destination's visibility state within the navigation system. + * + * @param backstack The current navigation backstack + * @param isSettled Whether the navigation state has settled + */ +internal fun navigationLifecycleDecorator( + backstack: NavigationBackstack, + isSettled: Boolean, +): NavigationDestinationDecorator { + return navigationDestinationDecorator { destination -> + val isInBackstack = backstack.contains(destination.instance) + + // Determine the appropriate lifecycle state based on destination visibility + val maxLifecycle = when { + isInBackstack && isSettled -> Lifecycle.State.RESUMED + isInBackstack && !isSettled -> Lifecycle.State.STARTED + else /* !isInBackStack */ -> Lifecycle.State.CREATED + } + + val parentLifecycleOwner = LocalLifecycleOwner.current + val lifecycleOwner = rememberNavigationLifecycleOwner( + maxLifecycle = maxLifecycle, + parentLifecycleOwner = parentLifecycleOwner + ) + + CompositionLocalProvider( + LocalLifecycleOwner provides lifecycleOwner + ) { + destination.Content() + } + } +} + +/** + * Creates and remembers a [LifecycleOwner] that follows the parent lifecycle but is capped + * at the specified [maxLifecycle] state. + * + * This is used internally by [navigationLifecycleDecorator] to manage the lifecycle of navigation + * destinations based on their visibility and animation state. + * + * @param maxLifecycle The maximum lifecycle state this owner can reach + * @param parentLifecycleOwner The parent lifecycle to follow + * @return A lifecycle owner that is capped at the specified max state + */ +@Composable +private fun rememberNavigationLifecycleOwner( + maxLifecycle: Lifecycle.State, + parentLifecycleOwner: LifecycleOwner, +) : LifecycleOwner { + val childLifecycleOwner = remember(parentLifecycleOwner) { ChildLifecycleOwner() } + // Pass LifecycleEvents from the parent down to the child + DisposableEffect(childLifecycleOwner, parentLifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + childLifecycleOwner.handleLifecycleEvent(event) + } + + parentLifecycleOwner.lifecycle.addObserver(observer) + + onDispose { parentLifecycleOwner.lifecycle.removeObserver(observer) } + } + // Ensure that the child lifecycle is capped at the maxLifecycle + LaunchedEffect(childLifecycleOwner, maxLifecycle) { + childLifecycleOwner.maxLifecycle = maxLifecycle + } + return childLifecycleOwner +} + +/** + * Internal implementation of a child lifecycle owner that follows a parent lifecycle + * but can be capped at a maximum state. + */ +private class ChildLifecycleOwner : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + var maxLifecycle: Lifecycle.State = Lifecycle.State.INITIALIZED + set(maxState) { + field = maxState + updateState() + } + + private var parentLifecycleState: Lifecycle.State = Lifecycle.State.CREATED + + fun handleLifecycleEvent(event: Lifecycle.Event) { + parentLifecycleState = event.targetState + updateState() + } + + fun updateState() { + if (parentLifecycleState.ordinal < maxLifecycle.ordinal) { + lifecycleRegistry.currentState = parentLifecycleState + } else { + lifecycleRegistry.currentState = maxLifecycle + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationSavedStateDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationSavedStateDecorator.kt new file mode 100644 index 000000000..f484701da --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationSavedStateDecorator.kt @@ -0,0 +1,124 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.lifecycle.LifecycleRegistry +import androidx.savedstate.SavedState +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import dev.enro.NavigationKey + +/** + * Returns a [NavigationDestinationDecorator] that provides saved state functionality to navigation destinations. + * This decorator wraps each destination with proper state management to ensure that calls to [rememberSaveable] + * within the destination content work properly and that state can be saved. + * + * It also provides the destination content with a [SavedStateRegistryOwner] which can be accessed + * via [LocalSavedStateRegistryOwner]. + * + * This decorator is **required** for proper state preservation across configuration changes + * and process death. + * + * @param navigationSavedStateHolder The [NavigationSavedStateHolder] that manages the saved state for destinations + */ +@Composable +public fun rememberSavedStateDecorator( + navigationSavedStateHolder: NavigationSavedStateHolder, +): NavigationDestinationDecorator = remember(navigationSavedStateHolder) { + savedStateDecorator( + navigationSavedStateHolder, + ) +} + +/** + * Creates a [NavigationDestinationDecorator] that provides saved state functionality. + * + * @param navigationSavedStateHolder The [NavigationSavedStateHolder] that manages the saved state for destinations + */ +internal fun savedStateDecorator( + navigationSavedStateHolder: NavigationSavedStateHolder, +): NavigationDestinationDecorator { + return navigationDestinationDecorator( + onPop = { instance -> + val id = instance.id + navigationSavedStateHolder.removeState(id) + }, + decorate = { destination -> + val instance = destination.instance + val id = instance.id + + val childRegistry = navigationSavedStateHolder.getSavedStateRegistry(id) + val saveableRegistry = navigationSavedStateHolder.getSaveableStateRegistry(id) + CompositionLocalProvider( + LocalSavedStateRegistryOwner provides childRegistry, + LocalSaveableStateRegistry provides saveableRegistry.saveableStateRegistry + ) { + destination.Content() + } + navigationSavedStateHolder.DestinationDisposedEffect(id) + } + ) +} + +/** + * Internal implementation of [SavedStateRegistryOwner] for navigation destinations. + * Manages the lifecycle and saved state registry for a single destination. + */ +internal class DestinationSavedStateRegistry( + savedState: SavedState?, +) : SavedStateRegistryOwner { + override val lifecycle: LifecycleRegistry = LifecycleRegistry(this) + + val savedStateRegistryController: SavedStateRegistryController = + SavedStateRegistryController.create(this) + + override val savedStateRegistry: SavedStateRegistry = + savedStateRegistryController.savedStateRegistry + + /** + * Tracks whether `enableSavedStateHandles()` has already been called for + * this registry. The first [DestinationViewModelStoreOwner] constructed + * around the registry flips this to `true` so a second owner — possible + * when Compose freshly inserts movable content at a new slot, causing + * `remember(savedStateRegistryOwner) { … }` inside the decorator chain to + * re-evaluate — can skip the call. `enableSavedStateHandles()` adds a + * non-idempotent `SavedStateHandleAttacher` observer; calling it twice + * would double-register. + */ + var savedStateHandlesEnabled: Boolean = false + + init { + savedStateRegistryController.performRestore(savedState) + } +} + +/** + * Internal implementation of [SaveableStateRegistry] wrapper for navigation destinations. + * Manages the saveable state registry for a single destination. + */ +internal class DestinationSaveableStateRegistry( + private var restoredValues: Map>?, +) { + + val saveableStateRegistry: SaveableStateRegistry by lazy { + SaveableStateRegistry( + restoredValues = restoredValues + ) { + // TODO we currently save all things, because we need to do this for savedState and @Serializable, + // but it would be really good if we could tell if something was @Serializable, and possibly + // delegate the "can save" to a parent + true + } + } + + fun performSave(): Map> { + return saveableStateRegistry.performSave() + } +} + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.kt new file mode 100644 index 000000000..e3b7c0e31 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.kt @@ -0,0 +1,202 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import dev.enro.NavigationKey +import dev.enro.ui.LocalNavigationContext + +/** + * Returns a [NavigationDestinationDecorator] that provides [ViewModelStore] functionality + * to navigation destinations. This decorator ensures that each destination has its own + * [ViewModelStoreOwner], allowing proper scoping of ViewModels to individual destinations. + * + * The decorator also handles cleanup when destinations are removed from the backstack + * based on the [shouldRemoveStoreOwner] callback. + * + * @param viewModelStoreOwner The parent [ViewModelStoreOwner] that provides the [ViewModelStore] + * @param shouldRemoveStoreOwner A callback that determines if the ViewModelStore should be + * cleared when the destination is removed from the backstack + */ +@Composable +public fun rememberViewModelStoreDecorator( + viewModelStoreOwner: ViewModelStoreOwner = + checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + shouldRemoveStoreOwner: () -> Boolean = rememberShouldRemoveViewModelStoreCallback(), +): NavigationDestinationDecorator { + val contextViewModelStoreOwner = LocalNavigationContext.current + return remember(viewModelStoreOwner, shouldRemoveStoreOwner) { + viewModelStoreDecorator( + parentViewModelStoreOwner = contextViewModelStoreOwner, + viewModelStore = viewModelStoreOwner.viewModelStore, + shouldRemoveStoreOwner = shouldRemoveStoreOwner + ) + } +} + +/** + * Creates a [NavigationDestinationDecorator] that provides ViewModelStore functionality. + * + * This decorator wraps each destination with its own [ViewModelStoreOwner] and provides + * that owner as a [LocalViewModelStoreOwner] so that ViewModels can be properly scoped + * to individual destinations. + * + * **Note:** This decorator requires [savedStateDecorator] to be applied before it to ensure + * that ViewModels can properly provide access to [androidx.lifecycle.SavedStateHandle]s. + * + * @param viewModelStore The parent [ViewModelStore] that manages destination-scoped stores + * @param shouldRemoveStoreOwner A callback that determines if the ViewModelStore should be + * cleared when the destination is removed from the backstack + */ +internal fun viewModelStoreDecorator( + parentViewModelStoreOwner: ViewModelStoreOwner, + viewModelStore: ViewModelStore, + shouldRemoveStoreOwner: () -> Boolean, +): NavigationDestinationDecorator { + val storage = viewModelStore.getOrCreateViewModelStoreStorage() + + return navigationDestinationDecorator( + onPop = { instance -> + if (shouldRemoveStoreOwner()) { + storage.clearViewModelStoreForInstance(instance) + } + }, + decorate = { destination -> + val destinationViewModelStore = storage.viewModelStoreForInstance(destination.instance) + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + + val childViewModelStoreOwner = remember(savedStateRegistryOwner) { + DestinationViewModelStoreOwner( + parentViewModelStoreOwner = parentViewModelStoreOwner, + destinationViewModelStore = destinationViewModelStore, + savedStateRegistryOwner = savedStateRegistryOwner, + ) + } + + CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelStoreOwner) { + destination.Content() + } + } + ) +} + +/** + * Internal ViewModelStoreOwner implementation for navigation destinations. + * Combines ViewModelStore functionality with SavedStateRegistry support. + */ +private class DestinationViewModelStoreOwner( + private val parentViewModelStoreOwner: ViewModelStoreOwner, + private val destinationViewModelStore: ViewModelStore, + savedStateRegistryOwner: SavedStateRegistryOwner, +) : ViewModelStoreOwner, + SavedStateRegistryOwner by savedStateRegistryOwner, + HasDefaultViewModelProviderFactory { + + override val viewModelStore: ViewModelStore + get() = destinationViewModelStore + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() { + when (parentViewModelStoreOwner) { + is HasDefaultViewModelProviderFactory -> { + return parentViewModelStoreOwner.defaultViewModelProviderFactory + } + else -> { + error("defaultViewModelProviderFactory not supported - use viewModel with explicit factory") + } + } + } + + override val defaultViewModelCreationExtras: CreationExtras + get() = MutableCreationExtras().also { + it[SAVED_STATE_REGISTRY_OWNER_KEY] = this + it[VIEW_MODEL_STORE_OWNER_KEY] = this + } + + init { + // The flag on DestinationSavedStateRegistry is the source of truth + // for "has enableSavedStateHandles() already run for this registry". + // The first owner constructed for a given registry does the + // initialisation; subsequent owners (which can happen when Compose + // freshly inserts movable content at a new slot, causing the + // remember(savedStateRegistryOwner) inside the decorator chain to + // re-evaluate) treat it as a no-op. The original require still + // catches the "decorator chain isn't wired up correctly" case — + // when the savedStateRegistryOwner isn't one of ours, we fall + // through to the strict check. + val destinationRegistry = savedStateRegistryOwner as? DestinationSavedStateRegistry + if (destinationRegistry == null || !destinationRegistry.savedStateHandlesEnabled) { + require( + lifecycle.currentState == Lifecycle.State.INITIALIZED || + lifecycle.currentState == Lifecycle.State.CREATED + ) { + "The Lifecycle state is already beyond CREATED. The " + + "ViewModelStoreDecorator requires adding the " + + "SavedStateDecorator to ensure support for " + + "SavedStateHandles." + } + enableSavedStateHandles() + destinationRegistry?.savedStateHandlesEnabled = true + } + } +} + +/** + * Internal storage for managing ViewModelStores per navigation instance. + * This ViewModel is stored in the parent ViewModelStore and manages child stores. + */ +private class ViewModelStoreStorage : ViewModel() { + private val stores = mutableMapOf() + + fun viewModelStoreForInstance(instance: NavigationKey.Instance<*>): ViewModelStore { + return stores.getOrPut(instance.id) { ViewModelStore() } + } + + fun clearViewModelStoreForInstance(instance: NavigationKey.Instance<*>) { + stores.remove(instance.id)?.clear() + } + + override fun onCleared() { + stores.forEach { (_, store) -> store.clear() } + stores.clear() + } +} + +/** + * Gets or creates the ViewModelStoreStorage from the parent ViewModelStore. + */ +private fun ViewModelStore.getOrCreateViewModelStoreStorage(): ViewModelStoreStorage { + val provider = ViewModelProvider.create( + store = this, + factory = viewModelFactory { + initializer { ViewModelStoreStorage() } + }, + ) + return provider[ViewModelStoreStorage::class] +} + +/** + * Platform-specific callback for determining when to remove ViewModelStores. + * This is typically based on whether the navigation is temporary (e.g., configuration change) + * or permanent (e.g., back navigation). + */ +@Composable +internal expect fun rememberShouldRemoveViewModelStoreCallback(): () -> Boolean diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/package.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/package.kt new file mode 100644 index 000000000..daba0afeb --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/package.kt @@ -0,0 +1,63 @@ +/** + * Navigation destination decorators provide a composable and extensible way to add functionality + * to navigation destinations without modifying their implementation. + * + * ## Overview + * + * Decorators wrap navigation destinations to provide additional functionality such as: + * - State preservation ([savedStateDecorator]) + * - ViewModel scoping ([viewModelStoreDecorator]) + * - Lifecycle management ([navigationLifecycleDecorator]) + * - Navigation context ([navigationContextDecorator]) + * - Content optimization ([movableContentDecorator]) + * + * ## Usage + * + * Decorators are typically applied automatically by [NavigationDisplay], but can also be + * applied manually when creating custom navigation displays: + * + * ```kotlin + * val decoratedDestination = decorateNavigationDestination( + * destination = originalDestination, + * decorators = listOf( + * rememberMovableContentDecorator(), + * rememberSavedStateDecorator(), + * rememberViewModelStoreDecorator(), + * rememberLifecycleDecorator(backstack, isSettled), + * rememberNavigationContextDecorator() + * ) + * ) + * ``` + * + * ## Order of Application + * + * The order in which decorators are applied is important: + * 1. **movableContentDecorator** - Should be first to ensure other decorators are moved properly + * 2. **savedStateDecorator** - Required by ViewModelStore decorator for SavedStateHandle support + * 3. **viewModelStoreDecorator** - Provides ViewModel scoping + * 4. **lifecycleDecorator** - Manages lifecycle states based on navigation state + * 5. **navigationContextDecorator** - Should be last as it depends on the others + * + * ## Creating Custom Decorators + * + * To create a custom decorator, use the [navigationDestinationDecorator] function: + * + * ```kotlin + * fun myCustomDecorator(): NavigationDestinationDecorator { + * return navigationDestinationDecorator( + * onPop = { instance -> + * // Clean up when destination is popped from the backstack + * }, + * decorate = { destination -> + * // Wrap the destination content + * MyCustomWrapper { + * destination.Content() + * } + * } + * ) + * } + * ``` + */ +package dev.enro.ui.decorators + +import dev.enro.ui.NavigationDisplay diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/sharedElementDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/sharedElementDecorator.kt new file mode 100644 index 000000000..89f10f970 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/sharedElementDecorator.kt @@ -0,0 +1,74 @@ +package dev.enro.ui.decorators + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.enro.NavigationKey +import dev.enro.ui.LocalNavigationAnimatedVisibilityScopeOrNull +import dev.enro.ui.LocalNavigationSharedTransitionScopeOrNull + +/** + * Returns a [NavigationDestinationDecorator] that wraps each + * destination's content in `Box(Modifier.sharedElement(...))` keyed by + * the destination instance id. + * + * **Why this exists.** Without it, when a scene transition mounts the + * same destination in two different scene compositions (e.g. going + * from a single-pane scene to a two-pane scene that still contains the + * previous entry, or swapping the top entry of a two-pane scene), the + * AnimatedContent in `NavigationDisplay` slides whole scene + * compositions in and out — and the shared entry rides along with the + * incoming composition. Wrapping every entry in a `sharedElement` Box + * lets Compose's `SharedTransitionScope` bridge the entry's bounds + * from its old layout slot to its new one, so it visually stays put + * (or smoothly transitions) instead of re-animating with the scene. + * + * **Crucial detail — this decorator must sit OUTSIDE the + * exclusion/movable-content gate.** The + * [movableContentDecorator] short-circuits and emits nothing when the + * destination isn't supposed to render in the current scene (because + * another scene won it). For the `sharedElement` bridge to work, + * Compose needs to see a layout node with the matching key in BOTH + * scene compositions during the transition. So we always emit the + * `Box(Modifier.sharedElement(...))`; the `movableContentDecorator` + * inside decides whether to render the actual content or leave the + * Box empty. An empty Box still participates in shared-element bounds + * tracking via the scene's outer layout placement. + * + * Apply this as the **first** decorator in the chain — `foldRight` + * makes the first decorator the outermost wrapper. + * + * If no `SharedTransitionScope` or `AnimatedVisibilityScope` is + * available (e.g. a destination rendered standalone for previews or + * snapshot tests, outside any `NavigationDisplay`), this decorator + * gracefully degrades to just calling `destination.Content()`. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +public fun rememberSharedElementDecorator(): NavigationDestinationDecorator = + remember { + sharedElementDecorator() + } + +@OptIn(ExperimentalSharedTransitionApi::class) +internal fun sharedElementDecorator(): NavigationDestinationDecorator = + navigationDestinationDecorator { destination -> + val sharedTransitionScope = LocalNavigationSharedTransitionScopeOrNull.current + val animatedVisibilityScope = LocalNavigationAnimatedVisibilityScopeOrNull.current + if (sharedTransitionScope == null || animatedVisibilityScope == null) { + destination.Content() + return@navigationDestinationDecorator + } + with(sharedTransitionScope) { + Box( + modifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState(destination.instance.id), + animatedVisibilityScope = animatedVisibilityScope, + ), + ) { + destination.Content() + } + } + } diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/EmptyDestination.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/EmptyDestination.kt new file mode 100644 index 000000000..976ad3d4d --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/EmptyDestination.kt @@ -0,0 +1,12 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.navigationDestination +import kotlinx.serialization.Serializable + +@Serializable +public data object EmptyNavigationKey : NavigationKey + +public fun emptyDestination(): NavigationDestinationProvider = + navigationDestination { } diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/ManagedFlowDestination.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/ManagedFlowDestination.kt new file mode 100644 index 000000000..6eb2a98d3 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/ManagedFlowDestination.kt @@ -0,0 +1,132 @@ +package dev.enro.ui.destinations + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.complete +import dev.enro.navigationHandle +import dev.enro.result.flow.NavigationFlowScope +import dev.enro.result.flow.registerForFlowResult +import dev.enro.result.flow.rememberNavigationContainerForFlow +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.NavigationDisplay +import dev.enro.ui.navigationDestination +import dev.enro.viewmodel.createEnroViewModel +import kotlin.reflect.KClass + +/** + * Creates a standalone managed flow destination. This destination type allows you to define + * a multi-step flow as a single destination that automatically manages its own lifecycle. + * + * @param keyType The navigation key type for this flow + * @param flow The flow definition that describes the steps and logic + * @param metadata Additional metadata for the destination + */ +@ExperimentalEnroApi +public inline fun managedFlowDestination( + noinline flow: NavigationFlowScope.() -> R, + noinline onCompleted: ManagedFlowDestinationScope.(R) -> Unit, +): NavigationDestinationProvider { + return managedFlowDestination( + keyType = T::class, + flow = flow, + onCompleted = onCompleted + ) +} + +@ExperimentalEnroApi +public inline fun , R : Any> managedFlowDestination( + noinline flow: NavigationFlowScope.() -> R, +): NavigationDestinationProvider { + return managedFlowDestination( + keyType = T::class, + flow = flow, + onCompleted = { + navigation.complete(it) + } + ) +} + + +/** + * Creates a standalone managed flow destination. This destination type allows you to define + * a multi-step flow as a single destination that automatically manages its own lifecycle. + * + * @param keyType The navigation key type for this flow + * @param flow The flow definition that describes the steps and logic + * @param metadata Additional metadata for the destination + */ +@ExperimentalEnroApi +public fun managedFlowDestination( + keyType: KClass, + flow: NavigationFlowScope.() -> R, + onCompleted: ManagedFlowDestinationScope.(R) -> Unit, +): NavigationDestinationProvider { + return navigationDestination { + ManagedFlowDestinationContent( + keyType = keyType, + flow = flow, + onCompleted = onCompleted, + ) + } +} + +@ExperimentalEnroApi +public fun , R : Any> managedFlowDestination( + keyType: KClass>, + flow: NavigationFlowScope.() -> R, +): NavigationDestinationProvider { + return navigationDestination { + ManagedFlowDestinationContent( + keyType = keyType, + flow = flow, + onCompleted = { navigation.complete(it) }, + ) + } +} + +@ExperimentalEnroApi +@Composable +private fun ManagedFlowDestinationContent( + keyType: KClass, + flow: NavigationFlowScope.() -> R, + onCompleted: ManagedFlowDestinationScope.(R) -> Unit, +) { + val viewModel = viewModel { + createEnroViewModel { + ManagedFlowViewModel( + keyType = keyType, + flowDefinition = flow, + onCompleted = onCompleted, + ) + } + } + val container = rememberNavigationContainerForFlow(viewModel.flow) + NavigationDisplay( + state = container, + ) +} + +@ExperimentalEnroApi +internal class ManagedFlowViewModel( + keyType: KClass, + private val flowDefinition: NavigationFlowScope.() -> R, + private val onCompleted: ManagedFlowDestinationScope.(R) -> Unit, +) : ViewModel() { + + private val navigation by navigationHandle(keyType) + + internal val flow by registerForFlowResult( + flow = flowDefinition, + onCompleted = { result -> + onCompleted(ManagedFlowDestinationScope(navigation), result) + } + ) +} + +public class ManagedFlowDestinationScope( + public val navigation: NavigationHandle, +) \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/RootContextDestination.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/RootContextDestination.kt new file mode 100644 index 000000000..30a98e164 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/RootContextDestination.kt @@ -0,0 +1,25 @@ +package dev.enro.ui.destinations + +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination + +// this object contains helper functions to decide if a destination is +// a destination that should open a root context or not +internal object RootContextDestination { + internal const val IsRootContextDestinationKey = "dev.enro.ui.NavigationDestination.RootContextDestination.IsRootContextDestinationKey" +} + +internal fun NavigationDestination.MetadataBuilder<*>.rootContextDestination() { + add(RootContextDestination.IsRootContextDestinationKey to true) +} + +internal fun NavigationKey.Instance<*>.isRootContextDestination( + controller: EnroController, +): Boolean { + return controller.bindings + .bindingFor(this) + .provider + .peekMetadata(this) + .get(RootContextDestination.IsRootContextDestinationKey) == true +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestination.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestination.kt new file mode 100644 index 000000000..a7514cd3e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestination.kt @@ -0,0 +1,187 @@ +package dev.enro.ui.destinations + +import dev.enro.EnroController +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.asInstance +import dev.enro.context.ContainerContext +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.navigationDestination + +internal class SyntheticDestination( + internal val block: SyntheticDestinationScope.() -> Unit, +) { + internal companion object { + internal const val SyntheticDestinationKey = "dev.enro.ui.destinations.SyntheticDestinationKey" + + internal val interceptor = object : NavigationInterceptor() { + override fun intercept( + fromContext: NavigationContext, + containerContext: ContainerContext, + operation: NavigationOperation.Open, + ): NavigationOperation? { + if (!isSyntheticDestination(operation.instance)) return operation + return resolveSyntheticOutcome( + fromContext = fromContext, + containerContext = containerContext, + instance = operation.instance, + ) + } + } + + /** + * Runs the synthetic's block synchronously and converts the outcome + * into a [NavigationOperation] that takes the place of the original + * `Open(synthetic)` in the surrounding `processOperations` pass. + * + * Pure outcomes (open/close/complete/completeFrom) become the + * equivalent operation; the side-effect outcome becomes a + * [NavigationOperation.SideEffect] whose body constructs the + * [SyntheticSideEffectScope] and invokes the user block in + * `afterExecution`. + */ + private fun resolveSyntheticOutcome( + fromContext: NavigationContext, + containerContext: ContainerContext, + instance: NavigationKey.Instance, + ): NavigationOperation { + val controller = fromContext.controller + val bindings = controller.bindings.bindingFor(instance = instance) + val syntheticDestination = bindings.provider.peekMetadata(instance)[SyntheticDestinationKey] + @Suppress("UNCHECKED_CAST") + val synthetic = requireNotNull(syntheticDestination) as SyntheticDestination + val scope = SyntheticDestinationScope( + context = fromContext, + instance = instance, + ) + val thrown = try { + synthetic.block(scope) + null + } catch (outcome: SyntheticDestinationOutcome) { + outcome + } + val effectiveOutcome = thrown ?: scope.finalizeAsSilentCloseIfNoOutcome() + + return when (effectiveOutcome) { + is SyntheticDestinationOutcome.Open -> + NavigationOperation.Open(effectiveOutcome.target) + is SyntheticDestinationOutcome.Close -> + NavigationOperation.Close(instance, silent = effectiveOutcome.silent) + is SyntheticDestinationOutcome.Complete -> when (val result = effectiveOutcome.result) { + null -> NavigationOperation.Complete(instance) + else -> { + @Suppress("UNCHECKED_CAST") + NavigationOperation.Complete( + instance = instance as NavigationKey.Instance>, + result = result, + ) + } + } + is SyntheticDestinationOutcome.CompleteFrom -> + NavigationOperation.CompleteFrom(instance, effectiveOutcome.target) + is SyntheticDestinationOutcome.SideEffect -> NavigationOperation.SideEffect { + val sideEffectScope = SyntheticSideEffectScope( + context = fromContext, + container = containerContext.container, + instance = instance, + ) + effectiveOutcome.block(sideEffectScope) + } + } + } + } +} + +public fun syntheticDestination( + metadata: NavigationDestination.MetadataBuilder.() -> Unit = {}, + block: SyntheticDestinationScope.() -> Unit, +): NavigationDestinationProvider { + return navigationDestination( + metadata = { + metadata.invoke(this) + add(SyntheticDestination.SyntheticDestinationKey to SyntheticDestination(block)) + } + ) { + error("SyntheticDestination with NavigationKey ${navigation.key::class.simpleName} was rendered; SyntheticDestinations should never end up in the Composition. Something is going wrong.") + } +} + +public fun isSyntheticDestination( + instance: NavigationKey.Instance<*>, +): Boolean { + return EnroController.instance?.bindings?.bindingFor(instance) + ?.provider + ?.peekMetadata(instance) + ?.contains(SyntheticDestination.SyntheticDestinationKey) + ?: false +} + +/** + * Runs the synthetic block bound to this [NavigationDestinationProvider] + * with a fresh [SyntheticDestinationScope] and returns the [SyntheticOutcome] + * the block decided on. Returns `null` if this provider isn't a synthetic + * destination at all. + * + * Primarily intended for unit-testing synthetic destinations without going + * through the navigation container's interceptor pipeline. The + * `testSyntheticDestination` helpers in enro-test wrap this with default + * context fixtures and assertion helpers. + */ +/** + * Looks up the synthetic destination bound to [key] on the controller's + * registered bindings and runs it via [NavigationDestinationProvider.peekSyntheticOutcome]. + * Returns `null` if no binding exists for [key], or the bound destination + * isn't a synthetic. + * + * Used by enro-test's `testSyntheticDestination` helper to find a synthetic + * registered on the installed controller without exposing the controller's + * binding repository as public API. + */ +@AdvancedEnroApi +public fun EnroController.peekSyntheticOutcome( + key: K, + context: NavigationContext, +): SyntheticOutcome? { + val instance = key.asInstance() + val binding = runCatching { bindings.bindingFor(instance) }.getOrNull() ?: return null + @Suppress("UNCHECKED_CAST") + val provider = binding.provider as NavigationDestinationProvider + return provider.peekSyntheticOutcome(context, instance) +} + +@AdvancedEnroApi +public fun NavigationDestinationProvider.peekSyntheticOutcome( + context: NavigationContext, + instance: NavigationKey.Instance, +): SyntheticOutcome? { + val syntheticDestination = peekMetadata(instance)[SyntheticDestination.SyntheticDestinationKey] + ?: return null + @Suppress("UNCHECKED_CAST") + val synthetic = syntheticDestination as SyntheticDestination + val scope = SyntheticDestinationScope( + context = context, + instance = instance, + ) + val thrown = try { + synthetic.block(scope) + null + } catch (outcome: SyntheticDestinationOutcome) { + outcome + } + val effective = thrown ?: scope.finalizeAsSilentCloseIfNoOutcome() + + return when (effective) { + is SyntheticDestinationOutcome.Open -> SyntheticOutcome.Open(effective.target) + is SyntheticDestinationOutcome.Close -> SyntheticOutcome.Close(effective.silent) + is SyntheticDestinationOutcome.Complete -> SyntheticOutcome.Complete(effective.result) + is SyntheticDestinationOutcome.CompleteFrom -> SyntheticOutcome.CompleteFrom(effective.target) + is SyntheticDestinationOutcome.SideEffect -> SyntheticOutcome.SideEffect( + instance = instance, + block = effective.block, + ) + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationOutcome.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationOutcome.kt new file mode 100644 index 000000000..5b83b2dab --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationOutcome.kt @@ -0,0 +1,59 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationKey + +/** + * Sentinel exception thrown by the outcome methods on [SyntheticDestinationScope] + * (e.g. `open`, `close`, `closeSilently`, `complete`, `completeFrom`, `sideEffect`) + * to signal the synthetic's decision back up to the synthetic dispatcher. The + * dispatcher catches the subclass and converts it to the corresponding + * [dev.enro.NavigationOperation]. + * + * Outcomes split into two kinds: + * + * - **Pure outcomes** ([Open], [Close], [Complete], [CompleteFrom]) become an + * in-place rewrite of the original `Open(synthetic)` — the dispatcher returns + * the equivalent operation from inside its interceptor, so the rewrite is + * applied during the same `processOperations` pass. Ordering is preserved. + * - **The side-effect outcome** ([SideEffect]) is dispatched as a + * `NavigationOperation.SideEffect`, which runs in `afterExecution` once + * every other operation in the current pass has settled. Used when the + * synthetic needs platform context, the container reference, or arbitrary + * imperative work. + * + * Throwing for control flow mirrors the pattern used by + * [InterceptorBuilderResult][dev.enro.interceptor.builder.InterceptorBuilderResult] + * — it lets the scope methods return [Nothing] so the synthetic block can + * short-circuit naturally from inside conditionals. + */ +internal sealed class SyntheticDestinationOutcome : RuntimeException() { + + internal class Open( + val target: NavigationKey.Instance, + ) : SyntheticDestinationOutcome() + + /** Regular close fires the result-channel callback; silent close does not. */ + internal class Close( + val silent: Boolean, + ) : SyntheticDestinationOutcome() + + internal class Complete( + val result: Any?, + ) : SyntheticDestinationOutcome() + + internal class CompleteFrom( + val target: NavigationKey.Instance, + ) : SyntheticDestinationOutcome() + + /** + * The block runs deferred, in `afterExecution` of the operation processing + * pass that intercepted the synthetic. By that point any other operations + * in the same pass have settled, so the side effect sees the post-rewrite + * backstack state. The dispatcher wraps this in a + * [dev.enro.NavigationOperation.SideEffect] and constructs the + * [SyntheticSideEffectScope] receiver when the side effect actually runs. + */ + internal class SideEffect( + val block: SyntheticSideEffectScope.() -> Unit, + ) : SyntheticDestinationOutcome() +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.complete.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.complete.kt new file mode 100644 index 000000000..60df474cb --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.complete.kt @@ -0,0 +1,47 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationKey +import dev.enro.asInstance +import kotlin.jvm.JvmName + +/** + * End the synthetic's outcome decision by registering a `Completed` result + * with the given [result] against whoever opened the synthetic. Only + * available when the synthetic's key is a [NavigationKey.WithResult]. + */ +public fun SyntheticDestinationScope>.complete( + result: R, +): Nothing = setOutcome(SyntheticDestinationOutcome.Complete(result = result)) + +/** + * End the synthetic's outcome decision by opening [key] and routing its + * eventual completion back to whoever opened the synthetic. The result + * type of [key] must match the synthetic's contract. + */ +public fun SyntheticDestinationScope>.completeFrom( + key: NavigationKey.WithResult, +): Nothing = setOutcome(SyntheticDestinationOutcome.CompleteFrom(key.asInstance())) + +public fun SyntheticDestinationScope>.completeFrom( + key: NavigationKey.WithMetadata>, +): Nothing = setOutcome(SyntheticDestinationOutcome.CompleteFrom(key.asInstance())) + +@JvmName("completeWithoutResult") +@Deprecated( + message = "A synthetic for a NavigationKey.WithResult cannot complete without a result. Use complete(result) instead.", + level = DeprecationLevel.ERROR, +) +public fun SyntheticDestinationScope>.complete(): Nothing { + error("${instance.key} is a NavigationKey.WithResult and cannot complete without a result") +} + +@JvmName("completeFromNonResultDeprecated") +@Deprecated( + message = "A synthetic for a NavigationKey.WithResult cannot completeFrom a NavigationKey that does not also implement NavigationKey.WithResult of the same result type.", + level = DeprecationLevel.ERROR, +) +public fun SyntheticDestinationScope>.completeFrom( + key: NavigationKey, +): Nothing { + error("${instance.key} is a NavigationKey.WithResult and cannot completeFrom a key that is not also a NavigationKey.WithResult of the same result type") +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.kt new file mode 100644 index 000000000..62497eaac --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.kt @@ -0,0 +1,203 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.context.AnyNavigationContext +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext + +/** + * The receiver scope of a `syntheticDestination { ... }` block. A synthetic + * destination is a [NavigationKey] that, when opened, runs the block instead + * of rendering a UI; the synthetic instance never reaches a backstack. + * + * Outcomes split into **pure** outcomes (synchronous, in-place rewrite of + * the original `Open(synthetic)`) and a **side-effect** outcome (deferred + * imperative work with platform/container access). The pure outcomes are: + * + * - [open] — open another [NavigationKey] in place of the synthetic. + * - [close] — register a `Closed` result for whoever called the synthetic. + * - [closeSilently] — close without firing the result-channel callback. + * - [complete] — register a `Completed` result. For result-bearing + * synthetics see the `complete(result: T)` extension. + * - [completeFrom] — open another key and have *its* completion fulfil the + * synthetic's contract. + * + * Pure outcomes flow through the same interceptor pipeline as any other + * operation, in order, in the same processing pass. That preserves + * ordering when synthetics appear in an initial backstack alongside + * normal destinations. + * + * For everything else — launching a system browser, rewriting the + * container's backstack, calling out to a non-Enro API — reach for + * [sideEffect]. The side-effect block runs deferred, has access to the + * originating [NavigationContext] and the target [dev.enro.NavigationContainer], + * and never blocks the operation pipeline. + * + * The block can fall through without calling any outcome method — that's + * treated as a silent close (no result-channel callback fires, no + * operation is dispatched against the backstack). + * + * Each outcome method throws a sentinel and returns [Nothing], so calls + * inside conditionals flow naturally without needing explicit `return`. + * The scope tracks the outcome it settles on: once one is set, any further + * outcome call from an async coroutine that outlived the block throws a + * clear "already finished" error rather than silently double-handling. + */ +public class SyntheticDestinationScope @PublishedApi internal constructor( + /** + * The [NavigationContext] the synthetic was opened from. Intended for + * reads — inspecting `controller`, walking parent contexts, checking + * `activeChild` — to inform the outcome decision. Imperative actions + * (calling `controller.execute`, mutating containers) should go through + * [sideEffect] instead, which is dispatched after the synthetic's own + * outcome has settled. + */ + public val context: NavigationContext, + public val instance: NavigationKey.Instance, +) { + public val key: K = instance.key + + /** + * The active destination closest to [context]. Read-only convenience — + * useful for synthetics that want to know "what screen am I being opened + * from?" Walks the context tree: if [context] is a + * [DestinationContext] this is that context; if it's a + * [ContainerContext], it's the container's active child; if it's a + * [RootContext], it's the active child of the root's active container. + */ + public val destinationContext: DestinationContext? + get() = when (context) { + is DestinationContext<*> -> context + is ContainerContext -> context.activeChild + is RootContext -> context.activeChild?.activeChild + } + + @Deprecated("Use destinationContext or context instead for greater clarity about the context being used") + public val navigationContext: AnyNavigationContext + get() = context + + @Deprecated("Use instance") + public val instruction: NavigationKey.Instance = instance + + /** + * The outcome the synthetic settled on. Null while the block is running + * and before any method is called; set the first time one of the scope's + * outcome methods runs (or by the dispatcher after the block falls + * through to record a silent close). + */ + internal var outcome: SyntheticDestinationOutcome? = null + private set + + /** + * Records [newOutcome] and throws it. If an outcome is already set, + * throws an [IllegalStateException] instead — this catches the case + * where an async coroutine outlived the block and tried to complete / + * close the synthetic after the dispatcher had already moved on. + */ + internal fun setOutcome(newOutcome: SyntheticDestinationOutcome): Nothing { + val current = outcome + if (current != null) { + error( + "SyntheticDestination for ${instance.key} has already finished with " + + "${current::class.simpleName}. A second outcome cannot be set — this " + + "usually means an async coroutine outlived the synthetic block and " + + "tried to complete/close it after the dispatcher had already moved on. " + + "Do any async work before opening the synthetic, or forward to a " + + "destination that owns the work itself." + ) + } + outcome = newOutcome + throw newOutcome + } + + /** + * Used by the dispatcher when the block falls through without calling an + * outcome method. Records a silent close so that any later coroutine + * call sees [outcome] is non-null and throws the "already finished" + * error from [setOutcome]. + */ + internal fun finalizeAsSilentCloseIfNoOutcome(): SyntheticDestinationOutcome { + val existing = outcome + if (existing != null) return existing + val silent = SyntheticDestinationOutcome.Close(silent = true) + outcome = silent + return silent + } + + /** + * End the synthetic's outcome decision by opening another [NavigationKey] + * in place of this synthetic. The synthetic instance itself never lands + * in any backstack; the dispatcher rewrites the original `Open(synthetic)` + * to `Open(key)` inline. + */ + public fun open(key: NavigationKey): Nothing = + setOutcome(SyntheticDestinationOutcome.Open(key.asInstance())) + + public fun open(key: NavigationKey.WithMetadata<*>): Nothing = + setOutcome(SyntheticDestinationOutcome.Open(key.asInstance())) + + /** + * End the synthetic's outcome decision by registering a `Closed` result + * against whichever [dev.enro.result.NavigationResultChannel] originally + * opened this synthetic. + */ + public fun close(): Nothing = + setOutcome(SyntheticDestinationOutcome.Close(silent = false)) + + /** + * Close the synthetic without firing the result-channel callback. Use + * when the synthetic acted as a pure side-effect bridge and the original + * caller doesn't need to know the synthetic finished. + */ + public fun closeSilently(): Nothing = + setOutcome(SyntheticDestinationOutcome.Close(silent = true)) + + /** + * End the synthetic's outcome decision by registering a `Completed` + * result with no payload. + * + * For synthetics whose key is a [NavigationKey.WithResult], this no-arg + * overload is shadowed by a deprecated-error extension — call the typed + * `complete(result: T)` extension instead. + */ + public fun complete(): Nothing = + setOutcome(SyntheticDestinationOutcome.Complete(result = null)) + + /** + * End the synthetic's outcome decision by opening [key] and routing its + * eventual completion back to whoever opened the synthetic. The + * synthetic itself doesn't produce the result; the forwarded key does. + */ + public fun completeFrom(key: NavigationKey): Nothing = + setOutcome(SyntheticDestinationOutcome.CompleteFrom(key.asInstance())) + + /** + * End the synthetic's outcome decision by dispatching a side effect. + * The [block] runs deferred, in `afterExecution` of the current + * operation pass — meaning every other operation in the same pass has + * already settled by the time the block runs. Use this when the + * synthetic needs platform handles (e.g. an Android Activity), the + * container reference, or any imperative work that doesn't fit a single + * navigation operation. + * + * The side-effect block runs with a [SyntheticSideEffectScope] receiver + * carrying `context`, `container`, `instance`, and `key`. From inside + * the block you can call `container.execute(context, ...)` to drive + * further navigation (including `SetBackstack` for whole-backstack + * rewrites). The synthetic itself is treated as silently closed once + * the side effect dispatches — no result-channel callback fires. + */ + public fun sideEffect( + block: SyntheticSideEffectScope.() -> Unit, + ): Nothing { + @Suppress("UNCHECKED_CAST") + setOutcome( + SyntheticDestinationOutcome.SideEffect( + block as SyntheticSideEffectScope.() -> Unit, + ) + ) + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticOutcome.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticOutcome.kt new file mode 100644 index 000000000..ad02d86fb --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticOutcome.kt @@ -0,0 +1,65 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationContainer +import dev.enro.NavigationContext +import dev.enro.NavigationKey + +/** + * The decision a synthetic destination's block made when it ran. Returned by + * [NavigationDestinationProvider.peekSyntheticOutcome] and consumed by + * `testSyntheticDestination` in enro-test for unit-testing synthetic logic + * without going through the navigation container's interceptor pipeline. + * + * Mirrors the internal `SyntheticDestinationOutcome` sealed class but is a + * plain value type — not a thrown sentinel — so it can be inspected, + * pattern-matched and asserted against. + */ +public sealed class SyntheticOutcome { + + /** The synthetic's block called `open(...)` — the dispatcher would rewrite the synthetic's `Open` to `Open(target)`. */ + public data class Open(public val instance: NavigationKey.Instance<*>) : SyntheticOutcome() { + public val key: NavigationKey get() = instance.key + } + + /** The synthetic's block called `close()` or `closeSilently()`. */ + public data class Close(public val silent: Boolean) : SyntheticOutcome() + + /** The synthetic's block called `complete(...)`. `result` is the typed payload (or `null` for non-result keys). */ + public data class Complete(public val result: Any?) : SyntheticOutcome() + + /** The synthetic's block called `completeFrom(...)` — the chosen key's eventual completion would route back to the original caller. */ + public data class CompleteFrom(public val instance: NavigationKey.Instance<*>) : SyntheticOutcome() { + public val key: NavigationKey get() = instance.key + } + + /** + * The synthetic's block called `sideEffect { ... }`. The block is not + * automatically invoked when the outcome is produced — call [runWith] + * to execute it. Useful in tests that want to inspect *what* side + * effect the synthetic chose without running it, or that want to + * provide a controlled scope (e.g. a mock context) for the side effect + * to operate against. + */ + public class SideEffect @PublishedApi internal constructor( + public val instance: NavigationKey.Instance<*>, + @PublishedApi + internal val block: SyntheticSideEffectScope.() -> Unit, + ) : SyntheticOutcome() { + /** + * Executes the side-effect block with the given [context] and + * [container] as the scope. + */ + public fun runWith( + context: NavigationContext, + container: NavigationContainer, + ) { + @Suppress("UNCHECKED_CAST") + val scope = SyntheticSideEffectScope( + context = context, + container = container, + instance = instance as NavigationKey.Instance, + ) + scope.block() + } + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticSideEffectScope.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticSideEffectScope.kt new file mode 100644 index 000000000..20bedc02a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticSideEffectScope.kt @@ -0,0 +1,41 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationContainer +import dev.enro.NavigationContext +import dev.enro.NavigationKey + +/** + * Receiver scope of a [SyntheticDestinationScope.sideEffect] block. + * + * A side-effect outcome runs deferred — after the dispatcher has rewritten + * the synthetic's `Open` and any other operations in the same processing + * pass have settled. That's the point at which `context` and `container` + * reflect the navigation state the user would expect to see "after" the + * synthetic ran, and the point at which imperative work (launching a + * browser intent, rewriting the container's backstack, etc.) is safe. + * + * Reaching for a side effect should be deliberate: the pure outcomes + * (`open`, `close`, `complete`, `completeFrom`) on the parent scope + * already cover most synthetic patterns. Use a side effect when you need + * platform handles (e.g. an Android `Activity`), when you need to read or + * mutate the container's backstack, or when you're bridging to a system + * outside Enro. + */ +public class SyntheticSideEffectScope @PublishedApi internal constructor( + /** + * The [NavigationContext] from which the synthetic was originally opened. + * Typically a [dev.enro.context.DestinationContext] (the caller's screen) + * but may be a [dev.enro.context.ContainerContext] or + * [dev.enro.context.RootContext] depending on how the synthetic was opened. + */ + public val context: NavigationContext, + /** + * The [NavigationContainer] the synthetic's `Open` was dispatched to. + * Use `container.execute(context, NavigationOperation.SetBackstack(...))` + * to rewrite the backstack from inside a side effect. + */ + public val container: NavigationContainer, + public val instance: NavigationKey.Instance, +) { + public val key: K get() = instance.key +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberDecoratedDestinations.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberDecoratedDestinations.kt new file mode 100644 index 000000000..c0e27ea8c --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberDecoratedDestinations.kt @@ -0,0 +1,141 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import dev.enro.EnroController +import dev.enro.NavigationBackstack +import dev.enro.NavigationKey +import dev.enro.ui.decorators.NavigationDestinationDecorator +import dev.enro.ui.decorators.NavigationSavedStateHolder +import dev.enro.ui.decorators.decorateNavigationDestination +import dev.enro.ui.decorators.rememberCompositionTrackingDecorator +import dev.enro.ui.decorators.rememberLifecycleDecorator +import dev.enro.ui.decorators.rememberMovableContentDecorator +import dev.enro.ui.decorators.rememberNavigationContextDecorator +import dev.enro.ui.decorators.rememberSavedStateDecorator +import dev.enro.ui.decorators.rememberSharedElementDecorator +import dev.enro.ui.decorators.rememberViewModelStoreDecorator + + +/** + * Creates NavigationDestination instances from the backstack and applies decorators. + * Decorators add functionality like lifecycle management, view models, and saved state. + * + * Onpop tracking mirrors Nav3's PrepareBackStack + per-entry DisposableEffect pattern: + * each decorated destination's content() runs a DisposableEffect that tracks composition + * presence, and [PrepareBackStack] runs DisposableEffects keyed on the backstack list so + * leaving the backstack is observable too. `onPop` fires when an entry has left BOTH. + * + * @param controller The navigation controller for binding resolution + * @param backstack The current navigation backstack + * @param isSettled Whether animations are currently settled (used by lifecycle decorator) + * @return List of decorated NavigationDestination instances + */ +@Composable +internal fun rememberDecoratedDestinations( + controller: EnroController, + backstack: NavigationBackstack, + savedStateHolder: NavigationSavedStateHolder, + isSettled: Boolean, +): List> { + // Create decorators that wrap destinations with additional functionality + val controllerDecorators = remember { + controller.decorators.getDecorators() + } + val decorators = listOf( + // sharedElementDecorator must be the OUTERMOST decorator (first + // in this list — foldRight makes the first entry the outermost + // wrapper). It always emits a Box(Modifier.sharedElement(...)) + // for every destination in every scene that lists it, even when + // the movableContentDecorator below skips rendering the actual + // content. That empty Box is what lets Compose's + // SharedTransitionScope bridge an entry's bounds from one + // scene's layout slot to another during a transition. + rememberSharedElementDecorator(), + rememberMovableContentDecorator(), // Preserves content across recompositions + rememberSavedStateDecorator(savedStateHolder), // Manages saved instance state + rememberViewModelStoreDecorator(), // Provides ViewModelStore for each destination + rememberLifecycleDecorator(backstack, isSettled), // Manages lifecycle state + rememberNavigationContextDecorator(), // Provides navigation context + ).plus(controllerDecorators) + + val idsInBackstack: MutableSet = remember { mutableSetOf() } + val idsInComposition: MutableSet = remember { mutableSetOf() } + + // Innermost decorator: tracks composition and fires onPop on every + // other decorator. Appending it as the LAST element makes foldRight + // wrap it as the innermost — which puts its DisposableEffect inside + // the movable content set up by movableContentDecorator. See + // docs/NAV3-COMPARISON.md for the rationale. + val trackingDecorator = rememberCompositionTrackingDecorator( + decoratorsToInvokeOnPop = decorators, + idsInBackstack = idsInBackstack, + idsInComposition = idsInComposition, + ) + val decoratorsWithTracking = decorators + trackingDecorator + val decoratedDestinations = remember { + mutableMapOf>() + } + + val decorated = remember(backstack) { + val active = backstack.map { it.id } + decoratedDestinations.filter { it.key !in active } + .onEach { decoratedDestinations.remove(it.key) } + + backstack + .map { instance -> + decoratedDestinations.getOrPut(instance.id) { + val destination = controller.bindings.destinationFor(instance) + decorateNavigationDestination( + destination = destination, + decorators = decoratorsWithTracking, + ) + } + } + } + + PrepareBackStack(decorated, decorators, idsInBackstack, idsInComposition) + return decorated +} + +/** + * Tracks backstack membership for each decorated destination. Mirrors Nav3's + * `PrepareBackStack`: every entry gets a [DisposableEffect] keyed on the entry id and + * the latest backstack list. When the effect disposes (entry left the backstack), if + * the entry is also not in composition, decorator `onPop` callbacks fire in + * reverse-decoration order. + * + * Splits the lifecycle observability into two independent sources (backstack + composition) + * so we never miss the moment both have settled, regardless of which goes first. + */ +@Composable +private fun PrepareBackStack( + entries: List>, + decorators: List>, + idsInBackstack: MutableSet, + idsInComposition: MutableSet, +) { + val latestEntries by rememberUpdatedState(entries) + val latestDecorators by rememberUpdatedState(decorators) + entries.forEach { entry -> + val instance = entry.instance + val id = instance.id + idsInBackstack.add(id) + DisposableEffect(id, entries.toList()) { + onDispose { + val latestIds = latestEntries.map { it.instance.id } + val popped = if (id !in latestIds) idsInBackstack.remove(id) else false + if (popped && id !in idsInComposition) { + @Suppress("UNCHECKED_CAST") + (latestDecorators.distinct() as List>) + .asReversed() + .forEach { it.onPop(instance) } + } + } + } + } +} + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberNavigationContainer.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberNavigationContainer.kt new file mode 100644 index 000000000..daa8d2567 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberNavigationContainer.kt @@ -0,0 +1,160 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.savedstate.savedState +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import dev.enro.EnroController +import dev.enro.NavigationBackstack +import dev.enro.NavigationContainer +import dev.enro.NavigationContainerFilter +import dev.enro.NavigationKey +import dev.enro.acceptAll +import dev.enro.asBackstack +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.interceptor.NoOpNavigationInterceptor +import dev.enro.ui.decorators.NavigationSavedStateHolder +import kotlinx.serialization.PolymorphicSerializer +import kotlin.jvm.JvmName +import kotlin.uuid.Uuid + +@Composable +public fun rememberNavigationContainer( + key: NavigationContainer.Key = rememberSaveable(saver = NavigationContainer.Key.Saver) { + NavigationContainer.Key("NavigationContainer@${Uuid.random()}") + }, + backstack: NavigationBackstack, + emptyBehavior: EmptyBehavior = EmptyBehavior.preventEmpty(), + interceptor: NavigationInterceptor = NoOpNavigationInterceptor, + filter: NavigationContainerFilter = acceptAll(), +): NavigationContainerState { + val parentContext = LocalNavigationContext.current + require(parentContext is RootContext || parentContext is DestinationContext<*>) { + "NavigationContainer can only be used within a RootContext or DestinationContext" + } + val controller = remember { + requireNotNull(EnroController.instance) { + "EnroController instance is not initialized" + } + } + val container = rememberSaveable( + saver = Saver( + save = { container -> + container.backstack.map { + encodeToSavedState( + serializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)), + value = it, + configuration = controller.serializers.savedStateConfiguration + ) + } + }, + restore = { savedBackstack -> + val backstack = savedBackstack + .map { + decodeFromSavedState( + deserializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)), + savedState = it, + configuration = controller.serializers.savedStateConfiguration + ) + } + .asBackstack() + NavigationContainer( + key = key, + controller = controller, + backstack = backstack, + ) + } + ), + ) { + NavigationContainer( + key = key, + controller = controller, + backstack = backstack, + ) + } + DisposableEffect(container, filter) { + container.setFilter(filter) + onDispose { + container.clearFilter(filter) + } + } + + DisposableEffect(container, emptyBehavior) { + container.addEmptyInterceptor(emptyBehavior.interceptor) + onDispose { + container.removeEmptyInterceptor(emptyBehavior.interceptor) + } + } + + DisposableEffect(container, interceptor) { + container.addInterceptor(interceptor) + onDispose { + container.removeInterceptor(interceptor) + } + } + + val context = remember(container, parentContext) { + ContainerContext( + container = container, + parent = parentContext, + ) + } + + // Register/unregister with parent context + DisposableEffect(container, parentContext) { + parentContext.registerChild(context) + onDispose { + parentContext.unregisterChild(context) + } + } + val savedState = rememberSaveable( + saver = NavigationSavedStateHolder.Saver + ) { + NavigationSavedStateHolder(savedState()) + } + val containerState = remember(container) { + NavigationContainerState( + container = container, + emptyBehavior = emptyBehavior, + context = context, + savedStateHolder = savedState, + ) + } + val destinations = rememberDecoratedDestinations( + controller = controller, + backstack = containerState.backstack, + savedStateHolder = savedState, + isSettled = containerState.isSettled, + ) + containerState.destinations = destinations + return containerState +} + +@Deprecated("Use the version of rememberNavigationContainer that takes a NavigationBackstack as the backstack parameter") +@Composable +@JvmName("rememberNavigationContainerListBackstack") +public fun rememberNavigationContainer( + key: NavigationContainer.Key = rememberSaveable(saver = NavigationContainer.Key.Saver) { + NavigationContainer.Key("NavigationContainer@${Uuid.random()}") + }, + backstack: List>, + emptyBehavior: EmptyBehavior = EmptyBehavior.preventEmpty(), + interceptor: NavigationInterceptor = NoOpNavigationInterceptor, + filter: NavigationContainerFilter = acceptAll(), +): NavigationContainerState { + return rememberNavigationContainer( + key = key, + backstack = backstack.asBackstack(), + emptyBehavior = emptyBehavior, + interceptor = interceptor, + filter = filter, + ) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DialogScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DialogScene.kt new file mode 100644 index 000000000..f9660626b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DialogScene.kt @@ -0,0 +1,79 @@ +package dev.enro.ui.scenes + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.ui.LocalNavigationContainer +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy +import dev.enro.ui.SceneStrategyScope +import dev.enro.ui.get + +/** An [NavigationScene.Overlay] that renders an [entry] within a [Dialog]. */ +internal data class DialogScene( + override val key: Any, + override val previousEntries: List>, + override val overlaidEntries: List>, + val entry: NavigationDestination, + val dialogProperties: DialogProperties, +) : NavigationScene.Overlay { + + override val entries: List> = listOf(entry) + + override val content: @Composable () -> Unit = { + val container = LocalNavigationContainer.current + Dialog( + onDismissRequest = { + container.execute(NavigationOperation.Close(entry.instance)) + }, + properties = dialogProperties, + ) { + entry.Content() + } + } +} + +/** + * A [NavigationSceneStrategy] that displays entries that have added [dialog] to their metadata + * within a [Dialog] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +public class DialogSceneStrategy : NavigationSceneStrategy { + @Composable + public override fun SceneStrategyScope.calculateScene( + entries: List>, + ): NavigationScene? { + val lastEntry = entries.lastOrNull() ?: return null + val dialogProperties = lastEntry.metadata[DialogPropertiesKey] ?: return null + return DialogScene( + key = lastEntry.instance.id, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + dialogProperties = dialogProperties, + ) + } + + /** + * Metadata key under which a destination's [DialogProperties] are + * stored when it opts into being displayed inside a [Dialog]. + * Mirrors Nav3's `DialogSceneStrategy.DialogKey: NavMetadataKey`. + */ + public object DialogPropertiesKey : + NavigationDestination.MetadataKey(default = null) + + public companion object +} + +/** + * Marks the destination as one that should be displayed inside a [Dialog]. + */ +public fun NavigationDestination.MetadataBuilder<*>.dialog( + dialogProperties: DialogProperties = DialogProperties(), +) { + add(DialogSceneStrategy.DialogPropertiesKey, dialogProperties) +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DirectOverlayScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DirectOverlayScene.kt new file mode 100644 index 000000000..af5ed7fef --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DirectOverlayScene.kt @@ -0,0 +1,146 @@ +package dev.enro.ui.scenes + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.window.Dialog +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy +import dev.enro.ui.SceneStrategyScope +import dev.enro.ui.get + +/** + * A [NavigationScene.Overlay] that renders the overlaid content directly on top of the current scene, and + * leaves it up to the [NavigationDestination] to decide how exactly to render the content. + * + * When the destination's metadata supplies [OverlayTransitions] (see + * the [directOverlay] / [directOverlayWithFade] builders), Enro's + * overlay renderer applies the given enter / exit transitions to the + * scene's appearance and disappearance — including the disappearance + * path, by keeping the scene composed for the duration of the exit + * transition. Without those transitions the scene snaps in and out + * the way it always did. + * + * If the content in the [DirectOverlayScene] does not prevent the user from interacting with the underlying + * scene (e.g. by using a Dialog or ModalBottomSheet), it will be possible to click through the overlay + * and interact with the underlying scene. + */ +public data class DirectOverlayScene( + override val key: Any, + override val previousEntries: List>, + override val overlaidEntries: List>, + val entry: NavigationDestination, +) : NavigationScene.Overlay { + + override val entries: List> = listOf(entry) + + override val content: @Composable () -> Unit = { + entry.Content() + } +} + +/** + * Enter + exit transition pair carried in a [NavigationDestination]'s + * metadata for [DirectOverlayScene]s that opt into animated + * enter/exit. The overlay renderer reads this off the scene's top + * entry and forwards both transitions to the underlying + * `AnimatedVisibility`. + * + * Stored as a plain (non-serialisable) value — same trade-off as + * Dialog's `DialogProperties` metadata. Lives for the lifetime of + * the destination only. + */ +@Immutable +public data class OverlayTransitions( + val enter: EnterTransition, + val exit: ExitTransition, +) + +/** + * A [NavigationSceneStrategy] that displays entries which have opted + * into direct-overlay rendering via [directOverlay] / [directOverlayWithFade]. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +public class DirectOverlaySceneStrategy : NavigationSceneStrategy { + @Composable + public override fun SceneStrategyScope.calculateScene( + entries: List>, + ): NavigationScene? { + val lastEntry = entries.lastOrNull() ?: return null + if (!lastEntry.metadata[IsDirectOverlayKey]) return null + return DirectOverlayScene( + key = lastEntry.instance.id, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + ) + } + + /** + * Metadata flag indicating that a destination should be rendered + * as a direct overlay. Default `false`. + */ + public object IsDirectOverlayKey : + NavigationDestination.MetadataKey(default = false) + + /** + * Optional metadata key carrying [OverlayTransitions] for animated + * appearance/disappearance. Default `null`. + */ + public object OverlayTransitionsKey : + NavigationDestination.MetadataKey(default = null) + + public companion object +} + +/** + * Marks the destination as a direct overlay — rendered on top of the + * underlying scene with no shell or window wrapper. Snaps in and out + * by default. Pair with [directOverlayWithFade] (or the explicit + * `enter` / `exit` overload below) to animate the appearance. + */ +public fun NavigationDestination.MetadataBuilder<*>.directOverlay() { + add(DirectOverlaySceneStrategy.IsDirectOverlayKey, true) +} + +/** + * Marks the destination as a direct overlay AND attaches a pair of + * transitions for the renderer to apply when the scene appears and + * disappears. The exit transition runs even when the destination is + * removed externally (back press, sibling navigation, programmatic + * close) — the renderer keeps the scene composed for the duration of + * the transition. + */ +public fun NavigationDestination.MetadataBuilder<*>.directOverlay( + enter: EnterTransition, + exit: ExitTransition, +) { + add(DirectOverlaySceneStrategy.IsDirectOverlayKey, true) + add(DirectOverlaySceneStrategy.OverlayTransitionsKey, OverlayTransitions(enter, exit)) +} + +/** + * Shortcut for [directOverlay] with a symmetric fade-in / fade-out + * pair — the most common overlay treatment. Override [durationMillis] + * to tighten or stretch the fade; pass explicit transitions to the + * other overload when you need anything fancier (slide, scale, etc). + */ +public fun NavigationDestination.MetadataBuilder<*>.directOverlayWithFade( + durationMillis: Int = 128, +) { + directOverlay( + enter = fadeIn(animationSpec = tween(durationMillis)), + exit = fadeOut(animationSpec = tween(durationMillis)), + ) +} + +public fun NavigationDestination<*>.isDirectOverlay(): Boolean { + return metadata[DirectOverlaySceneStrategy.IsDirectOverlayKey] +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/EmptyNavigationScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/EmptyNavigationScene.kt new file mode 100644 index 000000000..163387ce2 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/EmptyNavigationScene.kt @@ -0,0 +1,27 @@ +package dev.enro.ui.scenes + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene + +/** + * The [NavigationScene] returned by [rememberNavigationSceneState] when the + * container's destinations list is empty (typically because the container was + * configured with `EmptyBehavior.allowEmpty()` and has been emptied). + * + * Rendering an empty container produces no output — this scene has no entries, + * no previous entries, and a content lambda that composes nothing. It exists + * so [NavigationDisplay] can still drive its [AnimatedContent] transition + * through a real [NavigationScene] value rather than nullability everywhere. + * + * Having this short-circuit also guarantees that every `NavigationSceneStrategy` + * implementation receives a non-empty `entries` list when its `calculateScene` + * runs, which simplifies the strategy contract. + */ +internal data object EmptyNavigationScene : NavigationScene { + override val key: Any = EmptyNavigationScene + override val entries: List> = emptyList() + override val previousEntries: List> = emptyList() + override val content: @Composable () -> Unit = {} +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/NavigationSceneStrategy.calculateSceneWithSinglePaneFallback.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/NavigationSceneStrategy.calculateSceneWithSinglePaneFallback.kt new file mode 100644 index 000000000..dfc3b17fd --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/NavigationSceneStrategy.calculateSceneWithSinglePaneFallback.kt @@ -0,0 +1,18 @@ +package dev.enro.ui.scenes + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy +import dev.enro.ui.SceneStrategyScope + +@Composable +internal fun NavigationSceneStrategy.calculateSceneWithSinglePaneFallback( + scope: SceneStrategyScope, + entries: List>, +): NavigationScene { + val scene = with(this) { scope.calculateScene(entries) } + if (scene != null) return scene + return with(SinglePaneSceneStrategy()) { scope.calculateScene(entries) } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SinglePaneScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SinglePaneScene.kt new file mode 100644 index 000000000..fbe9c731a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SinglePaneScene.kt @@ -0,0 +1,42 @@ +package dev.enro.ui.scenes + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy +import dev.enro.ui.SceneStrategyScope + +/** + * The 1-entry [NavigationScene] returned by [SinglePaneSceneStrategy]. Mirrors + * Nav3's `SinglePaneScene` — a top-level data class with explicit equality so + * two scenes wrapping the same entry compare structurally and AnimatedContent + * can collapse them into the same slot. + */ +internal data class SinglePaneScene( + override val key: Any, + val entry: NavigationDestination, + override val previousEntries: List>, +) : NavigationScene { + override val entries: List> = listOf(entry) + override val content: @Composable () -> Unit = { entry.Content() } +} + +/** + * A [NavigationSceneStrategy] that always returns a 1-entry [SinglePaneScene] + * displaying the topmost entry. + */ +public class SinglePaneSceneStrategy : NavigationSceneStrategy { + @Composable + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): NavigationScene { + val last = entries.last() + return SinglePaneScene( + key = last.instance.id, + entry = last, + previousEntries = entries.dropLast(1), + ) + } +} + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/CreationExtras.navigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/CreationExtras.navigationHandle.kt new file mode 100644 index 000000000..9d88cd120 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/CreationExtras.navigationHandle.kt @@ -0,0 +1,13 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import dev.enro.NavigationHandle +import dev.enro.NavigationKey + +public inline fun CreationExtras.getNavigationHandle(): NavigationHandle { + val viewModelStoreOwner = requireNotNull(get(VIEW_MODEL_STORE_OWNER_KEY)) { + "Could not get NavigationHandle from CreationExtras, as the VIEW_MODEL_STORE_OWNER_KEY was not set in the CreationExtras." + } + return viewModelStoreOwner.getNavigationHandle() +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.kt new file mode 100644 index 000000000..affce198b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.kt @@ -0,0 +1,9 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModelProvider +import dev.enro.NavigationHandle + +public expect class EnroViewModelFactory( + navigationHandle: NavigationHandle<*>, + delegate: ViewModelProvider.Factory, +) : ViewModelProvider.Factory \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/EnroWrappedViewModelStoreOwner.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/EnroWrappedViewModelStoreOwner.kt new file mode 100644 index 000000000..f32eb7d1a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/EnroWrappedViewModelStoreOwner.kt @@ -0,0 +1,70 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.enableSavedStateHandles +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import dev.enro.EnroController + +// Wraps a ViewModelStoreOwner and a SavedStateRegistryOwner to +// ensure that Enro-required extras/factory stuff is configured +internal class EnroWrappedViewModelStoreOwner( + private val controller: EnroController, + private val viewModelStoreOwner: ViewModelStoreOwner, + // The savedStateRegistryOwner to use for this ViewModelStoreOwner's saved state handles and other things, + // it's OK to provide null here, but doing so will create an UnboundedSavedStateRegistryOwner, which won't + // actually save any state (which is fine for some platforms, like web/desktop. + savedStateRegistryOwner: SavedStateRegistryOwner?, +) : ViewModelStoreOwner by viewModelStoreOwner, + HasDefaultViewModelProviderFactory { + + private val savedStateRegistryOwner = savedStateRegistryOwner ?: UnboundedSavedStateRegistryOwner(this) + private val delegate = viewModelStoreOwner as? HasDefaultViewModelProviderFactory + + override val defaultViewModelCreationExtras: CreationExtras + get() { + if (delegate != null) return delegate.defaultViewModelCreationExtras + return MutableCreationExtras().apply { + set(SAVED_STATE_REGISTRY_OWNER_KEY, savedStateRegistryOwner) + set(VIEW_MODEL_STORE_OWNER_KEY, viewModelStoreOwner) + } + } + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() { + if (delegate != null) return delegate.defaultViewModelProviderFactory + return controller.viewModelRepository.getFactory() + } + + // This is a saved state registry owner for use when there is no other saved state registry provider actually + // provided, which is mostly useful on non-Android platforms where saving is not as important. This + // registry owner will not actually save or restore any state as it currently stands + private class UnboundedSavedStateRegistryOwner( + private val owner: EnroWrappedViewModelStoreOwner, + ) : SavedStateRegistryOwner, + LifecycleOwner, + ViewModelStoreOwner by owner { + + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle = lifecycleRegistry + + private val savedStateRegistryController = SavedStateRegistryController.create(this) + override val savedStateRegistry: SavedStateRegistry = savedStateRegistryController.savedStateRegistry + + init { + enableSavedStateHandles() + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.RESUMED + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/NavigationHandleProvider.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/NavigationHandleProvider.kt new file mode 100644 index 000000000..6dd77e549 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/NavigationHandleProvider.kt @@ -0,0 +1,42 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.CreationExtras +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import kotlin.reflect.KClass + +@PublishedApi +internal object NavigationHandleProvider { + private val navigationHandles = mutableMapOf, NavigationHandle>() + + fun put(modelClass: KClass<*>, navigationHandle: NavigationHandle) { + navigationHandles[modelClass] = navigationHandle + } + + fun clear(modelClass: KClass<*>) { + navigationHandles.remove(modelClass) + } + + fun get(modelClass: KClass<*>): NavigationHandle { + return navigationHandles[modelClass] + ?: error( + "Could not get a NavigationHandle for ViewModel of type ${modelClass.simpleName}." + ) + } + + // Called by enro-test + fun clearAllForTest() { + navigationHandles.clear() + } +} + +public inline fun CreationExtras.createEnroViewModel(noinline block: () -> T): T { + val navigationHandle = getNavigationHandle() + NavigationHandleProvider.put(T::class, navigationHandle) + val viewModel = block.invoke() + return viewModel.also { + viewModel.navigationHandleReference.navigationHandle = navigationHandle + NavigationHandleProvider.clear(T::class) + } +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModel.navigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModel.navigationHandle.kt new file mode 100644 index 000000000..14da33eaf --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModel.navigationHandle.kt @@ -0,0 +1,34 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import dev.enro.NavigationHandle +import dev.enro.NavigationKey + + +@PublishedApi +internal class ClosableNavigationHandleReference() : AutoCloseable { + var navigationHandle: NavigationHandle? = null + + override fun close() { + navigationHandle = null + } + + companion object { + const val NAVIGATION_HANDLE_KEY = "dev.enro.viemodel.NAVIGATION_HANDLE_KEY" + } +} + +@PublishedApi +internal val ViewModel.navigationHandleReference: ClosableNavigationHandleReference + get() { + val closeableReference = getCloseable( + key = ClosableNavigationHandleReference.NAVIGATION_HANDLE_KEY + ) ?: ClosableNavigationHandleReference().also { reference -> + addCloseable( + key = ClosableNavigationHandleReference.NAVIGATION_HANDLE_KEY, + closeable = reference, + ) + } + return closeableReference + } + diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelProvider.Factory.withNavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelProvider.Factory.withNavigationHandle.kt new file mode 100644 index 000000000..48ac35564 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelProvider.Factory.withNavigationHandle.kt @@ -0,0 +1,36 @@ +package dev.enro.viewmodel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModelProvider +import dev.enro.NavigationHandle +import dev.enro.navigationHandle + + +/** + * Given a ViewModelProvider.Factory, wraps that factory as an EnroViewModelFactory with the current NavigationHandle provided + * to ViewModels that are created with that factory, allowing the use of `by navigationHandle` in those ViewModels. + */ +public fun ViewModelProvider.Factory.withNavigationHandle( + navigationHandle: NavigationHandle<*>, +): ViewModelProvider.Factory = EnroViewModelFactory( + navigationHandle = navigationHandle, + delegate = this, +) + +/** + * A Composable helper for [withNavigationHandle] that automatically retrieves the current NavigationHandle from the Composition, + * and remembers the result of applying withNavigationHandle. + * + * @see [withNavigationHandle] + */ +@Composable +public fun ViewModelProvider.Factory.withNavigationHandle(): ViewModelProvider.Factory { + val navigation = navigationHandle() + + return remember(this, navigation) { + withNavigationHandle( + navigationHandle = navigation, + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelStoreOwner.getNavigationHandle.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelStoreOwner.getNavigationHandle.kt new file mode 100644 index 000000000..60490dd74 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelStoreOwner.getNavigationHandle.kt @@ -0,0 +1,38 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.handle.getNavigationHandleHolder +import kotlin.reflect.KClass + +/** + * Returns the [NavigationHandle] typed to [K] for the destination whose + * `ViewModelStore` this owner exposes. + * + * Intended for Android-side glue (Activity, Fragment, custom hosts) that + * needs to reach the handle for a destination it owns. Inside the + * destination's Composable or ViewModel, prefer `navigationHandle()` + * (composable) or `by navigationHandle()` (ViewModel) — those are the + * standard accessors. + * + * Throws if no Enro handle is associated with this owner, or if [K] + * doesn't match the destination's actual key type. + */ +public inline fun ViewModelStoreOwner.getNavigationHandle(): NavigationHandle { + return getNavigationHandle(K::class) +} + +/** + * Explicit-[KClass] form of `ViewModelStoreOwner.getNavigationHandle()`. + */ +public fun ViewModelStoreOwner.getNavigationHandle( + keyType: KClass, +): NavigationHandle { + val navigationHandle = getNavigationHandleHolder().navigationHandle + require(keyType.isInstance(navigationHandle.key)) { + "The NavigationHandle found in the ViewModelStoreOwner $this is not of type ${keyType.simpleName}" + } + @Suppress("UNCHECKED_CAST") + return navigationHandle as NavigationHandle +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/BackstackSavedStateTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/BackstackSavedStateTests.kt new file mode 100644 index 000000000..014e542f5 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/BackstackSavedStateTests.kt @@ -0,0 +1,112 @@ +package dev.enro + +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import dev.enro.test.EnroTest +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.runEnroTest +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests that lock down the saved-state round-trip path used by + * [dev.enro.ui.rememberNavigationContainer]'s Saver and by + * [dev.enro.ui.NavigationContainerState.saveState]. Both call sites + * encode each [NavigationKey.Instance] in the backstack via + * `NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class))` + * and the controller's `savedStateConfiguration`. If that path breaks, + * backstacks won't survive process death — these tests are the cheapest + * regression catcher for that class of bug. + * + * commonTest doesn't go through navigation binding registration, so the + * test helper explicitly registers [NavigationKeyFixtures.SimpleKey] with + * the controller's polymorphic `NavigationKey` resolver. + */ +class BackstackSavedStateTests { + + private object PersistentTestMetadataKey : + NavigationKey.MetadataKey(default = "default-persistent") + + private object TransientTestMetadataKey : + NavigationKey.TransientMetadataKey(default = "default-transient") + + private fun runWithSimpleKeySerializer(block: () -> Unit) = runEnroTest { + EnroTest.getCurrentNavigationController() + .serializers + .registerSerializersModule( + SerializersModule { + polymorphic(NavigationKey::class) { + subclass( + NavigationKeyFixtures.SimpleKey::class, + NavigationKeyFixtures.SimpleKey.serializer(), + ) + } + } + ) + block() + } + + @Test + fun `NavigationKey Instance round-trips through encodeToSavedState`() = runWithSimpleKeySerializer { + val configuration = EnroController.savedStateConfiguration + val serializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)) + + val original = NavigationKeyFixtures.SimpleKey().asInstance() + + val encoded = encodeToSavedState(serializer, original, configuration) + val decoded = decodeFromSavedState(serializer, encoded, configuration) + + assertEquals(original.id, decoded.id, "Instance id should round-trip unchanged") + assertEquals(original.key, decoded.key, "Instance key should round-trip unchanged") + } + + @Test + fun `Backstack of multiple instances round-trips through encodeToSavedState`() = runWithSimpleKeySerializer { + val configuration = EnroController.savedStateConfiguration + val serializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)) + + val originals = listOf( + NavigationKeyFixtures.SimpleKey().asInstance(), + NavigationKeyFixtures.SimpleKey().asInstance(), + NavigationKeyFixtures.SimpleKey().asInstance(), + ) + + val roundTripped = originals.map { instance -> + decodeFromSavedState(serializer, encodeToSavedState(serializer, instance, configuration), configuration) + } + + assertEquals(originals.size, roundTripped.size) + originals.zip(roundTripped).forEachIndexed { index, (original, decoded) -> + assertEquals(original.id, decoded.id, "Instance at index $index lost its id") + assertEquals(original.key, decoded.key, "Instance at index $index lost its key") + } + } + + @Test + fun `Persistent metadata survives round-trip and TransientMetadataKey is stripped`() = runWithSimpleKeySerializer { + val configuration = EnroController.savedStateConfiguration + val serializer = NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)) + + val original = NavigationKeyFixtures.SimpleKey().asInstance() + original.metadata.set(PersistentTestMetadataKey, "I survive") + original.metadata.set(TransientTestMetadataKey, "I do not survive") + + val encoded = encodeToSavedState(serializer, original, configuration) + val decoded = decodeFromSavedState(serializer, encoded, configuration) + + assertEquals( + expected = "I survive", + actual = decoded.metadata.get(PersistentTestMetadataKey), + message = "Persistent metadata should survive saved-state round-trip", + ) + assertEquals( + expected = "default-transient", + actual = decoded.metadata.get(TransientTestMetadataKey), + message = "TransientMetadataKey should not be persisted — the default should be returned after round-trip", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/ControllerInterceptorTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/ControllerInterceptorTests.kt new file mode 100644 index 000000000..325772e0d --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/ControllerInterceptorTests.kt @@ -0,0 +1,203 @@ +package dev.enro + +import androidx.compose.material3.Text +import dev.enro.controller.createNavigationModule +import dev.enro.controller.defaultNavigationModule +import dev.enro.controller.interceptors.PreviouslyActiveContainerInterceptor +import dev.enro.controller.interceptors.RootDestinationInterceptor +import dev.enro.test.EnroTest +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.runEnroTest +import dev.enro.ui.destinations.rootContextDestination +import dev.enro.ui.navigationDestination +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Coverage for the built-in controller-level interceptors installed via + * [defaultNavigationModule]. Both [RootDestinationInterceptor] and + * [PreviouslyActiveContainerInterceptor] had zero unit coverage prior to + * these tests — they're invisible defaults that anyone could refactor + * without immediate signal. + * + * The behaviour tests call the interceptors' methods directly rather than + * going through a real NavigationDisplay, because both interceptors are + * pure functions of (fromContext, containerContext, operations) and that + * approach gives precise, fast assertions. + */ +class ControllerInterceptorTests { + + @Test + fun `defaultNavigationModule registers both built-in controller interceptors`() { + val interceptors = defaultNavigationModule.interceptors + assertTrue( + actual = interceptors.contains(RootDestinationInterceptor), + message = "defaultNavigationModule should install RootDestinationInterceptor", + ) + assertTrue( + actual = interceptors.contains(PreviouslyActiveContainerInterceptor), + message = "defaultNavigationModule should install PreviouslyActiveContainerInterceptor", + ) + } + + @Test + fun `PreviouslyActiveContainerInterceptor records cross-container active state on Open and adds reactivation SideEffect on Close`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerA = NavigationContextFixtures.createContainerContext(rootContext) + val containerB = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerA) + rootContext.registerChild(containerB) + // Make containerB the currently-active leaf — registerVisibility + // also promotes activeChildId to containerB because containerA was + // never marked visible. + rootContext.registerVisibility(containerB, isVisible = true) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + val openOp = NavigationOperation.Open(instance) + + // Intercept the Open targeting containerA. The interceptor sees the + // active leaf (containerB) is different from the target container, + // and records containerB's id on the instance metadata. + PreviouslyActiveContainerInterceptor.intercept( + fromContext = rootContext, + containerContext = containerA, + operation = openOp, + ) + + // Closing the same instance via containerA should now produce a + // SideEffect that reactivates containerB. + val closeOp = NavigationOperation.Close(instance) + val processed = PreviouslyActiveContainerInterceptor.beforeIntercept( + fromContext = rootContext, + containerContext = containerA, + operations = listOf(closeOp), + ) + + assertEquals( + expected = 2, + actual = processed.size, + message = "Close should be augmented with a SideEffect when a previously-active container was recorded", + ) + assertEquals(closeOp, processed[0], "Original Close op should be first") + assertTrue( + actual = processed[1] is NavigationOperation.SideEffect, + message = "Second op should be the reactivation SideEffect; was: ${processed[1]::class.simpleName}", + ) + } + + @Test + fun `PreviouslyActiveContainerInterceptor does not add SideEffect when no previous container was recorded`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerA = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerA) + rootContext.registerVisibility(containerA, isVisible = true) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + val openOp = NavigationOperation.Open(instance) + + // Open targets the currently active container, so no metadata is recorded. + PreviouslyActiveContainerInterceptor.intercept( + fromContext = rootContext, + containerContext = containerA, + operation = openOp, + ) + + val closeOp = NavigationOperation.Close(instance) + val processed = PreviouslyActiveContainerInterceptor.beforeIntercept( + fromContext = rootContext, + containerContext = containerA, + operations = listOf(closeOp), + ) + + assertEquals( + expected = listOf(closeOp), + actual = processed, + message = "No SideEffect should be appended when there's no recorded previous container", + ) + } + + @Test + fun `RootDestinationInterceptor extracts root-context Opens into a single SideEffect`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("regular") } + ) + destination( + navigationDestination( + metadata = { rootContextDestination() }, + ) { Text("root") } + ) + } + ) + + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerContext) + + val regularOp = NavigationOperation.Open(RegularControllerInterceptorKey.asInstance()) + val rootOp = NavigationOperation.Open(RootControllerInterceptorKey.asInstance()) + + val processed = RootDestinationInterceptor.beforeIntercept( + fromContext = rootContext, + containerContext = containerContext, + operations = listOf(regularOp, rootOp), + ) + + assertEquals( + expected = 2, + actual = processed.size, + message = "Regular op should remain, root op should be replaced by one SideEffect; processed: $processed", + ) + assertEquals( + expected = regularOp, + actual = processed[0], + message = "Regular op should be left untouched", + ) + assertTrue( + actual = processed[1] is NavigationOperation.SideEffect, + message = "Root op should have been redirected into a SideEffect; was: ${processed[1]::class.simpleName}", + ) + } + + @Test + fun `RootDestinationInterceptor passes operations through unchanged when none target root-context destinations`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("regular") } + ) + } + ) + + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerContext) + + val operations = listOf( + NavigationOperation.Open(RegularControllerInterceptorKey.asInstance()), + NavigationOperation.Open(RegularControllerInterceptorKey.asInstance()), + ) + + val processed = RootDestinationInterceptor.beforeIntercept( + fromContext = rootContext, + containerContext = containerContext, + operations = operations, + ) + + assertEquals( + expected = operations, + actual = processed, + message = "Operations should pass through unchanged when no root-context destinations are present", + ) + } +} + +@Serializable +data object RegularControllerInterceptorKey : NavigationKey + +@Serializable +data object RootControllerInterceptorKey : NavigationKey diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/EmptyBehaviorTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/EmptyBehaviorTests.kt new file mode 100644 index 000000000..377800ba5 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/EmptyBehaviorTests.kt @@ -0,0 +1,104 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro + +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import dev.enro.ui.EmptyBehavior +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for the [EmptyBehavior] factory builders that consumers use via + * `rememberNavigationContainer(emptyBehavior = ...)`. The underlying + * `NavigationContainer.EmptyInterceptor` is already covered by + * NavigationContainerTests; this file locks down the public wrappers + * those tests don't exercise. + * + * `EmptyBehavior` is also installed on a container by adding its + * `interceptor` property as an `EmptyInterceptor`, which is what + * `rememberNavigationContainer` does for us under the hood -- the tests + * here replicate that wiring directly. + */ +class EmptyBehaviorTests { + + @Test + fun `preventEmpty denies the container becoming empty`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val destination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + val behavior = EmptyBehavior.preventEmpty() + container.addEmptyInterceptor(behavior.interceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertEquals( + expected = 1, + actual = container.backstack.size, + message = "preventEmpty should block the container from going empty; backstack: ${container.backstack}", + ) + } + + @Test + fun `allowEmpty allows the container to become empty and runs the onEmpty callback`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val destination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + var onEmptyInvocations = 0 + val behavior = EmptyBehavior.allowEmpty(onEmpty = { onEmptyInvocations++ }) + container.addEmptyInterceptor(behavior.interceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertEquals( + expected = 0, + actual = container.backstack.size, + message = "allowEmpty should permit the container to go empty after the last close", + ) + assertEquals( + expected = 1, + actual = onEmptyInvocations, + message = "onEmpty callback should fire exactly once when the container empties", + ) + } + + @Test + fun `default behavior matches preventEmpty`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val destination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + val behavior = EmptyBehavior.default() + container.addEmptyInterceptor(behavior.interceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertTrue( + actual = container.backstack.isNotEmpty(), + message = "default() should behave like preventEmpty(); the close on the last entry must have been denied", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/EnroTestHelpersTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/EnroTestHelpersTests.kt new file mode 100644 index 000000000..1e84b345d --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/EnroTestHelpersTests.kt @@ -0,0 +1,230 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro + +import dev.enro.path.NavigationPathBinding +import dev.enro.path.createPathBinding +import dev.enro.test.EnroTest +import dev.enro.test.EnroTestAssertionException +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.assertBackstackContains +import dev.enro.test.assertBackstackDoesNotContain +import dev.enro.test.assertBackstackEmpty +import dev.enro.test.assertBackstackKeys +import dev.enro.test.assertBackstackSize +import dev.enro.test.assertOperationSequence +import dev.enro.test.assertPathDoesNotResolve +import dev.enro.test.assertPathFor +import dev.enro.test.assertPathResolvesTo +import dev.enro.test.createTestNavigationHandle +import dev.enro.test.fixtures.NavigationContainerFixtures +import dev.enro.test.installNavigationModule +import dev.enro.test.installPathBindings +import dev.enro.test.lastOperation +import dev.enro.test.lastOperationOfType +import dev.enro.test.runEnroTest +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Tests for the enro-test ergonomic helpers shipped alongside the synthetic + * destination testing API. Covers backstack assertions, the install* module + * shortcuts, path-resolution assertions, and TestNavigationHandle operation + * history helpers. + */ +class EnroTestHelpersTests { + + // ---- Backstack assertions ---- + + @Test + fun `assertBackstackSize matches an empty backstack`() = runEnroTest { + val container = NavigationContainerFixtures.create() + container.container.assertBackstackSize(0) + } + + @Test + fun `assertBackstackSize fails with a helpful message`() = runEnroTest { + val container = NavigationContainerFixtures.create() + + val error = assertFailsWith { + container.container.assertBackstackSize(3) + } + assertTrue(error.message?.contains("Expected backstack to have 3 entries") == true) + } + + @Test + fun `assertBackstackKeys matches exact key sequence`() = runEnroTest { + val key1 = NavigationKeyFixtures.SimpleKey() + val key2 = NavigationKeyFixtures.SimpleKey() + val container = NavigationContainerFixtures.create( + backstack = backstackOf(key1.asInstance(), key2.asInstance()), + ) + container.container.assertBackstackKeys(key1, key2) + } + + @Test + fun `assertBackstackContains finds a matching key by type`() = runEnroTest { + val key = HelpersResultKey() + val container = NavigationContainerFixtures.create( + backstack = backstackOf(NavigationKeyFixtures.SimpleKey().asInstance(), key.asInstance()), + ) + val found = container.container.assertBackstackContains() + assertEquals(key, found.key) + } + + @Test + fun `assertBackstackDoesNotContain succeeds when key type is absent`() = runEnroTest { + val container = NavigationContainerFixtures.create( + backstack = backstackOf(NavigationKeyFixtures.SimpleKey().asInstance()), + ) + container.container.assertBackstackDoesNotContain() + } + + @Test + fun `assertBackstackEmpty passes for empty backstack and fails for non-empty`() = runEnroTest { + val empty = NavigationContainerFixtures.create() + empty.container.assertBackstackEmpty() + + val nonEmpty = NavigationContainerFixtures.create( + backstack = backstackOf(NavigationKeyFixtures.SimpleKey().asInstance()), + ) + assertFailsWith { + nonEmpty.container.assertBackstackEmpty() + } + } + + // ---- installNavigationModule / installPathBindings ---- + + @Test + fun `installPathBindings registers bindings on the test controller`() = runEnroTest { + val binding = NavigationPathBinding.createPathBinding( + pattern = "items/{id}", + propertyOne = HelpersPathKey::id, + constructor = ::HelpersPathKey, + ) + installPathBindings(binding) + + EnroTest.getCurrentNavigationController() + .assertPathResolvesTo("/items/abc") + } + + // ---- Path resolution assertions ---- + + @Test + fun `assertPathResolvesTo returns the typed key matching predicate`() = runEnroTest { + val binding = NavigationPathBinding.createPathBinding( + pattern = "items/{id}", + propertyOne = HelpersPathKey::id, + constructor = ::HelpersPathKey, + ) + installPathBindings(binding) + val controller = EnroTest.getCurrentNavigationController() + + val key = controller.assertPathResolvesTo("/items/xyz") { it.id == "xyz" } + + assertEquals("xyz", key.id) + } + + @Test + fun `assertPathDoesNotResolve passes when no binding matches`() = runEnroTest { + val controller = EnroTest.getCurrentNavigationController() + controller.assertPathDoesNotResolve("/items/never-resolved") + } + + @Test + fun `assertPathFor checks the reverse-direction serialisation`() = runEnroTest { + installPathBindings( + NavigationPathBinding.createPathBinding( + pattern = "items/{id}", + propertyOne = HelpersPathKey::id, + constructor = ::HelpersPathKey, + ) + ) + val controller = EnroTest.getCurrentNavigationController() + + controller.assertPathFor(HelpersPathKey("zzz"), expectedPath = "/items/zzz") + } + + @Test + fun `assertPathResolvesTo fails with a helpful message for wrong type`() = runEnroTest { + installPathBindings( + NavigationPathBinding.createPathBinding( + pattern = "items/{id}", + propertyOne = HelpersPathKey::id, + constructor = ::HelpersPathKey, + ) + ) + val controller = EnroTest.getCurrentNavigationController() + + val error = assertFailsWith { + controller.assertPathResolvesTo("/items/abc") + } + assertTrue(error.message?.contains("resolved to") == true) + } + + // ---- Operation history fluency ---- + + @Test + fun `lastOperation returns the most recent operation`() = runEnroTest { + val handle = createTestNavigationHandle(HelpersResultKey()) + handle.execute(NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance())) + handle.execute(NavigationOperation.Close(handle.instance)) + + val last = handle.lastOperation() + assertTrue(last is NavigationOperation.Close<*>) + } + + @Test + fun `lastOperationOfType filters by operation subtype`() = runEnroTest { + val handle = createTestNavigationHandle(HelpersResultKey()) + handle.execute(NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance())) + handle.execute(NavigationOperation.Close(handle.instance)) + + val lastOpen = handle.lastOperationOfType>() + assertTrue(lastOpen.instance.key is NavigationKeyFixtures.SimpleKey) + } + + @Test + fun `assertOperationSequence enforces type ordering`() = runEnroTest { + val handle = createTestNavigationHandle(HelpersResultKey()) + handle.execute(NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance())) + handle.execute(NavigationOperation.Close(handle.instance)) + + handle.assertOperationSequence( + NavigationOperation.Open::class, + NavigationOperation.Close::class, + ) + } + + @Test + fun `assertOperationSequence fails with mismatched sequence`() = runEnroTest { + val handle = createTestNavigationHandle(HelpersResultKey()) + handle.execute(NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance())) + + val error = assertFailsWith { + handle.assertOperationSequence( + NavigationOperation.Close::class, + NavigationOperation.Open::class, + ) + } + assertTrue(error.message?.contains("Expected operation sequence") == true) + } + + @Test + fun `lastOperation fails when no operations were executed`() = runEnroTest { + val handle = createTestNavigationHandle(HelpersResultKey()) + + assertFailsWith { + handle.lastOperation() + } + } +} + +@Serializable +data class HelpersPathKey(val id: String) : NavigationKey + +@Serializable +class HelpersResultKey : NavigationKey.WithResult diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/MultiContainerRoutingTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/MultiContainerRoutingTests.kt new file mode 100644 index 000000000..d81b707d7 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/MultiContainerRoutingTests.kt @@ -0,0 +1,118 @@ +package dev.enro + +import dev.enro.handle.findContainerForOperation +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * Tests that lock down the routing decision made by + * [findContainerForOperation] when more than one container is reachable + * from the originating navigation context. Until now, only + * [NavigationContainer.accepts] has been tested in isolation — the actual + * "given this context and this operation, which container handles it" + * resolution was untested. + */ +class MultiContainerRoutingTests { + + @Test + fun `findContainerForOperation routes Open to the accepting sibling container`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerA = NavigationContextFixtures.createContainerContext(rootContext) + val containerB = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerA) + rootContext.registerChild(containerB) + + val destinationInA = NavigationContextFixtures.createDestinationContext( + containerA, + NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()), + ) + + val keyForA = NavigationKeyFixtures.SimpleKey() + val keyForB = NavigationKeyFixtures.SimpleKey() + containerA.container.setFilter( + NavigationContainerFilter(fromChildrenOnly = false) { it.key == keyForA } + ) + containerB.container.setFilter( + NavigationContainerFilter(fromChildrenOnly = false) { it.key == keyForB } + ) + + val routedForB = findContainerForOperation( + fromContext = destinationInA, + operation = NavigationOperation.Open(keyForB.asInstance()), + ) + assertSame( + expected = containerB, + actual = routedForB, + message = "Open for keyForB should route to containerB even though the source is a child of containerA", + ) + + val routedForA = findContainerForOperation( + fromContext = destinationInA, + operation = NavigationOperation.Open(keyForA.asInstance()), + ) + assertSame( + expected = containerA, + actual = routedForA, + message = "Open for keyForA should route to containerA", + ) + } + + @Test + fun `findContainerForOperation returns null when no container accepts the operation`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerA = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerA) + + val destinationInA = NavigationContextFixtures.createDestinationContext( + containerA, + NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()), + ) + containerA.container.setFilter(acceptNone()) + + val routed = findContainerForOperation( + fromContext = destinationInA, + operation = NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance()), + ) + assertNull( + actual = routed, + message = "When no reachable container accepts the Open, findContainerForOperation should return null", + ) + } + + @Test + fun `findContainerForOperation does not route to a sibling whose filter is fromChildrenOnly`() = runEnroTest { + // Both containers accept the same predicate, but containerB additionally + // gates on fromChildrenOnly. The source destination is under containerA, + // so containerB's filter should reject the routing attempt and the + // resolver should fall through to containerA. + val rootContext = NavigationContextFixtures.createRootContext() + val containerA = NavigationContextFixtures.createContainerContext(rootContext) + val containerB = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(containerA) + rootContext.registerChild(containerB) + + val destinationInA = NavigationContextFixtures.createDestinationContext( + containerA, + NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()), + ) + + containerA.container.setFilter(NavigationContainerFilter(fromChildrenOnly = false) { true }) + containerB.container.setFilter(NavigationContainerFilter(fromChildrenOnly = true) { true }) + + val routed = findContainerForOperation( + fromContext = destinationInA, + operation = NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance()), + ) + + assertSame( + expected = containerA, + actual = routed, + message = "containerB has fromChildrenOnly = true and the source is not a child of it — should fall through to containerA", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationContainerTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationContainerTests.kt new file mode 100644 index 000000000..8710e76b9 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationContainerTests.kt @@ -0,0 +1,1086 @@ +package dev.enro + +import dev.enro.context.ContainerContext +import dev.enro.context.NavigationContext +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.interceptor.builder.navigationInterceptor +import dev.enro.test.EnroTest +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class NavigationContainerTests { + + @Test + fun `NavigationContainer accepts operations for keys in its backstack`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key1 = NavigationKeyFixtures.SimpleKey() + val instance1 = key1.asInstance() + val key2 = NavigationKeyFixtures.SimpleKey() + val instance2 = key2.asInstance() + + // Empty container should not accept close operations + assertFalse(container.accepts(containerContext, NavigationOperation.Close(instance1))) + assertFalse(container.accepts(containerContext, NavigationOperation.Complete(instance1))) + + // Add instances to backstack + container.setBackstackDirect(backstackOf(instance1, instance2)) + + // Container should accept operations for instances in backstack + assertTrue(container.accepts(containerContext, NavigationOperation.Close(instance1))) + assertTrue(container.accepts(containerContext, NavigationOperation.Close(instance2))) + assertTrue(container.accepts(containerContext, NavigationOperation.Complete(instance1))) + assertTrue(container.accepts(containerContext, NavigationOperation.Complete(instance2))) + + // Container should not accept operations for instances not in backstack + val key3 = NavigationKeyFixtures.SimpleKey() + val instance3 = key3.asInstance() + assertFalse(container.accepts(containerContext, NavigationOperation.Close(instance3))) + assertFalse(container.accepts(containerContext, NavigationOperation.Complete(instance3))) + } + + @Test + fun `NavigationContainer accepts open operations based on filter`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key1 = NavigationKeyFixtures.SimpleKey() + val instance1 = key1.asInstance() + + // By default, container accepts no open operations + assertFalse(container.accepts(containerContext, NavigationOperation.Open(instance1))) + + // Set filter to accept all + container.setFilter(acceptAll()) + assertTrue(container.accepts(containerContext, NavigationOperation.Open(instance1))) + + // Set filter to accept none + container.setFilter(acceptNone()) + assertFalse(container.accepts(containerContext, NavigationOperation.Open(instance1))) + } + + @Test + fun `NavigationContainer with fromChildrenOnly filter accepts operations from child contexts`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + // Create a child destination context under this container + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val childDestinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + // Set filter with fromChildrenOnly = true + val filter = NavigationContainerFilter(fromChildrenOnly = true) { true } + container.setFilter(filter) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + + // Operation from child context should be accepted + assertTrue(container.accepts(childDestinationContext, NavigationOperation.Open(instance))) + assertTrue(container.accepts(containerContext, NavigationOperation.Open(instance))) + } + + @Test + fun `NavigationContainer with fromChildrenOnly filter rejects operations from non-child contexts`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + // Create another container and destination context (not a child of our container) + val otherContainerContext = NavigationContextFixtures.createContainerContext(rootContext) + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val nonChildDestinationContext = + NavigationContextFixtures.createDestinationContext(otherContainerContext, destination) + + // Set filter with fromChildrenOnly = true + val filter = NavigationContainerFilter(fromChildrenOnly = true) { true } + container.setFilter(filter) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + + // Operation from non-child context should be rejected + assertFalse(container.accepts(nonChildDestinationContext, NavigationOperation.Open(instance))) + assertFalse(container.accepts(otherContainerContext, NavigationOperation.Open(instance))) + assertFalse(container.accepts(rootContext, NavigationOperation.Open(instance))) + } + + @Test + fun `NavigationContainer with fromChildrenOnly false accepts operations from any context`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + // Create contexts at different levels + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val childDestinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val otherContainerContext = NavigationContextFixtures.createContainerContext(rootContext) + val nonChildDestinationContext = + NavigationContextFixtures.createDestinationContext(otherContainerContext, destination) + + // Set filter with fromChildrenOnly = false (default) + val filter = NavigationContainerFilter(fromChildrenOnly = false) { true } + container.setFilter(filter) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + + // Operations from all contexts should be accepted + assertTrue(container.accepts(childDestinationContext, NavigationOperation.Open(instance))) + assertTrue(container.accepts(containerContext, NavigationOperation.Open(instance))) + assertTrue(container.accepts(nonChildDestinationContext, NavigationOperation.Open(instance))) + assertTrue(container.accepts(otherContainerContext, NavigationOperation.Open(instance))) + assertTrue(container.accepts(rootContext, NavigationOperation.Open(instance))) + } + + @Test + fun `NavigationContainer with fromChildrenOnly filter and predicate applies both conditions`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val childDestinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val otherContainerContext = NavigationContextFixtures.createContainerContext(rootContext) + val nonChildDestinationContext = + NavigationContextFixtures.createDestinationContext(otherContainerContext, destination) + + val acceptedKey = NavigationKeyFixtures.SimpleKey() + val rejectedKey = NavigationKeyFixtures.SimpleKey() + + // Set filter with fromChildrenOnly = true and a key predicate + val filter = NavigationContainerFilter(fromChildrenOnly = true) { it.key == acceptedKey } + container.setFilter(filter) + + val acceptedInstance = acceptedKey.asInstance() + val rejectedInstance = rejectedKey.asInstance() + + // Child context with accepted key should be accepted + assertTrue(container.accepts(childDestinationContext, NavigationOperation.Open(acceptedInstance))) + + // Child context with rejected key should be rejected + assertFalse(container.accepts(childDestinationContext, NavigationOperation.Open(rejectedInstance))) + + // Non-child context with accepted key should be rejected + assertFalse(container.accepts(nonChildDestinationContext, NavigationOperation.Open(acceptedInstance))) + + // Non-child context with rejected key should be rejected + assertFalse(container.accepts(nonChildDestinationContext, NavigationOperation.Open(rejectedInstance))) + } + + @Test + fun `Open operation adds instance to backstack`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = key.asInstance() + + assertEquals(0, container.backstack.size) + + container.execute(destinationContext, NavigationOperation.Open(instance)) + + assertEquals(1, container.backstack.size) + assertEquals(instance, container.backstack.first()) + } + + @Test + fun `Close operation removes instance from backstack`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key1 = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key1) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + + container.setBackstackDirect(backstackOf(instance1, instance2)) + assertEquals(2, container.backstack.size) + + container.execute(destinationContext, NavigationOperation.Close(instance1)) + + assertEquals(1, container.backstack.size) + assertEquals(instance2, container.backstack.first()) + } + + @Test + fun `Complete operation removes instance from backstack`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key1 = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key1) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + + container.setBackstackDirect(backstackOf(instance1, instance2)) + assertEquals(2, container.backstack.size) + + container.execute(destinationContext, NavigationOperation.Complete(instance1)) + + assertEquals(1, container.backstack.size) + assertEquals(instance2, container.backstack.first()) + } + + @Test + fun `Multiple operations are processed in order`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance3 = NavigationKeyFixtures.SimpleKey().asInstance() + + container.setBackstackDirect(backstackOf(instance1)) + + val aggregateOperation = NavigationOperation.AggregateOperation( + listOf( + NavigationOperation.Open(instance2), + NavigationOperation.Open(instance3), + NavigationOperation.Close(instance1), + ) + ) + + container.execute(destinationContext, aggregateOperation) + + assertEquals(2, container.backstack.size) + assertEquals(instance2, container.backstack[0]) + assertEquals(instance3, container.backstack[1]) + } + + @Test + fun `NavigationInterceptor can modify open operations`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val originalKey = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(originalKey) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val replacementKey = NavigationKeyFixtures.SimpleKey() + + var interceptorCalled = false + val interceptor = navigationInterceptor { + onOpened { + if (key !== originalKey) return@onOpened + interceptorCalled = true + replaceWith(replacementKey) + } + } + + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(originalKey.asInstance())) + + assertTrue(interceptorCalled) + assertEquals(1, container.backstack.size) + assertEquals(replacementKey, container.backstack.first().key) + } + + @Test + fun `NavigationInterceptor can cancel operations`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + var interceptorCalled = false + val interceptor = navigationInterceptor { + onOpened { + interceptorCalled = true + cancel() + } + } + + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(key.asInstance())) + + assertTrue(interceptorCalled) + assertEquals(0, container.backstack.size) + } + + @Test + fun `EmptyInterceptor prevents container from becoming empty`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + var emptyInterceptorCalled = false + val emptyInterceptor = object : NavigationContainer.EmptyInterceptor() { + override fun onEmpty(transition: NavigationTransition): Result { + emptyInterceptorCalled = true + return denyEmpty() + } + } + + container.addEmptyInterceptor(emptyInterceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertTrue(emptyInterceptorCalled) + assertEquals(1, container.backstack.size) // Container should still have the instance + } + + @Test + fun `EmptyInterceptor allows container to become empty`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + var emptyInterceptorCalled = false + val emptyInterceptor = object : NavigationContainer.EmptyInterceptor() { + override fun onEmpty(transition: NavigationTransition): Result { + emptyInterceptorCalled = true + return allowEmpty() + } + } + + container.addEmptyInterceptor(emptyInterceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertTrue(emptyInterceptorCalled) + assertEquals(0, container.backstack.size) + } + + @Test + fun `Multiple EmptyInterceptors any deny wins and side effects from every deny run`() = runEnroTest { + // The current logic is `emptyInterceptorResults.any { it is DenyEmpty }` + // — every DenyEmpty result has its side effect run via + // `filterIsInstance().onEach { performSideEffect() }`. So if + // two interceptors register and only one denies (with a side effect), + // the deny wins and that side effect runs. If both deny with side + // effects, BOTH side effects run. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + var denySideEffectRan = false + var allowInterceptorCalled = false + + val denyInterceptor = object : NavigationContainer.EmptyInterceptor() { + override fun onEmpty(transition: NavigationTransition): Result { + return denyEmptyAnd { denySideEffectRan = true } + } + } + val allowInterceptor = object : NavigationContainer.EmptyInterceptor() { + override fun onEmpty(transition: NavigationTransition): Result { + allowInterceptorCalled = true + return allowEmpty() + } + } + container.addEmptyInterceptor(denyInterceptor) + container.addEmptyInterceptor(allowInterceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertTrue(allowInterceptorCalled, "Allow interceptor should still be consulted") + assertTrue(denySideEffectRan, "Side effect from a DenyEmpty result should run even when another interceptor returns AllowEmpty") + assertEquals(1, container.backstack.size, "A single DenyEmpty wins over AllowEmpty — backstack should not be emptied") + } + + @Test + fun `Controller-level interceptors run after container-level interceptors`() = runEnroTest { + // Documented order: interceptors registered on the container run first, + // followed by interceptors from controller.interceptors.aggregateInterceptor. + // See NavigationContainer.execute -- `interceptors + controller.interceptors.aggregateInterceptor`. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val ordering = mutableListOf() + val containerInterceptor = navigationInterceptor { + onOpened { + ordering += "container" + continueWithOpen() + } + } + val controllerInterceptor = navigationInterceptor { + onOpened { + ordering += "controller" + continueWithOpen() + } + } + container.addInterceptor(containerInterceptor) + EnroTest.getCurrentNavigationController().interceptors.addInterceptor(controllerInterceptor) + + container.execute( + destinationContext, + NavigationOperation.Open(NavigationKeyFixtures.SimpleKey().asInstance()), + ) + + assertEquals(listOf("container", "controller"), ordering) + } + + @Test + fun `Open of an instance already in the backstack reorders without firing onOpened interceptor`() = runEnroTest { + // processOperations short-circuits the interceptor chain for Opens + // whose instance.id is already in backstackById — these are treated as + // reorders, not new entries. Asserts that contract. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val existing = NavigationKeyFixtures.SimpleKey().asInstance() + val top = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(existing, top)) + + var onOpenedCount = 0 + val interceptor = navigationInterceptor { + onOpened { + onOpenedCount++ + continueWithOpen() + } + } + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(existing)) + + assertEquals( + expected = 0, + actual = onOpenedCount, + message = "Reordering an already-present instance should bypass onOpened entirely", + ) + assertEquals(2, container.backstack.size) + assertEquals(top.id, container.backstack[0].id, "previous top should drop to the bottom") + assertEquals(existing.id, container.backstack[1].id, "reordered instance should move to the top") + } + + @Test + fun `EmptyInterceptor with side effect`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + var sideEffectExecuted = false + val emptyInterceptor = object : NavigationContainer.EmptyInterceptor() { + override fun onEmpty(transition: NavigationTransition): Result { + return denyEmptyAnd { + sideEffectExecuted = true + } + } + } + + container.addEmptyInterceptor(emptyInterceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertTrue(sideEffectExecuted) + assertEquals(1, container.backstack.size) // Container should still have the instance + } + + @Test + fun `Multiple interceptors are applied in order`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key1 = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key1) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val key2 = NavigationKeyFixtures.SimpleKey() + val key3 = NavigationKeyFixtures.SimpleKey() + + var interceptor1Called = false + var interceptor2Called = false + + val interceptor1 = navigationInterceptor { + onOpened { + if (key == key1) { + interceptor1Called = true + replaceWith(key2) + } + } + } + + val interceptor2 = navigationInterceptor { + onOpened { + interceptor2Called = true + if (key == key2) { + replaceWith(key3) + } else { + continueWithOpen() + } + } + } + + container.addInterceptor(interceptor1) + container.addInterceptor(interceptor2) + + container.execute(destinationContext, NavigationOperation.Open(key1.asInstance())) + + assertTrue(interceptor1Called) + assertTrue(interceptor2Called) + assertEquals(1, container.backstack.size) + assertEquals(key3, container.backstack.first().key) + } + + @Test + fun `Interceptor returning AggregateOperation containing the original op does not recurse`() = runEnroTest { + // Regression test for the aggregate-handling branch in + // NavigationInterceptor.processOperations. When an interceptor returns + // an AggregateOperation that includes the operation it was just given + // (singleton-after-anchor pattern: "do the original Open, AND also + // close these other entries"), the original op must be counted once + // — feeding it back through the interceptor would loop indefinitely + // because the interceptor would keep returning the same aggregate. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val anchorInstance = NavigationKeyFixtures.SimpleKey().asInstance() + val existingDetailInstance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(anchorInstance, existingDetailInstance)) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val newDetailInstance = NavigationKeyFixtures.SimpleKey().asInstance() + + var interceptCount = 0 + val interceptor = navigationInterceptor { + onOpened { + // Only react to the new detail Open — the recursion guard + // failure mode would manifest as this counter blowing past 1. + if (instance.id != newDetailInstance.id) continueWithOpen() + interceptCount++ + replaceWith( + NavigationOperation.AggregateOperation( + instance.asOpenOperation(), + existingDetailInstance.asCloseOperation(), + ) + ) + } + } + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(newDetailInstance)) + + assertEquals( + expected = 1, + actual = interceptCount, + message = "Interceptor must fire exactly once — re-feeding the original op would re-invoke it.", + ) + assertEquals(2, container.backstack.size) + assertEquals(anchorInstance.id, container.backstack[0].id) + assertEquals(newDetailInstance.id, container.backstack[1].id) + } + + @Test + fun `OnNavigationKeyOpenedScope exposes backstack fromContext and containerContext`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val existingInstance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(existingInstance)) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + var capturedBackstack: NavigationBackstack? = null + var capturedFromContext: NavigationContext<*, *>? = null + var capturedContainerContext: ContainerContext? = null + + val interceptor = navigationInterceptor { + onOpened { + capturedBackstack = backstack + capturedFromContext = fromContext + capturedContainerContext = containerContext + continueWithOpen() + } + } + container.addInterceptor(interceptor) + + val triggerInstance = NavigationKeyFixtures.SimpleKey().asInstance() + container.execute(destinationContext, NavigationOperation.Open(triggerInstance)) + + assertEquals( + expected = listOf(existingInstance.id), + actual = capturedBackstack?.map { it.id }, + message = "backstack should reflect the state PRIOR to the new Open operation", + ) + assertSame(destinationContext, capturedFromContext, "fromContext should be the originating destination context") + assertSame(containerContext, capturedContainerContext, "containerContext should be the target container's context") + } + + @Test + fun `OnNavigationKeyClosedScope exposes backstack fromContext and containerContext`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + var capturedBackstack: NavigationBackstack? = null + var capturedFromContext: NavigationContext<*, *>? = null + var capturedContainerContext: ContainerContext? = null + + val interceptor = navigationInterceptor { + onClosed { + capturedBackstack = backstack + capturedFromContext = fromContext + capturedContainerContext = containerContext + continueWithClose() + } + } + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Close(instance)) + + assertEquals(listOf(instance.id), capturedBackstack?.map { it.id }) + assertSame(destinationContext, capturedFromContext) + assertSame(containerContext, capturedContainerContext) + } + + @Test + fun `OnNavigationKeyCompletedScope exposes backstack fromContext and containerContext`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(instance)) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + var capturedBackstack: NavigationBackstack? = null + var capturedFromContext: NavigationContext<*, *>? = null + var capturedContainerContext: ContainerContext? = null + + val interceptor = navigationInterceptor { + onCompleted { + capturedBackstack = backstack + capturedFromContext = fromContext + capturedContainerContext = containerContext + continueWithComplete() + } + } + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Complete(instance)) + + assertEquals(listOf(instance.id), capturedBackstack?.map { it.id }) + assertSame(destinationContext, capturedFromContext) + assertSame(containerContext, capturedContainerContext) + } + + @Test + fun `replaceWith operation on OnNavigationKeyOpenedScope can rewrite Open into a SetBackstack`() = runEnroTest { + // Locks in the replaceWith(operation: NavigationOperation) overload on + // OnNavigationKeyOpenedScope by returning a full SetBackstack transition + // — i.e. swapping the entire backstack out from under the original Open. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val firstInstance = NavigationKeyFixtures.SimpleKey().asInstance() + val secondInstance = NavigationKeyFixtures.SimpleKey().asInstance() + container.setBackstackDirect(backstackOf(firstInstance, secondInstance)) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val triggerInstance = NavigationKeyFixtures.SimpleKey().asInstance() + val firstReplacement = NavigationKeyFixtures.SimpleKey().asInstance() + val secondReplacement = NavigationKeyFixtures.SimpleKey().asInstance() + + val interceptor = navigationInterceptor { + onOpened { + // Only react to the trigger — anything else (including the + // SetBackstack-derived Opens of the replacements) should just + // continue, otherwise we'd loop on our own rewrite. + if (instance.id != triggerInstance.id) continueWithOpen() + replaceWith( + NavigationOperation.SetBackstack( + currentBackstack = backstack, + targetBackstack = backstackOf(firstReplacement, secondReplacement), + ) + ) + } + } + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(triggerInstance)) + + assertEquals(2, container.backstack.size) + assertEquals(firstReplacement.id, container.backstack[0].id) + assertEquals(secondReplacement.id, container.backstack[1].id) + } + + @Test + fun `Re-entrant execute from within an interceptor throws IllegalStateException`() = runEnroTest { + // Exercises NavigationContainer.executionMutex's re-entry guard. If an + // interceptor tries to drive another navigation operation through the + // same container mid-execute, we want a fast, descriptive error rather + // than silent corruption. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val intruderInstance = NavigationKeyFixtures.SimpleKey().asInstance() + val interceptor = navigationInterceptor { + onOpened { + containerContext.container.execute( + destinationContext, + NavigationOperation.Open(intruderInstance), + ) + continueWithOpen() + } + } + container.addInterceptor(interceptor) + + val triggerInstance = NavigationKeyFixtures.SimpleKey().asInstance() + val error = assertFailsWith { + container.execute(destinationContext, NavigationOperation.Open(triggerInstance)) + } + assertTrue( + actual = error.message?.contains("navigationInterceptor") == true, + message = "Error should call out the interceptor as the cause; was: ${error.message}", + ) + } + + @Test + fun `SideEffect operations are executed`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + var sideEffectExecuted = false + val sideEffect = NavigationOperation.SideEffect { + sideEffectExecuted = true + } + + container.execute(destinationContext, sideEffect) + + assertTrue(sideEffectExecuted) + } + + @Test + fun `Interceptor beforeIntercept can modify operation list`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key1 = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key1) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val key2 = NavigationKeyFixtures.SimpleKey() + + val interceptor = object : NavigationInterceptor() { + override fun beforeIntercept( + fromContext: NavigationContext<*, *>, + containerContext: ContainerContext, + operations: List, + ): List { + // Add an extra operation + return operations + NavigationOperation.Open(key2.asInstance()) + } + } + + container.addInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(key1.asInstance())) + + assertEquals(2, container.backstack.size) + assertEquals(key1, container.backstack[0].key) + assertEquals(key2, container.backstack[1].key) + } + + @Test + fun `Remove interceptor stops it from being called`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + var interceptorCalled = false + val interceptor = navigationInterceptor { + onOpened { + interceptorCalled = true + cancel() + } + } + + container.addInterceptor(interceptor) + container.removeInterceptor(interceptor) + + container.execute(destinationContext, NavigationOperation.Open(key.asInstance())) + + assertFalse(interceptorCalled) + assertEquals(1, container.backstack.size) + } + + @Test + fun `Container requests active in root when backstack changes`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + rootContext.registerChild(containerContext) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + // Create another container to verify active switching + val otherContainer = NavigationContextFixtures.createContainerContext(rootContext) + rootContext.registerChild(otherContainer) + rootContext.setActiveContainer(otherContainer.id) + + assertEquals(otherContainer, rootContext.activeChild) + + // Execute operation should make this container active + container.execute(destinationContext, NavigationOperation.Open(key.asInstance())) + + assertEquals(containerContext, rootContext.activeChild) + } + + @Test + fun `Opening existing instance reorders backstack instead of duplicating`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance3 = NavigationKeyFixtures.SimpleKey().asInstance() + + // Set initial backstack + container.setBackstackDirect(backstackOf(instance1, instance2, instance3)) + assertEquals(3, container.backstack.size) + assertEquals(instance1, container.backstack[0]) + assertEquals(instance2, container.backstack[1]) + assertEquals(instance3, container.backstack[2]) + + // Open instance1 which is already at position 0 + container.execute(destinationContext, NavigationOperation.Open(instance1)) + + // Backstack should be reordered with instance1 moved to the top + assertEquals(3, container.backstack.size) + assertEquals(instance2, container.backstack[0]) + assertEquals(instance3, container.backstack[1]) + assertEquals(instance1, container.backstack[2]) + } + + @Test + fun `Opening existing instance from middle of backstack moves it to top`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance3 = NavigationKeyFixtures.SimpleKey().asInstance() + + // Set initial backstack + container.setBackstackDirect(backstackOf(instance1, instance2, instance3)) + + // Open instance2 which is in the middle + container.execute(destinationContext, NavigationOperation.Open(instance2)) + + // Backstack should be reordered with instance2 moved to the top + assertEquals(3, container.backstack.size) + assertEquals(instance1, container.backstack[0]) + assertEquals(instance3, container.backstack[1]) + assertEquals(instance2, container.backstack[2]) + } + + @Test + fun `Opening existing instance that is already at top does not change backstack`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance3 = NavigationKeyFixtures.SimpleKey().asInstance() + + // Set initial backstack + container.setBackstackDirect(backstackOf(instance1, instance2, instance3)) + + // Open instance3 which is already at the top + container.execute(destinationContext, NavigationOperation.Open(instance3)) + + // Backstack should remain unchanged + assertEquals(3, container.backstack.size) + assertEquals(instance1, container.backstack[0]) + assertEquals(instance2, container.backstack[1]) + assertEquals(instance3, container.backstack[2]) + } + + @Test + fun `Multiple operations with existing instances reorder correctly`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance1 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance2 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance3 = NavigationKeyFixtures.SimpleKey().asInstance() + val instance4 = NavigationKeyFixtures.SimpleKey().asInstance() + + // Set initial backstack + container.setBackstackDirect(backstackOf(instance1, instance2, instance3)) + + val aggregateOperation = NavigationOperation.AggregateOperation( + listOf( + NavigationOperation.Open(instance1), // Move to top + NavigationOperation.Open(instance4), // Add new + NavigationOperation.Open(instance2), // Move to top + ) + ) + + container.execute(destinationContext, aggregateOperation) + + // Expected order: instance3, instance1, instance4, instance2 + assertEquals(4, container.backstack.size) + assertEquals(instance3, container.backstack[0]) + assertEquals(instance1, container.backstack[1]) + assertEquals(instance4, container.backstack[2]) + assertEquals(instance2, container.backstack[3]) + } + + @Test + fun `Opening existing instance with single item backstack does nothing`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val key = NavigationKeyFixtures.SimpleKey() + val destination = NavigationDestinationFixtures.create(key) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, destination) + + val instance = NavigationKeyFixtures.SimpleKey().asInstance() + + // Set backstack with single item + container.setBackstackDirect(backstackOf(instance)) + + // Open the same instance + container.execute(destinationContext, NavigationOperation.Open(instance)) + + // Backstack should remain unchanged + assertEquals(1, container.backstack.size) + assertEquals(instance, container.backstack[0]) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationHandleConfigurationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationHandleConfigurationTests.kt new file mode 100644 index 000000000..22a421d9a --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationHandleConfigurationTests.kt @@ -0,0 +1,74 @@ +package dev.enro + +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.assertClosed +import dev.enro.test.assertNotClosed +import dev.enro.test.createTestNavigationHandle +import dev.enro.test.runEnroTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests that lock down the semantics of [NavigationHandle.requestClose] / + * [NavigationHandle.close] and the [NavigationHandleConfiguration.onCloseRequested] + * callback registry. These two entry points behave differently — `close()` + * unconditionally executes a Close; `requestClose()` consults any registered + * `onCloseRequested` callback first — and that distinction is currently held + * in place only by code review. + */ +class NavigationHandleConfigurationTests { + + @Test + fun `requestClose with a registered callback runs the callback and does NOT execute Close`() = runEnroTest { + val handle = createTestNavigationHandle(NavigationKeyFixtures.SimpleKey()) + var callbackRan = false + NavigationHandleConfiguration(handle).onCloseRequested { + callbackRan = true + } + + handle.requestClose() + + assertTrue(callbackRan, "onCloseRequested callback should fire") + handle.assertNotClosed() + } + + @Test + fun `requestClose with no callback executes Close directly`() = runEnroTest { + val handle = createTestNavigationHandle(NavigationKeyFixtures.SimpleKey()) + + handle.requestClose() + + handle.assertClosed() + } + + @Test + fun `close bypasses onCloseRequested callback`() = runEnroTest { + val handle = createTestNavigationHandle(NavigationKeyFixtures.SimpleKey()) + var callbackRan = false + NavigationHandleConfiguration(handle).onCloseRequested { + callbackRan = true + } + + handle.close() + + assertFalse(callbackRan, "close() should not consult onCloseRequested callbacks") + handle.assertClosed() + } + + @Test + fun `Multiple onCloseRequested callbacks throw with a clear error`() = runEnroTest { + val handle = createTestNavigationHandle(NavigationKeyFixtures.SimpleKey()) + NavigationHandleConfiguration(handle).onCloseRequested { /* first */ } + NavigationHandleConfiguration(handle).onCloseRequested { /* second */ } + + val error = assertFailsWith { + handle.requestClose() + } + assertTrue( + actual = error.message?.contains("Multiple onCloseRequested callbacks") == true, + message = "Error should call out duplicate onCloseRequested registration; was: ${error.message}", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationHandleExtensionsTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationHandleExtensionsTests.kt new file mode 100644 index 000000000..eba340493 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationHandleExtensionsTests.kt @@ -0,0 +1,49 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro + +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.createTestNavigationHandle +import dev.enro.test.runEnroTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for the smaller NavigationHandle extension functions that don't + * already have dedicated coverage. Today this file just exercises + * `closeAndReplaceWith`; new extension-level tests can land here as + * they're written. + */ +class NavigationHandleExtensionsTests { + + @Test + fun `closeAndReplaceWith dispatches Close of self and Open of the replacement key`() = runEnroTest { + val handle = createTestNavigationHandle(NavigationKeyFixtures.SimpleKey()) + val replacementKey = NavigationKeyFixtures.SimpleKey() + + handle.closeAndReplaceWith(replacementKey) + + assertEquals( + expected = 2, + actual = handle.operations.size, + message = "closeAndReplaceWith should unpack to Close(self) + Open(replacement); operations: ${handle.operations}", + ) + + val firstOp = handle.operations[0] + assertTrue(firstOp is NavigationOperation.Close<*>, "First op must be Close") + assertEquals( + expected = handle.instance.id, + actual = (firstOp as NavigationOperation.Close<*>).instance.id, + message = "Close must target the handle's own instance", + ) + + val secondOp = handle.operations[1] + assertTrue(secondOp is NavigationOperation.Open<*>, "Second op must be Open") + assertEquals( + expected = replacementKey, + actual = (secondOp as NavigationOperation.Open<*>).instance.key, + message = "Open's instance must carry the replacement key", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationPluginTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationPluginTests.kt new file mode 100644 index 000000000..de2999b47 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationPluginTests.kt @@ -0,0 +1,133 @@ +package dev.enro + +import androidx.compose.material3.Text +import dev.enro.controller.createNavigationModule +import dev.enro.plugin.NavigationPlugin +import dev.enro.test.EnroTest +import dev.enro.test.runEnroTest +import dev.enro.ui.NavigationDestination +import dev.enro.ui.navigationDestination +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +/** + * Tests for the [NavigationPlugin] SPI and its [PluginRepository] wiring. + * Plugins are the framework's extension point for cross-cutting hooks + * like analytics, lifecycle stamping, or destination-metadata rewriting + * — none of which had unit coverage before. + */ +class NavigationPluginTests { + + @Test + fun `Plugin added via addModule receives onAttached when controller is already installed`() = runEnroTest { + val attachedControllers = mutableListOf() + val plugin = object : NavigationPlugin() { + override fun onAttached(controller: EnroController) { + attachedControllers += controller + } + } + + val controller = EnroTest.getCurrentNavigationController() + controller.addModule(createNavigationModule { plugin(plugin) }) + + assertEquals( + expected = 1, + actual = attachedControllers.size, + message = "Plugin onAttached should fire once when the plugin is added to an already-installed controller", + ) + assertSame(controller, attachedControllers.single()) + } + + @Test + fun `Plugin onDetached fires when controller is uninstalled`() = runEnroTest { + var detachedCount = 0 + val plugin = object : NavigationPlugin() { + override fun onDetached(controller: EnroController) { + detachedCount++ + } + } + + EnroTest.getCurrentNavigationController() + .addModule(createNavigationModule { plugin(plugin) }) + + // Uninstall manually inside the test so we can observe onDetached + // running while runEnroTest's finally still does the no-op cleanup. + EnroTest.uninstallNavigationController() + + assertEquals( + expected = 1, + actual = detachedCount, + message = "Plugin onDetached should fire exactly once when the controller is uninstalled", + ) + } + + @Test + fun `Multiple plugins receive onAttached in registration order`() = runEnroTest { + val events = mutableListOf() + val pluginA = object : NavigationPlugin() { + override fun onAttached(controller: EnroController) { + events += "A" + } + } + val pluginB = object : NavigationPlugin() { + override fun onAttached(controller: EnroController) { + events += "B" + } + } + val pluginC = object : NavigationPlugin() { + override fun onAttached(controller: EnroController) { + events += "C" + } + } + + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + plugin(pluginA) + plugin(pluginB) + plugin(pluginC) + } + ) + + assertEquals( + expected = listOf("A", "B", "C"), + actual = events, + message = "Plugins should be notified in the order they were registered with the module", + ) + } + + @Test + fun `onDestinationCreated additionalMetadata is applied to the destination`() = runEnroTest { + val plugin = object : NavigationPlugin() { + override fun onDestinationCreated( + destination: NavigationDestination<*>, + additionalMetadata: MutableMap, + ) { + additionalMetadata["plugin-added-key"] = "plugin-added-value" + } + } + + val controller = EnroTest.getCurrentNavigationController() + controller.addModule( + createNavigationModule { + plugin(plugin) + destination( + navigationDestination { Text("plugin test") } + ) + } + ) + + val instance = TestPluginKey.asInstance() + val destination = controller.bindings.destinationFor(instance) + + assertEquals( + expected = "plugin-added-value", + actual = destination.metadata["plugin-added-key"], + message = "Metadata added by the plugin's onDestinationCreated should be present on the resolved destination", + ) + } +} + +@Serializable +data object TestPluginKey : NavigationKey diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/PathBindingIntegrationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/PathBindingIntegrationTests.kt new file mode 100644 index 000000000..3f404ad47 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/PathBindingIntegrationTests.kt @@ -0,0 +1,324 @@ +package dev.enro + +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.controller.createNavigationModule +import dev.enro.path.NavigationPathBinding +import dev.enro.path.PathData +import dev.enro.path.createPathBinding +import dev.enro.path.fromBinding +import dev.enro.path.getBackstackFromPath +import dev.enro.path.getNavigationKeyFromPath +import dev.enro.path.getPathFromNavigationKey +import dev.enro.test.EnroTest +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.runEnroTest +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Integration tests for the path-binding lookup that happens through the + * controller's [PathRepository]. The existing [path.NavigationPathBindingTests] + * covers path-pattern matching and key↔path conversion in isolation; these + * tests assert that bindings registered via [createNavigationModule] are + * routable via [getNavigationKeyFromPath] from any navigation context. + */ +class PathBindingIntegrationTests { + + @Test + fun `getNavigationKeyFromPath resolves a registered binding into the right key`() = runEnroTest { + val binding = NavigationPathBinding.createPathBinding( + pattern = "products/{productId}", + propertyOne = ProductDetailPathKey::productId, + constructor = { id -> ProductDetailPathKey(id) }, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { path(binding) } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val resolved = rootContext.getNavigationKeyFromPath("products/abc-123") + + assertEquals( + expected = ProductDetailPathKey("abc-123"), + actual = resolved, + message = "Path binding should deserialize the {productId} segment into the right key", + ) + } + + @Test + fun `getNavigationKeyFromPath returns null when no binding matches the path`() = runEnroTest { + val binding = NavigationPathBinding.createPathBinding( + pattern = "settings", + constructor = { SettingsPathKey }, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { path(binding) } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val resolved = rootContext.getNavigationKeyFromPath("totally/unknown/path") + + assertNull( + actual = resolved, + message = "An unmatched path should resolve to null rather than throw", + ) + } + + @Test + fun `getNavigationKeyFromPath disambiguates between multiple bindings by pattern`() = runEnroTest { + val productBinding = NavigationPathBinding.createPathBinding( + pattern = "products/{productId}", + propertyOne = ProductDetailPathKey::productId, + constructor = { id -> ProductDetailPathKey(id) }, + ) + val settingsBinding = NavigationPathBinding.createPathBinding( + pattern = "settings", + constructor = { SettingsPathKey }, + ) + + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + path(productBinding) + path(settingsBinding) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + assertEquals( + expected = ProductDetailPathKey("p-7"), + actual = rootContext.getNavigationKeyFromPath("products/p-7"), + message = "Product path should resolve to the product key", + ) + assertEquals( + expected = SettingsPathKey, + actual = rootContext.getNavigationKeyFromPath("settings"), + message = "Settings path should resolve to the settings key", + ) + } + + @Test + fun `getNavigationKeyFromPath prefers the most specific matching binding`() = runEnroTest { + val genericProduct = NavigationPathBinding.createPathBinding( + pattern = "products/{productId}", + propertyOne = ProductDetailPathKey::productId, + constructor = { id -> ProductDetailPathKey(id) }, + ) + val specialProduct = NavigationPathBinding.createPathBinding( + pattern = "products/special", + constructor = { SettingsPathKey }, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + path(genericProduct) + path(specialProduct) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + assertEquals( + expected = SettingsPathKey, + actual = rootContext.getNavigationKeyFromPath("products/special"), + message = "Literal segment should win over parameter segment for the same path", + ) + assertEquals( + expected = ProductDetailPathKey("p-1"), + actual = rootContext.getNavigationKeyFromPath("products/p-1"), + message = "Generic binding should still match other paths", + ) + } + + @Test + fun `Two bindings that match the same path throw IllegalArgumentException when resolving`() = runEnroTest { + val first = NavigationPathBinding.createPathBinding( + pattern = "ambiguous", + constructor = { SettingsPathKey }, + ) + val second = NavigationPathBinding.createPathBinding( + pattern = "ambiguous", + constructor = { AnotherPathKey }, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + path(first) + path(second) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val error = assertFailsWith { + rootContext.getNavigationKeyFromPath("ambiguous") + } + assertTrue( + actual = error.message?.contains("Multiple path bindings") == true, + message = "Error should call out the ambiguity; was: ${error.message}", + ) + } + + @Test + fun `getPathFromNavigationKey serializes a key back to its registered path`() = runEnroTest { + val binding = NavigationPathBinding.createPathBinding( + pattern = "products/{productId}", + propertyOne = ProductDetailPathKey::productId, + constructor = { id -> ProductDetailPathKey(id) }, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { path(binding) } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val path = rootContext.getPathFromNavigationKey(ProductDetailPathKey("p-42")) + + assertEquals( + expected = "/products/p-42", + actual = path, + message = "Key should serialize through the registered binding into its URL form", + ) + } + + @Test + fun `getPathFromNavigationKey returns null when no binding is registered for the key`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule(createNavigationModule { }) + val rootContext = NavigationContextFixtures.createRootContext() + + val path = rootContext.getPathFromNavigationKey(ProductDetailPathKey("any-id")) + + assertNull( + actual = path, + message = "Without a registered binding, key->path should return null rather than throw", + ) + } + + @OptIn(ExperimentalEnroApi::class) + @Test + fun `NavigationKey PathBinding round-trips a key through fromBinding helper`() = runEnroTest { + val binding = NavigationPathBinding.fromBinding( + keyType = ProductDetailPathKey::class, + binding = ProductDetailPathKeyBinding, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { path(binding) } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val resolved = rootContext.getNavigationKeyFromPath("/binding/products?id=p-7") + val serialized = rootContext.getPathFromNavigationKey(ProductDetailPathKey("p-7")) + + assertEquals( + expected = ProductDetailPathKey("p-7"), + actual = resolved, + message = "fromBinding should drive the deserialize side via the user's PathBinding", + ) + assertEquals( + expected = "/binding/products?id=p-7", + actual = serialized, + message = "fromBinding should drive the serialize side via the user's PathBinding", + ) + } + + @Test + fun `getBackstackFromPath produces a single-entry backstack for a matching URL`() = runEnroTest { + val binding = NavigationPathBinding.createPathBinding( + pattern = "products/{productId}", + propertyOne = ProductDetailPathKey::productId, + constructor = { id -> ProductDetailPathKey(id) }, + ) + val controller = EnroTest.getCurrentNavigationController() + controller.addModule(createNavigationModule { path(binding) }) + + val backstack = controller.getBackstackFromPath("/products/c-1") + + assertEquals( + expected = listOf(ProductDetailPathKey("c-1")), + actual = backstack?.keys, + message = "getBackstackFromPath should wrap a resolved key in a single-entry backstack", + ) + } + + @Test + fun `getBackstackFromPath returns null for an unresolvable URL`() = runEnroTest { + val controller = EnroTest.getCurrentNavigationController() + controller.addModule(createNavigationModule { }) + + val backstack = controller.getBackstackFromPath("/something/nobody/registered") + + assertNull( + actual = backstack, + message = "Unmatched URL should produce null, leaving the caller to fall back", + ) + } + + @Test + fun `Value class path parameters round-trip through an explicit NavigationPathBinding`() = runEnroTest { + val binding = NavigationPathBinding( + keyType = ValueClassPathKey::class, + pattern = "/customers/{id}?count={count?}", + deserialize = { + ValueClassPathKey( + id = CustomerIdValue(require("id")), + count = optional("count")?.toInt()?.let { CountValue(it) }, + ) + }, + serialize = { key -> + set("id", key.id.value) + key.count?.let { v -> set("count", v.raw.toString()) } + }, + ) + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { path(binding) } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val resolved = rootContext.getNavigationKeyFromPath("/customers/cust-1?count=3") + val serialized = rootContext.getPathFromNavigationKey( + ValueClassPathKey(CustomerIdValue("cust-1"), CountValue(3)), + ) + + assertEquals( + expected = ValueClassPathKey(CustomerIdValue("cust-1"), CountValue(3)), + actual = resolved, + ) + assertEquals( + expected = "/customers/cust-1?count=3", + actual = serialized, + ) + } +} + +@Serializable +data class ProductDetailPathKey(val productId: String) : NavigationKey + +@Serializable +data object SettingsPathKey : NavigationKey + +@Serializable +data object AnotherPathKey : NavigationKey + +@OptIn(ExperimentalEnroApi::class) +private object ProductDetailPathKeyBinding : NavigationKey.PathBinding { + override val pattern: String = "/binding/products?id={id?}" + override fun deserialize(data: PathData): ProductDetailPathKey { + return ProductDetailPathKey(productId = data.optional("id") ?: "missing") + } + override fun serialize(builder: PathData.Builder, key: ProductDetailPathKey) { + builder.set("id", key.productId) + } +} + +@JvmInline +@Serializable +value class CustomerIdValue(val value: String) + +@JvmInline +@Serializable +value class CountValue(val raw: Int) + +@Serializable +data class ValueClassPathKey( + val id: CustomerIdValue, + val count: CountValue? = null, +) : NavigationKey diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/ResultChannelTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/ResultChannelTests.kt new file mode 100644 index 000000000..88f390bcc --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/ResultChannelTests.kt @@ -0,0 +1,143 @@ +package dev.enro + +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests for [NavigationResultChannel] and its interaction with the + * container's [NavigationInterceptor.Companion.processOperations] pipeline. + * + * Uses [NavigationKeyFixtures.StringResultKey] (a `NavigationKey.WithResult`) + * for any Complete operations so we go through the public 2-arg + * `NavigationOperation.Complete(WithResult, result)` overload rather than + * trying to reach the private primary constructor with an + * `@file:Suppress` hack — which compiles on JVM but fails the K/Native + * linker. + * + * The companion-object state ([NavigationResultChannel.pendingResults]) is + * cleared after each test to avoid cross-test contamination — tests run on + * the same process, and a stale entry in `pendingResults` would otherwise + * surface as a flaky failure later. + */ +class ResultChannelTests { + + @AfterTest + fun clearPendingResults() { + NavigationResultChannel.pendingResults.value = emptyMap() + } + + @Test + fun `Complete registerResult adds a Completed result to pendingResults for instances with ResultIdKey`() = runEnroTest { + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "channel") + val instance = NavigationKeyFixtures.StringResultKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + val completeOp = NavigationOperation.Complete(instance, "the result") + + completeOp.registerResult() + + val pending = NavigationResultChannel.pendingResults.value[resultId] + assertTrue( + actual = pending is NavigationResult.Completed<*>, + message = "Expected a Completed result in pendingResults; was: ${pending?.let { it::class.simpleName }}", + ) + assertSame(instance, pending.instance, "Completed result must reference the same instance") + } + + @Test + fun `Close registerResult adds a Closed result to pendingResults for instances with ResultIdKey`() = runEnroTest { + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "channel") + val instance = NavigationKeyFixtures.SimpleKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + val closeOp = NavigationOperation.Close(instance) + + closeOp.registerResult() + + val pending = NavigationResultChannel.pendingResults.value[resultId] + assertTrue( + actual = pending is NavigationResult.Closed, + message = "Expected a Closed result in pendingResults; was: ${pending?.let { it::class.simpleName }}", + ) + } + + @Test + fun `silent Close registerResult does not add to pendingResults`() = runEnroTest { + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "channel") + val instance = NavigationKeyFixtures.SimpleKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + val silentCloseOp = NavigationOperation.Close(instance, silent = true) + + silentCloseOp.registerResult() + + assertNull( + actual = NavigationResultChannel.pendingResults.value[resultId], + message = "Silent close should not publish a result to pendingResults", + ) + } + + @Test + fun `registerResult is a no-op for instances without ResultIdKey`() = runEnroTest { + val instance = NavigationKeyFixtures.StringResultKey().asInstance() + val completeOp = NavigationOperation.Complete(instance, "result") + + // No ResultIdKey on the instance — should silently no-op. + completeOp.registerResult() + + assertTrue( + actual = NavigationResultChannel.pendingResults.value.isEmpty(), + message = "registerResult must not publish anything when instance has no ResultIdKey", + ) + } + + @Test + fun `Complete via the container strips other backstack entries sharing the same ResultIdKey`() = runEnroTest { + // processOperations' "result deduplication" path: when a Complete + // fires with a ResultIdKey, any other backstack instances sharing + // the same result id are stripped from the resulting backstack. + // This is how multi-step result flows (registerForFlowResult) collapse + // the intermediate steps when the flow completes. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val anchor = NavigationKeyFixtures.SimpleKey().asInstance() + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "flow") + val stepOne = NavigationKeyFixtures.SimpleKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + val stepTwo = NavigationKeyFixtures.StringResultKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + container.setBackstackDirect(backstackOf(anchor, stepOne, stepTwo)) + + container.execute(destinationContext, NavigationOperation.Complete(stepTwo, "done")) + + val remainingIds = container.backstack.map { it.id } + assertEquals( + expected = listOf(anchor.id), + actual = remainingIds, + message = "Completing stepTwo (resultId=flow) should strip every backstack entry sharing that result id; remaining: $remainingIds", + ) + assertFalse( + actual = remainingIds.contains(stepOne.id), + message = "stepOne shares the flow's result id and should have been stripped", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/ResultDelegationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/ResultDelegationTests.kt new file mode 100644 index 000000000..91b22436b --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/ResultDelegationTests.kt @@ -0,0 +1,149 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro + +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.createTestNavigationHandle +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests for the `CompleteFrom` / `closeAndCompleteFrom` family of + * operations. These let a destination open a *different* destination as + * the delegate that will produce the original waiter's result -- the + * delegate inherits the origin's [NavigationResultChannel.ResultIdKey] + * so that when the delegate completes, the result reaches the origin's + * registered channel instead of the delegate's. + * + * This is what `registerForFlowResult { open(...); openAnother(...) }` + * builds on, and what `navigation.closeAndCompleteFrom(key)` uses to + * forward a pending result to a successor screen. + */ +class ResultDelegationTests { + + @AfterTest + fun clearPendingResults() { + NavigationResultChannel.pendingResults.value = emptyMap() + } + + @Test + fun `CompleteFrom propagates ResultIdKey from origin to delegate and returns Open of delegate`() = runEnroTest { + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "delegated") + + val origin = NavigationKeyFixtures.SimpleKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + val delegate = NavigationKeyFixtures.SimpleKey().asInstance() + + val operation = NavigationOperation.CompleteFrom(origin, delegate) + + assertEquals( + expected = resultId, + actual = delegate.metadata.get(NavigationResultChannel.ResultIdKey), + message = "Delegate should inherit the origin's ResultIdKey so its eventual completion is routed back to the origin's channel", + ) + assertSame( + expected = delegate, + actual = operation.instance, + message = "CompleteFrom should return an Open of the delegate instance", + ) + } + + @Test + fun `CompleteFrom delegate's completion routes its result to the origin's ResultIdKey`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "delegated") + // Use NavigationKey.WithResult so the eventual + // NavigationOperation.Complete(delegate, "...") goes through the + // public typed Complete invoke instead of the private constructor. + val origin = NavigationKeyFixtures.StringResultKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + val delegate = NavigationKeyFixtures.StringResultKey().asInstance() + + // Origin is already on the backstack waiting for a result. + container.setBackstackDirect(backstackOf(origin)) + + // CompleteFrom transfers the resultId to the delegate and opens it. + container.execute( + destinationContext, + NavigationOperation.CompleteFrom(origin, delegate), + ) + + // Delegate is now on top, sharing the origin's ResultIdKey. + assertEquals(2, container.backstack.size) + assertEquals( + expected = resultId, + actual = delegate.metadata.get(NavigationResultChannel.ResultIdKey), + ) + + // Completing the delegate publishes the result under the origin's + // ResultIdKey -- which is what the origin's NavigationResultChannel + // is observing. + container.execute(destinationContext, NavigationOperation.Complete(delegate, "delegated result")) + + val pending = NavigationResultChannel.pendingResults.value[resultId] + assertTrue( + actual = pending is NavigationResult.Completed<*>, + message = "Delegate's Complete should publish a Completed result under the origin's ResultIdKey; pending: $pending", + ) + } + + @Test + fun `closeAndCompleteFrom dispatches Close of self and Open of the delegate with propagated ResultIdKey`() = runEnroTest { + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "delegated") + + // Build a TestNavigationHandle whose instance already carries a + // ResultIdKey (as it would if it had been opened by a result channel). + val handle = createTestNavigationHandle(NavigationKeyFixtures.SimpleKey()).apply { + instance.metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + val delegateKey = NavigationKeyFixtures.SimpleKey() + + handle.closeAndCompleteFrom(delegateKey) + + assertEquals( + expected = 2, + actual = handle.operations.size, + message = "closeAndCompleteFrom should unpack to Close(self) + Open(delegate); operations: ${handle.operations}", + ) + + val firstOp = handle.operations[0] + assertTrue(firstOp is NavigationOperation.Close<*>, "First op must be Close") + assertEquals( + expected = handle.instance.id, + actual = (firstOp as NavigationOperation.Close<*>).instance.id, + message = "Close must target the handle's own instance", + ) + + val secondOp = handle.operations[1] + assertTrue(secondOp is NavigationOperation.Open<*>, "Second op must be Open") + val openedInstance = (secondOp as NavigationOperation.Open<*>).instance + assertEquals( + expected = delegateKey, + actual = openedInstance.key, + message = "Open should target the delegate key", + ) + assertEquals( + expected = resultId, + actual = openedInstance.metadata.get(NavigationResultChannel.ResultIdKey), + message = "Delegate's ResultIdKey should be the origin's so the delegate's eventual completion routes back to the origin's channel", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/SceneHarnessSmokeTest.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/SceneHarnessSmokeTest.kt new file mode 100644 index 000000000..65da7da74 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/SceneHarnessSmokeTest.kt @@ -0,0 +1,28 @@ +package dev.enro + +import androidx.compose.material3.Text +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test + +/** + * Smoke test for the Compose multiplatform test harness. + * + * Establishes that `runComposeUiTest` works from `:enro-runtime`'s + * commonTest on the desktop target. Once this is green, scene-strategy / + * scene-decorator / overlay tests can layer on top, replacing what would + * otherwise need to live as instrumented robot tests. + */ +@OptIn(ExperimentalTestApi::class) +class SceneHarnessSmokeTest { + + @Test + fun `runComposeUiTest renders a simple composable`() = runComposeUiTest { + setContent { + Text("hello harness") + } + onNodeWithText("hello harness").assertIsDisplayed() + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/SceneIntegrationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/SceneIntegrationTests.kt new file mode 100644 index 000000000..67b5087e5 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/SceneIntegrationTests.kt @@ -0,0 +1,607 @@ +package dev.enro + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import dev.enro.controller.createNavigationModule +import dev.enro.test.EnroTest +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.ui.LocalNavigationAnimatedVisibilityScopeOrNull +import dev.enro.ui.LocalNavigationContainer +import dev.enro.ui.LocalNavigationContext +import dev.enro.ui.LocalNavigationSharedTransitionScopeOrNull +import dev.enro.ui.NavigationContainerState +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationDisplay +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy +import dev.enro.ui.SceneDecoratorStrategy +import dev.enro.ui.SceneStrategyScope +import dev.enro.ui.navigationDestination +import dev.enro.ui.rememberNavigationContainer +import dev.enro.ui.scenes.SinglePaneSceneStrategy +import dev.enro.ui.scenes.directOverlay +import dev.enro.ui.scenes.isDirectOverlay +import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Compose-driven integration tests exercising the scene layer. Each test + * installs an [EnroController], registers bindings for the test keys it + * uses, and renders a [NavigationDisplay] inside [runComposeUiTest] — + * which gives us fast, multiplatform-friendly tests against the real + * runtime without needing the instrumented robot harness. + */ +@OptIn(ExperimentalTestApi::class) +class SceneIntegrationTests { + + @Test + fun `NavigationDisplay renders the current destination's content`() = runEnroComposeTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { + Text("test destination rendered") + } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf(TestSceneKey.asInstance()), + ) + NavigationDisplay(state = container) + } + } + + onNodeWithText("test destination rendered").assertIsDisplayed() + } + + @Test + fun `Scene strategy chain falls through to the next strategy when one returns null`() = runEnroComposeTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { + Text("rendered via fall-through strategy") + } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + var alwaysNullCallCount = 0 + val alwaysNullStrategy = object : NavigationSceneStrategy { + @Composable + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): NavigationScene? { + alwaysNullCallCount++ + return null + } + } + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf(TestSceneKey.asInstance()), + ) + NavigationDisplay( + state = container, + sceneStrategy = NavigationSceneStrategy.from( + alwaysNullStrategy, + SinglePaneSceneStrategy(), + ), + ) + } + } + + assertTrue( + actual = alwaysNullCallCount > 0, + message = "The earlier strategy must be consulted before the chain falls through", + ) + onNodeWithText("rendered via fall-through strategy").assertIsDisplayed() + } + + @Test + fun `Nested overlay scenes render content from every layer`() = runEnroComposeTest { + // Backstack: [A, OverlayB, OverlayC]. The runtime should resolve this + // into three composed scenes — SinglePane(A) underneath, OverlayB above + // it, OverlayC on top — with content from each layer visible at once + // because overlays don't replace the underlying scene chain. + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("underlying A") } + ) + destination( + navigationDestination( + metadata = { directOverlay() }, + ) { Text("overlay B") } + ) + destination( + navigationDestination( + metadata = { directOverlay() }, + ) { Text("overlay C") } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf( + TestSceneKey.asInstance(), + OverlaySceneKey.asInstance(), + SecondOverlaySceneKey.asInstance(), + ), + ) + NavigationDisplay(state = container) + } + } + + onNodeWithText("underlying A").assertIsDisplayed() + onNodeWithText("overlay B").assertIsDisplayed() + onNodeWithText("overlay C").assertIsDisplayed() + } + + @Test + fun `Overlay scenes resolve their underlying scene chain via previousEntries`() = runEnroComposeTest { + // When an overlay is on top of the backstack, NavigationDisplay must + // still resolve and render the scene corresponding to + // overlay.previousEntries underneath. The decorator (which skips + // overlays) is a convenient window into what scene was resolved for + // that underlying chain — we can assert its entries contain exactly + // the underlying destination. + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("underlying entry") } + ) + destination( + navigationDestination( + metadata = { directOverlay() }, + ) { Text("overlay on top") } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + val underlyingInstance = TestSceneKey.asInstance() + val overlayInstance = OverlaySceneKey.asInstance() + + val decoratedScenes = mutableListOf() + val recordingDecorator = SceneDecoratorStrategy { scene -> + decoratedScenes += scene + scene + } + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf(underlyingInstance, overlayInstance), + ) + NavigationDisplay( + state = container, + sceneDecoratorStrategies = listOf(recordingDecorator), + ) + } + } + + onNodeWithText("overlay on top").assertIsDisplayed() + + val nonOverlayScenes = decoratedScenes.filter { it !is NavigationScene.Overlay } + assertTrue( + actual = nonOverlayScenes.isNotEmpty(), + message = "An underlying scene should have been resolved and passed through the decorator", + ) + val underlyingEntryIds = nonOverlayScenes.flatMap { it.entries.map { entry -> entry.id } } + assertTrue( + actual = underlyingEntryIds.contains(underlyingInstance.id), + message = "Underlying scene's entries should include the underlying instance " + + "(${underlyingInstance.id}); saw entry ids: $underlyingEntryIds", + ) + assertFalse( + actual = underlyingEntryIds.contains(overlayInstance.id), + message = "Underlying scene must not include the overlay's own instance; " + + "saw entry ids: $underlyingEntryIds", + ) + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Test + fun `AnimatedVisibility and SharedTransition scopes propagate into decorated scene content`() = runEnroComposeTest { + // SceneDecoratorStrategy.decorateScene itself runs before the + // AnimatedContent / SharedTransitionLayout that NavigationDisplay + // sets up — but the SCENE'S CONTENT lambda returned from a decorator + // runs inside both, so reading LocalNavigationAnimatedVisibilityScope + // / LocalNavigationSharedTransitionScope from there should yield the + // real scopes (with their transition state etc.). This test locks + // that contract down. + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("animated scene content") } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + var capturedAnimatedScope: AnimatedVisibilityScope? = null + var capturedSharedScope: SharedTransitionScope? = null + val capturingDecorator = SceneDecoratorStrategy { scene -> + object : NavigationScene by scene { + override val content: @Composable () -> Unit = { + capturedAnimatedScope = LocalNavigationAnimatedVisibilityScopeOrNull.current + capturedSharedScope = LocalNavigationSharedTransitionScopeOrNull.current + scene.content() + } + } + } + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf(TestSceneKey.asInstance()), + ) + NavigationDisplay( + state = container, + sceneDecoratorStrategies = listOf(capturingDecorator), + ) + } + } + + onNodeWithText("animated scene content").assertIsDisplayed() + waitForIdle() + + val animatedScope = assertNotNull( + capturedAnimatedScope, + "LocalNavigationAnimatedVisibilityScope must be non-null inside decorated scene content", + ) + assertNotNull( + capturedSharedScope, + "LocalNavigationSharedTransitionScope must be non-null inside decorated scene content", + ) + assertEquals( + expected = EnterExitState.Visible, + actual = animatedScope.transition.targetState, + message = "After idle, the captured scope's transition target should be Visible", + ) + } + + @Test + fun `Overlay onRemove suspends until completed before the scene fully leaves composition`() = runEnroComposeTest { + // NavigationDisplay invokes scene.onRemove() in a LaunchedEffect once + // the exit transition settles, and only calls onFullyHidden() (which + // drops the scene from its tracking map) AFTER onRemove returns. So + // an onRemove that suspends should keep the scene "alive" — present + // in the rendered set — until it resolves. + // + // We can't observe NavigationDisplay's internal rendered map directly, + // but we capture onRemove start / end events through the suspending + // hook itself and assert the suspension contract that way. + val onRemoveSignal = CompletableDeferred() + val events = mutableListOf() + + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("underlying") } + ) + destination( + navigationDestination( + metadata = { directOverlay() }, + ) { Text("controlled overlay") } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val underlyingInstance = TestSceneKey.asInstance() + val overlayInstance = OverlaySceneKey.asInstance() + var capturedContainer: NavigationContainerState? = null + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf(underlyingInstance, overlayInstance), + ) + capturedContainer = container + NavigationDisplay( + state = container, + sceneStrategy = NavigationSceneStrategy.from( + TestOverlayStrategy(onRemoveSignal, events), + SinglePaneSceneStrategy(), + ), + ) + } + } + + onNodeWithText("controlled overlay").assertIsDisplayed() + assertEquals( + expected = emptyList(), + actual = events.toList(), + message = "onRemove must not fire while the overlay is on the backstack", + ) + + capturedContainer!!.updateBackstack { it.dropLast(1).asBackstack() } + waitForIdle() + + assertEquals( + expected = listOf("onRemove-start"), + actual = events.toList(), + message = "After popping the overlay, onRemove should have started exactly once and be suspended on the signal", + ) + + onRemoveSignal.complete(Unit) + waitForIdle() + + assertEquals( + expected = listOf("onRemove-start", "onRemove-end"), + actual = events.toList(), + message = "Completing the signal should resume the suspended onRemove through to its end", + ) + } + + @Test + fun `requestClose routes through onCloseRequested callback when one is registered in an overlay destination`() = runEnroComposeTest { + // Integration counterpart to NavigationHandleConfigurationTests: + // verifies that the requestClose() path running through a real + // DestinationNavigationHandle (created by NavigationDisplay for the + // rendered overlay) consults a callback registered via + // navigation.configure { onCloseRequested { ... } } AND that a + // callback which doesn't itself call close() actually prevents the + // overlay from being dismissed. This is the key user-visible + // behaviour of onCloseRequested -- it lets a destination veto its + // own close so it can prompt for confirmation, etc. + var callbackInvocations = 0 + + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("underlying") } + ) + destination( + navigationDestination( + metadata = { directOverlay() }, + ) { + navigation.configure { + onCloseRequested { + callbackInvocations++ + // Intentionally do NOT call close() — registering this + // callback should suppress the default close behaviour. + } + } + Button(onClick = { navigation.requestClose() }) { + Text("dismiss overlay") + } + } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf( + TestSceneKey.asInstance(), + OverlaySceneKey.asInstance(), + ), + ) + NavigationDisplay(state = container) + } + } + + onNodeWithText("dismiss overlay").assertIsDisplayed() + assertEquals(0, callbackInvocations) + + onNodeWithText("dismiss overlay").performClick() + waitForIdle() + + assertEquals( + expected = 1, + actual = callbackInvocations, + message = "onCloseRequested callback should have fired exactly once after requestClose", + ) + onNodeWithText("dismiss overlay").assertIsDisplayed() + } + + @Test + fun `LocalNavigationContainer and LocalNavigationContext are readable inside decorateScene`() = runEnroComposeTest { + // A scene decorator may need to consult the live container (e.g. read + // the backstack to decide what wrapper to produce) without deferring + // that work into the returned scene's content lambda. The locals must + // therefore be in scope during decorateScene composition, not only + // during scene.content(). + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("decorated content") } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + var capturedContainer: NavigationContainerState? = null + var capturedContext: dev.enro.NavigationContext? = null + val capturingDecorator = SceneDecoratorStrategy { scene -> + // Reading LocalNavigationContainer.current here throws on the + // pre-fix runtime — the local was only provided around scene + // rendering, not around decorateScene composition. + capturedContainer = LocalNavigationContainer.current + capturedContext = LocalNavigationContext.current + scene + } + + var expectedContainer: NavigationContainerState? = null + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf(TestSceneKey.asInstance()), + ) + expectedContainer = container + NavigationDisplay( + state = container, + sceneDecoratorStrategies = listOf(capturingDecorator), + ) + } + } + + onNodeWithText("decorated content").assertIsDisplayed() + waitForIdle() + + val seenContainer = assertNotNull( + capturedContainer, + "LocalNavigationContainer.current must resolve inside decorateScene", + ) + assertEquals( + expected = expectedContainer, + actual = seenContainer, + message = "Decorator should see the same NavigationContainerState that NavigationDisplay was given", + ) + assertEquals( + expected = expectedContainer!!.context, + actual = capturedContext, + message = "Decorator should see the container's own context via LocalNavigationContext, not a fallback root context", + ) + } + + @Test + fun `Scene decorators are NOT applied to Overlay scenes`() = runEnroComposeTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { + Text("underlying scene") + } + ) + destination( + navigationDestination( + metadata = { directOverlay() }, + ) { + Text("overlay scene") + } + ) + } + ) + val rootContext = NavigationContextFixtures.createRootContext() + + val decoratedScenes = mutableListOf() + val recordingDecorator = SceneDecoratorStrategy { scene -> + decoratedScenes += scene + scene + } + + setContent { + CompositionLocalProvider(LocalNavigationContext provides rootContext) { + val container = rememberNavigationContainer( + backstack = backstackOf( + TestSceneKey.asInstance(), + OverlaySceneKey.asInstance(), + ), + ) + NavigationDisplay( + state = container, + sceneDecoratorStrategies = listOf(recordingDecorator), + ) + } + } + + onNodeWithText("overlay scene").assertIsDisplayed() + assertTrue( + actual = decoratedScenes.isNotEmpty(), + message = "The decorator should be invoked at least once for the underlying scene", + ) + assertFalse( + actual = decoratedScenes.any { it is NavigationScene.Overlay }, + message = "Decorators must not be applied to NavigationScene.Overlay; decorated: $decoratedScenes", + ) + } +} + +@Serializable +data object TestSceneKey : NavigationKey + +@Serializable +data object OverlaySceneKey : NavigationKey + +@Serializable +data object SecondOverlaySceneKey : NavigationKey + +/** + * A scene strategy that wraps any `directOverlay()` destination in a + * [TestOverlayScene] with a controllable [onRemove] — used by the + * overlay-onRemove-suspension test. + */ +private class TestOverlayStrategy( + private val onRemoveSignal: CompletableDeferred, + private val events: MutableList, +) : NavigationSceneStrategy { + @Composable + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): NavigationScene? { + val top = entries.lastOrNull() ?: return null + if (!top.isDirectOverlay()) return null + return TestOverlayScene( + key = top.instance.id, + entry = top, + overlaidEntries = entries.dropLast(1), + onRemoveSignal = onRemoveSignal, + events = events, + ) + } +} + +private class TestOverlayScene( + override val key: Any, + val entry: NavigationDestination, + override val overlaidEntries: List>, + private val onRemoveSignal: CompletableDeferred, + private val events: MutableList, +) : NavigationScene.Overlay { + override val entries: List> = listOf(entry) + override val previousEntries: List> = overlaidEntries + override val content: @Composable () -> Unit = { entry.Content() } + override suspend fun onRemove() { + events += "onRemove-start" + onRemoveSignal.await() + events += "onRemove-end" + } +} + +/** + * Wrap [runComposeUiTest] with the same install/uninstall lifecycle that + * [dev.enro.test.runEnroTest] provides for non-Compose tests, so callers + * can use the `ComposeUiTest` scope (setContent, onNode*, etc.) directly + * with an installed [EnroController] available. + */ +@OptIn(ExperimentalTestApi::class) +internal fun runEnroComposeTest(block: ComposeUiTest.() -> Unit) = runComposeUiTest { + EnroTest.installNavigationController() + try { + block() + } finally { + EnroTest.uninstallNavigationController() + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/SetBackstackDiffTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/SetBackstackDiffTests.kt new file mode 100644 index 000000000..79ab9c68e --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/SetBackstackDiffTests.kt @@ -0,0 +1,175 @@ +package dev.enro + +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests that lock down [NavigationOperation.SetBackstack] diff semantics. + * + * SetBackstack desugars to an `AggregateOperation` of `Open` + `Close` + * operations based on a [NavigationTransition] diff. The key contract + * the runtime depends on -- and that downstream destination tracking + * relies on for state preservation -- is that retained instances pass + * through `processOperations` as references to the original objects + * (not copies, not new instances). Each test below asserts via + * reference equality (`assertSame`) that retained backstack entries + * survive a SetBackstack unchanged. + */ +class SetBackstackDiffTests { + + @Test + fun `NavigationTransition computes opened closed and retained sets correctly`() { + val a = NavigationKeyFixtures.SimpleKey().asInstance() + val b = NavigationKeyFixtures.SimpleKey().asInstance() + val c = NavigationKeyFixtures.SimpleKey().asInstance() + val d = NavigationKeyFixtures.SimpleKey().asInstance() + + val transition = NavigationTransition( + currentBackstack = backstackOf(a, b, c), + targetBackstack = backstackOf(b, c, d), + ) + + assertEquals(listOf(d), transition.opened, "opened should be instances in target but not current") + assertEquals(listOf(a), transition.closed, "closed should be instances in current but not target") + assertEquals(setOf(b, c), transition.retained, "retained should be the intersection") + } + + @Test + fun `SetBackstack reorder preserves the same instance references`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val a = NavigationKeyFixtures.SimpleKey().asInstance() + val b = NavigationKeyFixtures.SimpleKey().asInstance() + val c = NavigationKeyFixtures.SimpleKey().asInstance() + + val initial = backstackOf(a, b, c) + container.setBackstackDirect(initial) + + container.execute( + destinationContext, + NavigationOperation.SetBackstack( + currentBackstack = initial, + targetBackstack = backstackOf(c, b, a), + ), + ) + + assertEquals(3, container.backstack.size) + assertSame(c, container.backstack[0], "Reordered instance at position 0 should be the original c reference") + assertSame(b, container.backstack[1], "Reordered instance at position 1 should be the original b reference") + assertSame(a, container.backstack[2], "Reordered instance at position 2 should be the original a reference") + } + + @Test + fun `SetBackstack adding entries preserves existing instances and appends new ones`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val a = NavigationKeyFixtures.SimpleKey().asInstance() + val newlyOpened = NavigationKeyFixtures.SimpleKey().asInstance() + + val initial = backstackOf(a) + container.setBackstackDirect(initial) + + container.execute( + destinationContext, + NavigationOperation.SetBackstack( + currentBackstack = initial, + targetBackstack = backstackOf(a, newlyOpened), + ), + ) + + assertEquals(2, container.backstack.size) + assertSame(a, container.backstack[0], "Retained instance must keep its reference identity across the diff") + assertSame(newlyOpened, container.backstack[1], "Newly opened instance should be appended at the end") + } + + @Test + fun `SetBackstack removing entries preserves the remaining instances and drops the removed`() = runEnroTest { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val a = NavigationKeyFixtures.SimpleKey().asInstance() + val b = NavigationKeyFixtures.SimpleKey().asInstance() + val c = NavigationKeyFixtures.SimpleKey().asInstance() + + val initial = backstackOf(a, b, c) + container.setBackstackDirect(initial) + + container.execute( + destinationContext, + NavigationOperation.SetBackstack( + currentBackstack = initial, + targetBackstack = backstackOf(a, c), + ), + ) + + assertEquals(2, container.backstack.size) + assertSame(a, container.backstack[0], "Retained instance a must keep its reference identity") + assertSame(c, container.backstack[1], "Retained instance c must keep its reference identity") + assertFalse( + actual = container.backstack.any { it === b }, + message = "Removed instance b must no longer appear in the backstack", + ) + } + + @Test + fun `SetBackstack replacing the top entry retains the underlying entries and drops only the replaced one`() = runEnroTest { + // The common list-detail "swap detail" gesture: [list, oldDetail] + // becomes [list, newDetail]. The list entry must keep its reference + // identity so its ViewModel / scope is preserved; the old detail + // is dropped and the new detail appears in place. + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val list = NavigationKeyFixtures.SimpleKey().asInstance() + val oldDetail = NavigationKeyFixtures.SimpleKey().asInstance() + val newDetail = NavigationKeyFixtures.SimpleKey().asInstance() + + val initial = backstackOf(list, oldDetail) + container.setBackstackDirect(initial) + + container.execute( + destinationContext, + NavigationOperation.SetBackstack( + currentBackstack = initial, + targetBackstack = backstackOf(list, newDetail), + ), + ) + + assertEquals(2, container.backstack.size) + assertSame(list, container.backstack[0], "List instance must retain its reference across the swap") + assertSame(newDetail, container.backstack[1]) + assertTrue( + actual = container.backstack.none { it === oldDetail }, + message = "Old detail instance must be gone after the swap", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/SyntheticDestinationTesterTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/SyntheticDestinationTesterTests.kt new file mode 100644 index 000000000..a5d1072b7 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/SyntheticDestinationTesterTests.kt @@ -0,0 +1,235 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro + +import androidx.compose.material3.Text +import dev.enro.controller.createNavigationModule +import dev.enro.test.EnroTest +import dev.enro.test.assertCloses +import dev.enro.test.assertCompletes +import dev.enro.test.assertCompletesFrom +import dev.enro.test.assertOpens +import dev.enro.test.assertSideEffect +import dev.enro.test.runEnroTest +import dev.enro.test.runWith +import dev.enro.test.testSyntheticDestination +import dev.enro.ui.destinations.complete +import dev.enro.ui.destinations.completeFrom +import dev.enro.ui.destinations.syntheticDestination +import dev.enro.ui.navigationDestination +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Tests for the `testSyntheticDestination` helpers in enro-test. Covers each + * outcome path through both entry points: looking up the synthetic via the + * controller's bindings (the "registered" path), and providing the + * NavigationDestinationProvider directly (the "direct" path that doesn't + * need a controller install). + */ +class SyntheticDestinationTesterTests { + + // ---- Registered-path tests ---- + + @Test + fun `testSyntheticDestination by key finds the synthetic via controller bindings`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { open(SyntheticTesterTargetKey()) } + ) + } + ) + + val outcome = testSyntheticDestination(SyntheticTesterOpenKey) + + outcome.assertOpens() + } + + @Test + fun `Registered close outcome is reported as Close`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { close() } + ) + } + ) + + testSyntheticDestination(SyntheticTesterOpenKey).assertCloses(silent = false) + } + + @Test + fun `Registered closeSilently outcome is reported as Close with silent=true`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { closeSilently() } + ) + } + ) + + testSyntheticDestination(SyntheticTesterOpenKey).assertCloses(silent = true) + } + + @Test + fun `Registered complete with result is reported as Complete with the payload`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { complete("hello") } + ) + } + ) + + testSyntheticDestination(SyntheticTesterResultKey()).assertCompletes(expectedResult = "hello") + } + + @Test + fun `Registered completeFrom is reported as CompleteFrom of the forwarded key`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("forwarded") } + ) + destination( + syntheticDestination { completeFrom(SyntheticTesterResultKey()) } + ) + } + ) + + testSyntheticDestination(SyntheticTesterForwarderKey()) + .assertCompletesFrom() + } + + @Test + fun `Registered fall-through is reported as a silent close`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { + // no outcome method + } + ) + } + ) + + testSyntheticDestination(SyntheticTesterOpenKey).assertCloses(silent = true) + } + + @Test + fun `testSyntheticDestination by key throws a clear error when key isn't bound`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule(createNavigationModule {}) + + val error = assertFailsWith { + testSyntheticDestination(SyntheticTesterOpenKey) + } + assertTrue( + actual = error.message?.contains("not bound to a synthetic destination") == true, + message = "Error should explain the synthetic isn't bound; was: ${error.message}", + ) + } + + @Test + fun `testSyntheticDestination by key throws when the bound destination is not a synthetic`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("ordinary destination") } + ) + } + ) + + val error = assertFailsWith { + testSyntheticDestination(SyntheticTesterOpenKey) + } + assertTrue( + actual = error.message?.contains("not bound to a synthetic destination") == true, + message = "Error should explain that the binding isn't a synthetic; was: ${error.message}", + ) + } + + // ---- Direct-path (no controller install) tests ---- + + @Test + fun `testSyntheticDestination by key and provider works without installing the synthetic on the controller`() = runEnroTest { + // No addModule call — the synthetic provider is passed directly. + val provider = syntheticDestination { open(SyntheticTesterTargetKey()) } + + val outcome = testSyntheticDestination(SyntheticTesterOpenKey, provider) + + outcome.assertOpens() + } + + @Test + fun `Direct-path supports each outcome shape`() = runEnroTest { + val openProvider = syntheticDestination { open(SyntheticTesterTargetKey()) } + val closeProvider = syntheticDestination { close() } + val completeProvider = syntheticDestination { complete("ok") } + val sideEffectProvider = syntheticDestination { + sideEffect { /* no-op */ } + } + + testSyntheticDestination(SyntheticTesterOpenKey, openProvider).assertOpens() + testSyntheticDestination(SyntheticTesterOpenKey, closeProvider).assertCloses(silent = false) + testSyntheticDestination(SyntheticTesterResultKey(), completeProvider).assertCompletes("ok") + testSyntheticDestination(SyntheticTesterOpenKey, sideEffectProvider).assertSideEffect() + } + + @Test + fun `Direct-path throws when the provider isn't a synthetic`() = runEnroTest { + val notSynthetic = navigationDestination { Text("ordinary") } + + val error = assertFailsWith { + testSyntheticDestination(SyntheticTesterOpenKey, notSynthetic) + } + assertTrue( + actual = error.message?.contains("not a synthetic destination") == true, + message = "Error should explain the provider isn't synthetic; was: ${error.message}", + ) + } + + // ---- Side-effect tests ---- + + @Test + fun `SideEffect runWith default fixtures executes the block`() = runEnroTest { + var ran = false + val provider = syntheticDestination { + sideEffect { ran = true } + } + + val outcome = testSyntheticDestination(SyntheticTesterOpenKey, provider) + + outcome.assertSideEffect().runWith() + assertTrue(ran, "Side-effect block should have run") + } + + @Test + fun `SideEffect block sees the instance and key from the synthetic`() = runEnroTest { + var capturedKey: NavigationKey? = null + val provider = syntheticDestination { + sideEffect { capturedKey = key } + } + + testSyntheticDestination(SyntheticTesterOpenKey, provider) + .assertSideEffect() + .runWith() + + assertEquals(SyntheticTesterOpenKey, capturedKey) + } +} + +@Serializable +data object SyntheticTesterOpenKey : NavigationKey + +@Serializable +data class SyntheticTesterTargetKey(val id: String = "target") : NavigationKey + +@Serializable +class SyntheticTesterResultKey : NavigationKey.WithResult + +@Serializable +class SyntheticTesterForwarderKey : NavigationKey.WithResult diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/SyntheticDestinationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/SyntheticDestinationTests.kt new file mode 100644 index 000000000..8e49f3d23 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/SyntheticDestinationTests.kt @@ -0,0 +1,560 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro + +import androidx.compose.material3.Text +import dev.enro.controller.createNavigationModule +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import dev.enro.test.EnroTest +import dev.enro.test.NavigationKeyFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.runEnroTest +import dev.enro.ui.destinations.SyntheticDestination +import dev.enro.ui.destinations.complete +import dev.enro.ui.destinations.isSyntheticDestination +import dev.enro.ui.destinations.syntheticDestination +import dev.enro.ui.navigationDestination +import kotlinx.serialization.Serializable +import kotlin.test.* + +/** + * Coverage for [SyntheticDestination] and its registered interceptor. + * The synthetic destination mechanism is the third built-in + * controller interceptor (alongside RootDestinationInterceptor and + * PreviouslyActiveContainerInterceptor) and had zero unit coverage. + * + * A synthetic destination is "a NavigationKey that, when opened, runs a + * block of code instead of rendering anything" -- the interceptor + * recognises the open, replaces it with a SideEffect that invokes the + * synthetic block, and the destination never reaches the backstack. + * It's the redirect / fire-and-forget primitive in Enro. + * + * The block can fall through (pure side-effect bridge) or short-circuit by + * calling one of the outcome methods on the scope (`open`, `close`, + * `complete`, `completeFrom`). Outcome methods throw a sentinel that the + * dispatcher catches and converts to a [NavigationOperation]. + */ +class SyntheticDestinationTests { + + @AfterTest + fun clearPendingResults() { + NavigationResultChannel.pendingResults.value = emptyMap() + } + + @Test + fun `isSyntheticDestination returns true for instances bound to a synthetic destination`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { /* no-op */ } + ) + } + ) + val instance = SyntheticTestKey.asInstance() + + assertTrue( + actual = isSyntheticDestination(instance), + message = "Instance bound to a syntheticDestination provider should be reported as synthetic", + ) + } + + @Test + fun `isSyntheticDestination returns false for regular destinations`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("regular") } + ) + } + ) + val instance = RegularSyntheticTestKey.asInstance() + + assertFalse( + actual = isSyntheticDestination(instance), + message = "Regular destinations must not be detected as synthetic", + ) + } + + @Test + fun `Opening a synthetic destination runs its block with the right scope and does not add to the backstack`() = runEnroTest { + // In production, the SyntheticDestination.interceptor is installed + // via defaultNavigationModule on EnroController. commonTest uses a + // bare controller, so we attach the same interceptor manually to + // exercise the full path: Open(synthetic) -> interceptor returns + // SideEffect -> SideEffect calls executeSynthetic -> the + // syntheticDestination block runs with a scope carrying the + // originating fromContext and the original instance. + var blockExecutions = 0 + var capturedContext: NavigationContext? = null + var capturedInstance: NavigationKey.Instance<*>? = null + + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { + blockExecutions++ + capturedContext = context + capturedInstance = instance + } + ) + } + ) + + val container = openSynthetic(SyntheticTestKey.asInstance()) + + assertEquals( + expected = 1, + actual = blockExecutions, + message = "Synthetic block should have been invoked exactly once for the Open", + ) + assertSame( + expected = container.destinationContext, + actual = capturedContext, + message = "Synthetic block's scope.context should be the originating fromContext", + ) + assertEquals( + expected = 0, + actual = container.container.backstack.size, + message = "Synthetic destinations must never reach the container backstack", + ) + assertEquals( + expected = SyntheticTestKey.asInstance().key, + actual = capturedInstance?.key, + message = "Synthetic block's scope.instance should carry the original key", + ) + } + + @Test + fun `Synthetic falling through with no outcome leaves the backstack untouched and registers no result`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { + // pure side-effect bridge — no outcome method called + } + ) + } + ) + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "ch") + val syntheticInstance = SyntheticTestKey.asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + val container = openSynthetic(syntheticInstance) + + assertEquals( + expected = 0, + actual = container.container.backstack.size, + message = "Backstack must remain empty when synthetic falls through with no outcome", + ) + assertNull( + actual = NavigationResultChannel.pendingResults.value[resultId], + message = "Falling through should not register any result for the synthetic's channel", + ) + } + + @Test + fun `Synthetic open otherKey opens that key on the originating container`() = runEnroTest { + val targetKey = NavigationKeyFixtures.SimpleKey() + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { open(targetKey) } + ) + destination( + navigationDestination { Text("target") } + ) + } + ) + + val container = openSynthetic(SyntheticTestKey.asInstance()) + + val keys = container.container.backstack.map { it.key } + assertEquals( + expected = listOf(targetKey), + actual = keys, + message = "Synthetic open(target) should have produced an Open(target) on the originating container; got: $keys", + ) + } + + @Test + fun `Synthetic close registers a Closed result for the synthetic's instance`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { close() } + ) + } + ) + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "ch") + val syntheticInstance = SyntheticTestKey.asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + openSynthetic(syntheticInstance) + + val pending = NavigationResultChannel.pendingResults.value[resultId] + assertTrue( + actual = pending is NavigationResult.Closed, + message = "Synthetic close() should register a Closed result; got: ${pending?.let { it::class.simpleName }}", + ) + } + + @Test + fun `Synthetic complete with result registers a Completed result with that value`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { complete("from synthetic") } + ) + } + ) + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "ch") + val syntheticInstance = ResultBearingSyntheticTestKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + openSynthetic(syntheticInstance) + + val pending = NavigationResultChannel.pendingResults.value[resultId] + assertTrue( + actual = pending is NavigationResult.Completed<*>, + message = "Synthetic complete(result) should register a Completed result", + ) + assertEquals( + expected = "from synthetic", + actual = (pending as NavigationResult.Completed<*>).data, + message = "Completed result's payload should match what the synthetic passed to complete()", + ) + } + + @Test + fun `Synthetic closeSilently does not register a result for the synthetic's channel`() = runEnroTest { + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { closeSilently() } + ) + } + ) + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "ch") + val syntheticInstance = SyntheticTestKey.asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + openSynthetic(syntheticInstance) + + assertNull( + actual = NavigationResultChannel.pendingResults.value[resultId], + message = "closeSilently() must not publish a result; the original caller's onClosed should not fire", + ) + } + + @Test + fun `Synthetic close does not strip the parent destination from the backstack`() = runEnroTest { + // Belt-and-braces test: the synthetic's Close operation targets the + // synthetic's instance (which is never in any backstack), so the + // parent destination — the one that opened the synthetic — must + // remain on the backstack untouched. + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { close() } + ) + destination( + navigationDestination { Text("parent") } + ) + } + ) + + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + container.addInterceptor(SyntheticDestination.interceptor) + + val parentKey = NavigationKeyFixtures.SimpleKey() + val parentInstance = parentKey.asInstance() + container.setBackstackDirect(backstackOf(parentInstance)) + + val parentDestination = NavigationDestinationFixtures.create(parentKey) + val parentContext = NavigationContextFixtures.createDestinationContext(containerContext, parentDestination) + + container.execute(parentContext, NavigationOperation.Open(SyntheticTestKey.asInstance())) + + assertEquals( + expected = listOf(parentInstance.id), + actual = container.backstack.map { it.id }, + message = "Synthetic close must not affect the backstack of whichever destination opened it", + ) + } + + @Test + fun `Calling an outcome method after the synthetic finished throws already-finished`() = runEnroTest { + // Simulates the "block launched a coroutine that outlived the + // block" case. We can't easily await a real coroutine in a test, + // so we capture the scope and invoke an outcome method after the + // dispatcher has moved on — same shape, same failure mode. + var capturedScope: dev.enro.ui.destinations.SyntheticDestinationScope? = null + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { + capturedScope = this + // Falling through — dispatcher will mark this as silent close. + } + ) + } + ) + + openSynthetic(SyntheticTestKey.asInstance()) + + val scope = requireNotNull(capturedScope) { "scope should have been captured by the block" } + val error = kotlin.runCatching { scope.close() }.exceptionOrNull() + assertTrue( + actual = error is IllegalStateException, + message = "Late outcome call after fall-through should throw IllegalStateException; was: ${error?.let { it::class.simpleName }}", + ) + assertTrue( + actual = error?.message?.contains("already finished") == true, + message = "Error message should mention the synthetic already finished; was: ${error?.message}", + ) + } + + @Test + fun `Pure synthetic outcomes are rewritten in place and preserve initial-backstack ordering`() = runEnroTest { + // A synthetic that opens TargetKey, placed as the FIRST entry in an + // AggregateOperation alongside RegularKey, should land as + // [TargetKey, RegularKey]. The synthetic's outcome must replace + // the synthetic's Open in the same processing pass — not be + // appended at the end via a separate execute call. + val targetKey = NavigationKeyFixtures.SimpleKey() + val regularKey = NavigationKeyFixtures.SimpleKey() + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { open(targetKey) } + ) + destination( + navigationDestination { Text("regular") } + ) + } + ) + + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + container.addInterceptor(SyntheticDestination.interceptor) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + container.execute( + destinationContext, + NavigationOperation.AggregateOperation( + NavigationOperation.Open(SyntheticTestKey.asInstance()), + NavigationOperation.Open(regularKey.asInstance()), + ), + ) + + assertEquals( + expected = listOf(targetKey, regularKey), + actual = container.backstack.map { it.key }, + message = "Pure synthetic outcomes must be rewritten in place — backstack ordering was: ${container.backstack.map { it.key }}", + ) + } + + @Test + fun `Side-effect synthetic runs after the surrounding pass settles`() = runEnroTest { + // Assert that when the side-effect block runs, the container's + // backstack already reflects the other operations from the same + // processing pass — i.e. the side effect is deferred to + // afterExecution as advertised. + var observedBackstackAtSideEffect: List? = null + val regularKey = NavigationKeyFixtures.SimpleKey() + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { + sideEffect { + observedBackstackAtSideEffect = container.backstack.map { it.key } + } + } + ) + destination( + navigationDestination { Text("regular") } + ) + } + ) + + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + container.addInterceptor(SyntheticDestination.interceptor) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + container.execute( + destinationContext, + NavigationOperation.AggregateOperation( + NavigationOperation.Open(SyntheticTestKey.asInstance()), + NavigationOperation.Open(regularKey.asInstance()), + ), + ) + + assertEquals( + expected = listOf(regularKey), + actual = observedBackstackAtSideEffect, + message = "Side-effect block should run after the surrounding pass settled; observed: $observedBackstackAtSideEffect", + ) + } + + @Test + fun `Side-effect synthetic can rewrite the backstack via container execute`() = runEnroTest { + // Use sideEffect's container reference to perform a SetBackstack — + // the deferred-execution model that the framework prescribes for + // anything that can't be expressed as a pure outcome. + val newRootKey = NavigationKeyFixtures.SimpleKey() + val newTopKey = NavigationKeyFixtures.SimpleKey() + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { + sideEffect { + container.execute( + context, + NavigationOperation.SetBackstack( + currentBackstack = container.backstack, + targetBackstack = backstackOf( + newRootKey.asInstance(), + newTopKey.asInstance(), + ), + ), + ) + } + } + ) + destination( + navigationDestination { Text("any") } + ) + } + ) + + val container = openSynthetic(SyntheticTestKey.asInstance()).container + + assertEquals( + expected = listOf(newRootKey, newTopKey), + actual = container.backstack.map { it.key }, + message = "Side-effect's SetBackstack should have rewritten the backstack; was: ${container.backstack.map { it.key }}", + ) + } + + @Test + fun `Self-referential synthetic outcome trips the recursion guard`() = runEnroTest { + // A synthetic whose pure outcome opens itself would loop forever + // inside processOperations without a guard. We expect a clear + // IllegalStateException naming the offender. + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + syntheticDestination { open(SyntheticTestKey) } + ) + } + ) + + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + container.addInterceptor(SyntheticDestination.interceptor) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + val error = kotlin.runCatching { + container.execute(destinationContext, NavigationOperation.Open(SyntheticTestKey.asInstance())) + }.exceptionOrNull() + + assertTrue( + actual = error is IllegalStateException, + message = "Expected IllegalStateException from the recursion guard; got: ${error?.let { it::class.simpleName }}", + ) + assertTrue( + actual = error?.message?.contains("exceeded") == true, + message = "Recursion guard error should mention exceeding the iteration limit; was: ${error?.message}", + ) + } + + @Test + fun `Synthetic completeFrom forwards result-channel routing to the chosen destination`() = runEnroTest { + val forwarded = ResultBearingSyntheticTestKey() + EnroTest.getCurrentNavigationController().addModule( + createNavigationModule { + destination( + navigationDestination { Text("forwarded") } + ) + destination( + syntheticDestination { completeFrom(forwarded) } + ) + } + ) + val resultId = NavigationResultChannel.Id(ownerId = "owner", resultId = "ch") + val syntheticInstance = ForwardingSyntheticKey().asInstance().apply { + metadata.set(NavigationResultChannel.ResultIdKey, resultId) + } + + val container = openSynthetic(syntheticInstance) + + // The forwarded key should have landed in the container's backstack… + val landed = container.container.backstack.singleOrNull() + assertTrue( + actual = landed != null && landed.key == forwarded, + message = "completeFrom should have opened the forwarded key; backstack was: ${container.container.backstack.map { it.key }}", + ) + // …and the forwarded instance must carry the synthetic's original ResultIdKey, so + // when *it* completes, the original caller's channel gets the result. + assertEquals( + expected = resultId, + actual = landed?.metadata?.get(NavigationResultChannel.ResultIdKey), + message = "completeFrom must copy the synthetic's ResultIdKey onto the forwarded instance so result routing reaches the original caller", + ) + } +} + +private data class OpenedSyntheticContainer( + val container: NavigationContainer, + val destinationContext: NavigationContext, +) + +private fun openSynthetic( + syntheticInstance: NavigationKey.Instance, +): OpenedSyntheticContainer { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val container = containerContext.container + container.setFilter(acceptAll()) + container.addInterceptor(SyntheticDestination.interceptor) + + val sourceDestination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + val destinationContext = NavigationContextFixtures.createDestinationContext(containerContext, sourceDestination) + + container.execute(destinationContext, NavigationOperation.Open(syntheticInstance)) + return OpenedSyntheticContainer(container, destinationContext) +} + +@Serializable +data object SyntheticTestKey : NavigationKey + +@Serializable +data object RegularSyntheticTestKey : NavigationKey + +@Serializable +class ResultBearingSyntheticTestKey : NavigationKey.WithResult + +@Serializable +class ForwardingSyntheticKey : NavigationKey.WithResult diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/context/DestinationContextTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/context/DestinationContextTests.kt new file mode 100644 index 000000000..d417bd75e --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/context/DestinationContextTests.kt @@ -0,0 +1,17 @@ +package dev.enro.context + +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.fixtures.NavigationDestinationFixtures +import dev.enro.test.NavigationKeyFixtures + +class DestinationContextTests { + class DestinationContextCommonTests : NavigationContextWithContainerChildrenCommonTests( + constructContext = { + val rootContext = NavigationContextFixtures.createRootContext() + val containerContext = NavigationContextFixtures.createContainerContext(rootContext) + val destination = NavigationDestinationFixtures.create(NavigationKeyFixtures.SimpleKey()) + + NavigationContextFixtures.createDestinationContext(containerContext, destination) + } + ) +} \ No newline at end of file diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/context/NavigationContextWithContainerChildrenCommonTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/context/NavigationContextWithContainerChildrenCommonTests.kt new file mode 100644 index 000000000..f57849b49 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/context/NavigationContextWithContainerChildrenCommonTests.kt @@ -0,0 +1,236 @@ +package dev.enro.context + +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.test.runEnroTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +abstract class NavigationContextWithContainerChildrenCommonTests( + private val constructContext: () -> NavigationContext.WithContainerChildren<*>, +) { + @Test + fun `when single child is registered it becomes active`() = runEnroTest { + val rootContext = constructContext() + val child = NavigationContextFixtures.createContainerContext(rootContext) + + assertNull(rootContext.activeChild) + + rootContext.registerChild(child) + + assertEquals(child, rootContext.activeChild) + } + + @Test + fun `when single child is registered and becomes visible and then not visible it remains active`() = runEnroTest { + val rootContext = constructContext() + val child = NavigationContextFixtures.createContainerContext(rootContext) + + assertNull(rootContext.activeChild) + rootContext.registerChild(child) + rootContext.registerVisibility(child, true) + rootContext.registerVisibility(child, false) + assertEquals(child, rootContext.activeChild) + } + + + @Test + fun `when child is registered as visible it becomes active`() = runEnroTest { + val rootContext = constructContext() + val child = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child) + rootContext.registerVisibility(child, true) + + assertEquals(child, rootContext.activeChild) + } + + @Test + fun `when active child becomes not visible the first visible child becomes active`() = runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + val child3 = NavigationContextFixtures.createContainerContext(rootContext) + + // Register all children + rootContext.registerChild(child1) + rootContext.registerChild(child2) + rootContext.registerChild(child3) + + // First registered child should be active + assertEquals(child1, rootContext.activeChild) + + // Make child2 and child3 visible + rootContext.registerVisibility(child2, true) + rootContext.registerVisibility(child3, true) + + // When child is registered as visible, if the previously active child was not visible, it + // should become active + assertEquals(child2, rootContext.activeChild) + + // Make child3 active + rootContext.setActiveContainer(child3.id) + assertEquals(child3, rootContext.activeChild) + + // When child3 becomes not visible, child2 should become active + rootContext.registerVisibility(child3, false) + assertEquals(child2, rootContext.activeChild) + + // Even when there is a visible container, setting the active container should still + // respect the request for that container to become active + rootContext.setActiveContainer(child1) + assertEquals(child1, rootContext.activeChild) + } + + @Test + fun `when no children are visible activeChild is last visible child`() = runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child1) + rootContext.registerChild(child2) + + rootContext.registerVisibility(child1, true) + rootContext.registerVisibility(child2, true) + + rootContext.setActiveContainer(child2.id) + assertEquals(child2, rootContext.activeChild) + + // Make all children not visible + rootContext.registerVisibility(child2, false) + rootContext.registerVisibility(child1, false) + + assertEquals(child1, rootContext.activeChild) + } + + @Test + fun `when child is unregistered and was active first visible child becomes active`() = runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + val child3 = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child1) + rootContext.registerChild(child2) + rootContext.registerChild(child3) + + rootContext.registerVisibility(child1, true) + rootContext.registerVisibility(child2, true) + rootContext.registerVisibility(child3, true) + + rootContext.setActiveContainer(child3.id) + assertEquals(child3, rootContext.activeChild) + + // Unregister the active child + rootContext.unregisterChild(child3) + + // First visible child should become active + assertEquals(child1, rootContext.activeChild) + } + + @Test + fun `when currently active child is not visible and new child becomes visible new child becomes active`() = + runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child1) + rootContext.registerChild(child2) + + // child1 is active by default (first registered) + assertEquals(child1, rootContext.activeChild) + + // child1 is not visible by default, so when child2 becomes visible it should become active + rootContext.registerVisibility(child2, true) + assertEquals(child2, rootContext.activeChild) + } + + @Test + fun `setActiveContainer changes active child`() = runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child1) + rootContext.registerChild(child2) + + assertEquals(child1, rootContext.activeChild) + + rootContext.setActiveContainer(child2.id) + assertEquals(child2, rootContext.activeChild) + + rootContext.setActiveContainer(child1.id) + assertEquals(child1, rootContext.activeChild) + } + + @Test + fun `children list contains all registered children regardless of visibility`() = runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + val child3 = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child1) + rootContext.registerChild(child2) + rootContext.registerChild(child3) + + assertEquals(3, rootContext.children.size) + assertEquals(setOf(child1, child2, child3), rootContext.children.toSet()) + + // Change visibility doesn't affect children list + rootContext.registerVisibility(child1, true) + rootContext.registerVisibility(child2, false) + rootContext.registerVisibility(child3, true) + + assertEquals(3, rootContext.children.size) + assertEquals(setOf(child1, child2, child3), rootContext.children.toSet()) + } + + @Test + fun `registerVisibility with unregistered child does nothing`() = runEnroTest { + val rootContext = constructContext() + val child = NavigationContextFixtures.createContainerContext(rootContext) + + // Try to register visibility without registering child first + rootContext.registerVisibility(child, true) + + assertNull(rootContext.activeChild) + assertEquals(0, rootContext.children.size) + } + + @Test + fun `multiple visibility changes maintain correct active child`() = runEnroTest { + val rootContext = constructContext() + val child1 = NavigationContextFixtures.createContainerContext(rootContext) + val child2 = NavigationContextFixtures.createContainerContext(rootContext) + val child3 = NavigationContextFixtures.createContainerContext(rootContext) + + rootContext.registerChild(child1) + rootContext.registerChild(child2) + rootContext.registerChild(child3) + + // Initially child1 is active + assertEquals(child1, rootContext.activeChild) + + // Make child2 visible and active + rootContext.registerVisibility(child2, true) + rootContext.setActiveContainer(child2.id) + assertEquals(child2, rootContext.activeChild) + + // Toggle visibility multiple times + rootContext.registerVisibility(child1, true) + rootContext.registerVisibility(child3, true) + assertEquals(child2, rootContext.activeChild) // Should remain child2 + + rootContext.registerVisibility(child2, false) + assertEquals(child1, rootContext.activeChild) // Should switch to first visible + + rootContext.registerVisibility(child2, true) + assertEquals(child1, rootContext.activeChild) // Should remain child1 + + rootContext.setActiveContainer(child2.id) + assertEquals(child2, rootContext.activeChild) // Explicitly set to child2 + } +} \ No newline at end of file diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/context/RootContextTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/context/RootContextTests.kt new file mode 100644 index 000000000..6008bb22a --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/context/RootContextTests.kt @@ -0,0 +1,11 @@ +package dev.enro.context + +import dev.enro.test.fixtures.NavigationContextFixtures + +class RootContextTests { + class RootContextCommonContainerTests : NavigationContextWithContainerChildrenCommonTests( + constructContext = { + NavigationContextFixtures.createRootContext() + } + ) +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/path/NavigationPathBindingTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/path/NavigationPathBindingTests.kt new file mode 100644 index 000000000..802072591 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/path/NavigationPathBindingTests.kt @@ -0,0 +1,645 @@ +package dev.enro.path + +import dev.enro.NavigationKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NavigationPathBindingTests { + @Test + fun `getPath matching simple single segment path`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test", + deserialize = { TODO() }, + serialize = { TODO() }, + ) + // Simple cases with/without leading/trailing slashes + assertTrue { binding.matches(ParsedPath.fromString("test")) } + assertTrue { binding.matches(ParsedPath.fromString("test/")) } + assertTrue { binding.matches(ParsedPath.fromString("/test")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/")) } + + // Query parameters are optional, so should not affect the match + assertTrue { binding.matches(ParsedPath.fromString("/test/?query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("/test?query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("test?query=123")) } + + // Additional segments should not match + assertFalse { binding.matches(ParsedPath.fromString("/test/123")) } + + // An empty initial segment should not match + assertFalse { binding.matches(ParsedPath.fromString("//test")) } + } + + @Test + fun `getPath matching simple multi segment path`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test/example/next", + deserialize = { TODO() }, + serialize = { TODO() }, + ) + // Simple cases with/without leading/trailing slashes + assertTrue { binding.matches(ParsedPath.fromString("test/example/next")) } + assertTrue { binding.matches(ParsedPath.fromString("test/example/next/")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/example/next")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/example/next/")) } + + // Query parameters are optional, so should not affect the match + assertTrue { binding.matches(ParsedPath.fromString("/test/example/next/?query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/example/next?query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("test/example/next?query=123")) } + + // Additional segments should not match + assertFalse { binding.matches(ParsedPath.fromString("/test/example/next/extra")) } + assertFalse { binding.matches(ParsedPath.fromString("/test/extra/example/next")) } + + // Empty segments should not match + assertFalse { binding.matches(ParsedPath.fromString("//test/example/next")) } + assertFalse { binding.matches(ParsedPath.fromString("/test//example/next")) } + assertFalse { binding.matches(ParsedPath.fromString("/test/example//next")) } + } + + @Test + fun `getPath matching multi segment path with placeholders`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test/{id}/example/{name}", + deserialize = { TODO() }, + serialize = { TODO() }, + ) + + // Simple cases with/without leading/trailing slashes + assertTrue { binding.matches(ParsedPath.fromString("test/123/example/john")) } + assertTrue { binding.matches(ParsedPath.fromString("test/123/example/john/")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john/")) } + + // Query parameters are optional, so should not affect the match + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john/?query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john?query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("test/123/example/john?query=123")) } + + // Additional segments should not match + assertFalse { binding.matches(ParsedPath.fromString("/test/123/example/john/extra")) } + assertFalse { binding.matches(ParsedPath.fromString("/test/extra/123/example/john")) } + + // Empty segments should not match + assertFalse { binding.matches(ParsedPath.fromString("//test/123/example/john")) } + assertFalse { binding.matches(ParsedPath.fromString("/test/123//example/john")) } + } + + @Test + fun `getPath matching multi segment path with optional parameters`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test/{id}/example/{name}?required={required}&optional={optional?}", + deserialize = { TODO() }, + serialize = { TODO() }, + ) + + // Simple cases with/without leading/trailing slashes + assertTrue { binding.matches(ParsedPath.fromString("test/123/example/john?required=123")) } + assertTrue { binding.matches(ParsedPath.fromString("test/123/example/john/?required=123")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john?required=123")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john/?required=123")) } + + // Query parameters are optional, so should not affect the match + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john/?required=123&query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("/test/123/example/john?required=123&query=123")) } + assertTrue { binding.matches(ParsedPath.fromString("test/123/example/john?required=123&query=123")) } + + // Missing required parameters should not match + assertFalse { binding.matches(ParsedPath.fromString("/test/123/example/john/?query=123")) } + assertFalse { binding.matches(ParsedPath.fromString("/test/123/example/john?query=123&optional=123")) } + assertFalse { binding.matches(ParsedPath.fromString("/test/123/example/john?optional=456")) } + } + + @Test + fun `fromPath for root`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "/", + deserialize = { ObjectKey }, + serialize = { TODO() }, + ) + + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("/"))) + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString(""))) + } + + @Test + fun `fromPath for single segment`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test", + deserialize = { ObjectKey }, + serialize = { TODO() }, + ) + + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("/test"))) + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("/test/"))) + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("test"))) + } + + @Test + fun `fromPath for multi segment`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test/example/next", + deserialize = { ObjectKey }, + serialize = { TODO() }, + ) + + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("/test/example/next"))) + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("/test/example/next/"))) + assertEquals(ObjectKey, binding.fromPath(ParsedPath.fromString("test/example/next"))) + } + + @Test + fun `fromPath for multi segment with placeholders`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test/{id}/example/{name}", + deserialize = { + ParameterizedKey( + id = require("id"), + name = require("name"), + ) + }, + serialize = { TODO() }, + ) + + assertEquals( + ParameterizedKey(id = "123", name = "john"), + binding.fromPath(ParsedPath.fromString("/test/123/example/john")), + ) + } + + @Test + fun `fromPath for multi segment with optional parameters`() { + val binding = NavigationPathBinding( + keyType = NavigationKey::class, + pattern = "test/{id}/example/{name}?required={required}&optional={optional?}", + deserialize = { + ParameterizedOptionalKey( + id = require("id"), + name = require("name"), + requiredQuery = require("required"), + optionalQuery = optional("optional"), + ) + }, + serialize = { TODO() }, + ) + + assertEquals( + ParameterizedOptionalKey( + id = "123", + name = "john", + requiredQuery = "456", + optionalQuery = null, + ), + binding.fromPath(ParsedPath.fromString("/test/123/example/john?required=456")), + ) + + assertEquals( + ParameterizedOptionalKey( + id = "123", + name = "john", + requiredQuery = "456", + optionalQuery = "768", + ), + binding.fromPath(ParsedPath.fromString("/test/123/example/john?required=456&optional=768")), + ) + } + + @Test + fun `toPath for root`() { + val binding = NavigationPathBinding( + keyType = ObjectKey::class, + pattern = "/", + deserialize = { TODO() }, + serialize = {}, + ) + + assertEquals("/", binding.toPath(ObjectKey)) + } + + @Test + fun `toPath for single segment`() { + val binding = NavigationPathBinding( + keyType = ObjectKey::class, + pattern = "test", + deserialize = { TODO() }, + serialize = {}, + ) + + assertEquals("/test", binding.toPath(ObjectKey)) + } + + @Test + fun `toPath for multi segment`() { + val binding = NavigationPathBinding( + keyType = ObjectKey::class, + pattern = "test/example/next", + deserialize = { TODO() }, + serialize = {}, + ) + + assertEquals("/test/example/next", binding.toPath(ObjectKey)) + } + + @Test + fun `toPath for multi segment with placeholders`() { + val binding = NavigationPathBinding( + keyType = ParameterizedKey::class, + pattern = "test/{id}/example/{name}", + deserialize = { TODO() }, + serialize = { + set("id", it.id) + set("name", it.name) + }, + ) + + assertEquals("/test/123/example/john", binding.toPath(ParameterizedKey("123", "john"))) + } + + @Test + fun `toPath for multi segment with optional parameters`() { + val binding = NavigationPathBinding( + keyType = ParameterizedOptionalKey::class, + pattern = "test/{id}/example/{name}?required={required}&optional={optional?}", + deserialize = { TODO() }, + serialize = { + set("id", it.id) + set("name", it.name) + set("required", it.requiredQuery) + if (it.optionalQuery != null) { + set("optional", it.optionalQuery) + } + }, + ) + + assertEquals( + "/test/123/example/john?required=456", + binding.toPath( + ParameterizedOptionalKey( + id = "123", + name = "john", + requiredQuery = "456", + optionalQuery = null + ) + ), + ) + + assertEquals( + "/test/123/example/john?required=456&optional=768", + binding.toPath( + ParameterizedOptionalKey( + id = "123", + name = "john", + requiredQuery = "456", + optionalQuery = "768" + ) + ), + ) + } + + @Test + fun `fromPath for multi segment with url encoded characters`() { + val binding = NavigationPathBinding( + keyType = ParameterizedOptionalKey::class, + pattern = "test/{id}/example/{name}?required={required}&optional={optional?}", + deserialize = { + ParameterizedOptionalKey( + id = require("id"), + name = require("name"), + requiredQuery = require("required"), + optionalQuery = optional("optional") + ) + }, + serialize = { TODO() }, + ) + + assertEquals( + ParameterizedOptionalKey( + id = "⛅︎☂︎♠︎ spaces ♛☹︎✎", + name = "😀 / 🤪 - 🤩 ℔ℑ∩∀∁", + requiredQuery = "😇🥰℀ℳ℃", + optionalQuery = "- dashes / slashes {} [%20%asd] " + ), + binding.fromPath( + ParsedPath.fromString( + "/test/%E2%9B%85%EF%B8%8E%E2%98%82%EF%B8%8E%E2%99%A0%EF%B8%8E%20spaces%20%E2%99%9B%E2%98%B9%EF%B8%8E%E2%9C%8E/example/%F0%9F%98%80%20%2F%20%F0%9F%A4%AA%20-%20%F0%9F%A4%A9%20%E2%84%94%E2%84%91%E2%88%A9%E2%88%80%E2%88%81?required=%F0%9F%98%87%F0%9F%A5%B0%E2%84%80%E2%84%B3%E2%84%83&optional=-%20dashes%20%2F%20slashes%20%7B%7D%20%5B%2520%25asd%5D%20" + ) + ) + ) + } + + @Test + fun `toPath for multi segment with optional parameters and url encoded characters`() { + val binding = NavigationPathBinding( + keyType = ParameterizedOptionalKey::class, + pattern = "test/{id}/example/{name}?required={required}&optional={optional?}", + deserialize = { TODO() }, + serialize = { + set("id", it.id) + set("name", it.name) + set("required", it.requiredQuery) + if (it.optionalQuery != null) { + set("optional", it.optionalQuery) + } + }, + ) + + assertEquals( + "/test/%E2%9B%85%EF%B8%8E%E2%98%82%EF%B8%8E%E2%99%A0%EF%B8%8E%20spaces%20%E2%99%9B%E2%98%B9%EF%B8%8E%E2%9C%8E/example/%F0%9F%98%80%20%2F%20%F0%9F%A4%AA%20-%20%F0%9F%A4%A9%20%E2%84%94%E2%84%91%E2%88%A9%E2%88%80%E2%88%81?required=%F0%9F%98%87%F0%9F%A5%B0%E2%84%80%E2%84%B3%E2%84%83&optional=-%20dashes%20%2F%20slashes%20%7B%7D%20%5B%2520%25asd%5D%20", + binding.toPath( + ParameterizedOptionalKey( + id = "⛅︎☂︎♠︎ spaces ♛☹︎✎", + name = "😀 / 🤪 - 🤩 ℔ℑ∩∀∁", + requiredQuery = "😇🥰℀ℳ℃", + optionalQuery = "- dashes / slashes {} [%20%asd] " + ) + ), + ) + } + + @Test + fun `createPathBinding for no params`() { + val binding = NavigationPathBinding.createPathBinding( + "test", + { ParameterKeys.NoParams } + ) + val expectedKey = ParameterKeys.NoParams + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for one param`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}", + ParameterKeys.OneParam::id, + ParameterKeys::OneParam, + ) + val expectedKey = ParameterKeys.OneParam("123") + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test/123", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for two params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}", + ParameterKeys.TwoParams::id, + ParameterKeys.TwoParams::name, + ParameterKeys::TwoParams, + ) + val expectedKey = ParameterKeys.TwoParams("123", "john") + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test/123/example/john", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for three params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}?queryAge={age}", + ParameterKeys.ThreeParams::id, + ParameterKeys.ThreeParams::name, + ParameterKeys.ThreeParams::age, + ParameterKeys::ThreeParams, + ) + val expectedKey = ParameterKeys.ThreeParams("123", "john", 30) + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test/123/example/john?queryAge=30", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for four params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}?queryAge={age}&isActive={isActive}", + ParameterKeys.FourParams::id, + ParameterKeys.FourParams::name, + ParameterKeys.FourParams::age, + ParameterKeys.FourParams::isActive, + ParameterKeys::FourParams, + ) + val expectedKey = ParameterKeys.FourParams("123", "john", 30, true) + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test/123/example/john?queryAge=30&isActive=true", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for five params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}?queryAge={age}&isActive={isActive}&address={address?}", + ParameterKeys.FiveParams::id, + ParameterKeys.FiveParams::name, + ParameterKeys.FiveParams::age, + ParameterKeys.FiveParams::isActive, + ParameterKeys.FiveParams::address, + ParameterKeys::FiveParams, + ) + val expectedKey = ParameterKeys.FiveParams("123", "john", 30, true, "123 Main St") + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test/123/example/john?queryAge=30&isActive=true&address=123%20Main%20St", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for six params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}?queryAge={age}&isActive={isActive}&address={address}&phoneNumber={phoneNumber}", + ParameterKeys.SixParams::id, + ParameterKeys.SixParams::name, + ParameterKeys.SixParams::age, + ParameterKeys.SixParams::isActive, + ParameterKeys.SixParams::address, + ParameterKeys.SixParams::phoneNumber, + ParameterKeys::SixParams, + ) + val expectedKey = ParameterKeys.SixParams( + id = "123", + name = "john", + age = 30, + isActive = true, + address = "123 Main St", + phoneNumber = "123-456-7890" + ) + + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + + assertEquals("/test/123/example/john?queryAge=30&isActive=true&address=123%20Main%20St&phoneNumber=123-456-7890", path) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for seven params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}?queryAge={age}&isActive={isActive}&address={address}&phoneNumber={phoneNumber}&email={email}", + ParameterKeys.SevenParams::id, + ParameterKeys.SevenParams::name, + ParameterKeys.SevenParams::age, + ParameterKeys.SevenParams::isActive, + ParameterKeys.SevenParams::address, + ParameterKeys.SevenParams::phoneNumber, + ParameterKeys.SevenParams::email, + ParameterKeys::SevenParams, + ) + val expectedKey = ParameterKeys.SevenParams( + id = "123", + name = "john", + age = 30, + isActive = true, + address = "123 Main St", + phoneNumber = "123-456-7890", + email = "test@example.com", + ) + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + assertEquals( + "/test/123/example/john?queryAge=30&isActive=true&address=123%20Main%20St&phoneNumber=123-456-7890&email=test%40example.com", + path + ) + assertEquals(expectedKey, parsedKey) + } + + @Test + fun `createPathBinding for eight params`() { + val binding = NavigationPathBinding.createPathBinding( + "test/{id}/example/{name}?queryAge={age}&isActive={isActive}&address={address}&phoneNumber={phoneNumber}&email={email}&website={website}", + ParameterKeys.EightParams::id, + ParameterKeys.EightParams::name, + ParameterKeys.EightParams::age, + ParameterKeys.EightParams::isActive, + ParameterKeys.EightParams::address, + ParameterKeys.EightParams::phoneNumber, + ParameterKeys.EightParams::email, + ParameterKeys.EightParams::website, + ParameterKeys::EightParams, + ) + val expectedKey = ParameterKeys.EightParams( + id = "123", + name = "john", + age = 30, + isActive = true, + address = "123 Main St", + phoneNumber = "123-456-7890", + email = "test@example.com", + website = "https://example.com", + ) + val path = binding.toPath(expectedKey) + val parsedKey = binding.fromPath(ParsedPath.fromString(path)) + assertEquals( + "/test/123/example/john?queryAge=30&isActive=true&address=123%20Main%20St&phoneNumber=123-456-7890&email=test%40example.com&website=https%3A%2F%2Fexample.com", + path + ) + assertEquals(expectedKey, parsedKey) + } +} + +private data object ObjectKey : NavigationKey + +data class ParameterizedKey( + val id: String, + val name: String, +) : NavigationKey + +data class ParameterizedOptionalKey( + val id: String, + val name: String, + val requiredQuery: String, + val optionalQuery: String?, +) : NavigationKey + + +object ParameterKeys { + object NoParams : NavigationKey + + data class OneParam( + val id: String + ) : NavigationKey + + data class TwoParams( + val id: String, + val name: String + ) : NavigationKey + + data class ThreeParams( + val id: String, + val name: String, + val age: Int + ) : NavigationKey + + data class FourParams( + val id: String, + val name: String, + val age: Int, + val isActive: Boolean + ) : NavigationKey + + data class FiveParams( + val id: String, + val name: String, + val age: Int, + val isActive: Boolean, + val address: String? + ) : NavigationKey + + data class SixParams( + val id: String, + val name: String, + val age: Int, + val isActive: Boolean, + val address: String, + val phoneNumber: String + ) : NavigationKey + + data class SevenParams( + val id: String, + val name: String, + val age: Int, + val isActive: Boolean, + val address: String, + val phoneNumber: String, + val email: String + ) : NavigationKey + + data class EightParams( + val id: String, + val name: String, + val age: Int, + val isActive: Boolean, + val address: String, + val phoneNumber: String, + val email: String, + val website: String + ) : NavigationKey +} \ No newline at end of file diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/result/flow/ResultFlowTest.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/result/flow/ResultFlowTest.kt new file mode 100644 index 000000000..937ea4348 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/result/flow/ResultFlowTest.kt @@ -0,0 +1,87 @@ +package dev.enro.result.flow + +import androidx.lifecycle.ViewModel +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.complete +import dev.enro.navigationHandle +import dev.enro.test.assertCompleted +import dev.enro.test.fixtures.NavigationContainerFixtures +import dev.enro.test.putNavigationHandleForViewModel +import dev.enro.test.runEnroTest +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertTrue + +class ResultFlowTest { + + @Test + fun test() = runEnroTest { + val testNavigationHandle = + putNavigationHandleForViewModel( + ResultFlowDestination() + ) + val viewModel = ResultFlowViewModel() + val container = NavigationContainerFixtures.createForFlow(viewModel.flow) + + val first = container.backstack[0] as NavigationKey.Instance + assertTrue { + first.key.name == "First" + } + container.execute(NavigationOperation.Complete(first, "1")) + + val second = container.backstack[1] as NavigationKey.Instance + assertTrue { + second.key.name == "Second" + } + container.execute(NavigationOperation.Complete(second, "2")) + + val third = container.backstack[2] as NavigationKey.Instance + assertTrue { + third.key.name == "Third" + } + container.execute(NavigationOperation.Complete(third, "3")) + + testNavigationHandle.assertCompleted( + """ + First: 1 + Second: 2 + Third: 3 + """.trimIndent() + ) + } + + class ResultFlowViewModel : ViewModel() { + private val navigation by navigationHandle() + val flow by registerForFlowResult( + flow = { + val first = open( + RequestString("First") + ) + val second = open( + RequestString("Second") + ) + val third = open( + RequestString("Third") + ) + + return@registerForFlowResult """ + First: $first + Second: $second + Third: $third + """.trimIndent() + }, + onCompleted = { result -> + navigation.complete(result) + } + ) + } + + @Serializable + class ResultFlowDestination : NavigationKey.WithResult + + @Serializable + class RequestString( + val name: String, + ) : NavigationKey.WithResult +} \ No newline at end of file diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/serialization/HistoryStateSerializationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/serialization/HistoryStateSerializationTests.kt new file mode 100644 index 000000000..020023fe9 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/serialization/HistoryStateSerializationTests.kt @@ -0,0 +1,141 @@ +package dev.enro.serialization + +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.controller.repository.SerializerRepository +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.ClassDiscriminatorMode +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import kotlin.jvm.JvmInline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Serializable +@JvmInline +internal value class HistoryTestId(val value: String) + +@Serializable +internal enum class HistoryTestType { NPC, LOCATION } + +@Serializable +internal data class HistoryTestKey( + val id: HistoryTestId, + val name: String = "", + val types: Set = emptySet(), + val excludeIds: Set = emptySet(), +) : NavigationKey + +/** + * Pins the serialization of persisted navigation state (browser history, + * deep-link tooling) under the controller's json configuration, which uses + * the default (POLYMORPHIC) class-discriminator mode. + * + * `ClassDiscriminatorMode.ALL_JSON_OBJECTS` cannot be used: with kotlinx + * 1.11, realistic NavigationKey shapes break BOTH encoders under polymorphic + * dispatch — + * + * * STREAMING: a value-class field's discriminator write is deferred until + * the next `beginStructure`, which an inline field never opens, so the + * pending discriminator leaks into the next-opened object + * (`Instance.metadata` in practice) and the output fails to decode with + * "Expected JsonObject, but had JsonLiteral". Collection fields produce + * outright INVALID JSON (a `"type"` key:value pair inside an array). + * * TREE (`encodeToJsonElement`): collection fields crash the encoder with + * `NumberFormatException: For input string: "type"` (the deferred + * discriminator is applied inside a list context, where the tag is parsed + * as an array index). + * + * The documenting tests below assert those failures still exist: when one + * starts failing, kotlinx has fixed the corresponding bug and the + * POLYMORPHIC-mode constraint can be revisited. Upstream issue: + * https://github.com/Kotlin/kotlinx.serialization/issues/3022 + */ +class HistoryStateSerializationTests { + + private fun repository(): SerializerRepository { + return SerializerRepository().apply { + registerSerializersModule( + SerializersModule { + polymorphic(NavigationKey::class) { + subclass(HistoryTestKey.serializer()) + } + } + ) + } + } + + private val instanceSerializer = + NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)) + + private val fullShapeKey = HistoryTestKey( + id = HistoryTestId("abc-123"), + name = "name", + types = setOf(HistoryTestType.NPC, HistoryTestType.LOCATION), + excludeIds = setOf(HistoryTestId("excluded")), + ) + + @Test + fun instanceWithValueClassAndCollectionFieldsRoundTrips() { + val json = repository().jsonConfiguration + val instance = fullShapeKey.asInstance() + + val encoded = json.encodeToString(instanceSerializer, instance) + val decoded = json.decodeFromString(instanceSerializer, encoded) + + assertEquals(instance.id, decoded.id) + assertEquals(instance.key, decoded.key) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun allJsonObjectsJson(): Json = Json(from = repository().jsonConfiguration) { + classDiscriminatorMode = ClassDiscriminatorMode.ALL_JSON_OBJECTS + } + + /** + * Documents the upstream kotlinx streaming-encoder bug. If this test + * FAILS, kotlinx has fixed the deferred-discriminator leak for inline + * fields and ALL_JSON_OBJECTS may be viable again (check the other + * documenting tests too). + */ + @Test + fun allJsonObjectsStreamingEncoderLeaksValueClassDiscriminator() { + val json = allJsonObjectsJson() + val instance = HistoryTestKey(id = HistoryTestId("abc-123")).asInstance() + + val encoded = json.encodeToString(instanceSerializer, instance) + + assertTrue( + encoded.contains("\"metadata\":{\"type\":\"dev.enro.serialization.HistoryTestId\""), + "kotlinx appears to have fixed the streaming-encoder discriminator leak — " + + "ALL_JSON_OBJECTS may be viable again. Encoded: $encoded", + ) + } + + /** + * Documents the upstream kotlinx tree-encoder bug. If this test FAILS, + * kotlinx has fixed the collection-field discriminator crash and + * ALL_JSON_OBJECTS may be viable again (check the other documenting + * tests too). + */ + @Test + fun allJsonObjectsTreeEncoderFailsOnCollectionFields() { + val json = allJsonObjectsJson() + val instance = fullShapeKey.asInstance() + + val result = runCatching { + json.encodeToJsonElement(instanceSerializer, instance) + } + + assertTrue( + result.isFailure, + "kotlinx appears to have fixed the tree-encoder collection-field crash — " + + "ALL_JSON_OBJECTS may be viable again. Encoded: ${result.getOrNull()}", + ) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/serialization/SavedStateSerializationTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/serialization/SavedStateSerializationTests.kt new file mode 100644 index 000000000..36494319c --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/serialization/SavedStateSerializationTests.kt @@ -0,0 +1,53 @@ +package dev.enro.serialization + +import androidx.savedstate.serialization.decodeFromSavedState +import androidx.savedstate.serialization.encodeToSavedState +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.controller.repository.SerializerRepository +import dev.enro.test.platform.RobolectricHostTest +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The savedstate analogue of [HistoryStateSerializationTests]: container + * backstacks (rememberNavigationContainer), flow results, and enroSaver all + * round-trip through androidx.savedstate serialization with + * `ClassDiscriminatorMode.ALL_OBJECTS`. This pins that an Instance whose key + * carries a value-class field survives that round-trip — i.e. that the + * androidx SavedState encoder does NOT share the kotlinx streaming-JSON + * encoder's deferred-discriminator leak (see HistoryStateSerializationTests). + */ +class SavedStateSerializationTests : RobolectricHostTest() { + + private fun repository(): SerializerRepository { + return SerializerRepository().apply { + registerSerializersModule( + SerializersModule { + polymorphic(NavigationKey::class) { + subclass(HistoryTestKey.serializer()) + } + } + ) + } + } + + private val instanceSerializer = + NavigationKey.Instance.serializer(PolymorphicSerializer(NavigationKey::class)) + + @Test + fun instanceWithValueClassKeyFieldRoundTripsThroughSavedState() { + val configuration = repository().savedStateConfiguration + val instance = HistoryTestKey(id = HistoryTestId("abc-123"), name = "name").asInstance() + + val saved = encodeToSavedState(instanceSerializer, instance, configuration) + val decoded = decodeFromSavedState(instanceSerializer, saved, configuration) + + assertEquals(instance.id, decoded.id) + assertEquals(instance.key, decoded.key) + } +} diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/test/NavigationKeyFixtures.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/test/NavigationKeyFixtures.kt new file mode 100644 index 000000000..10a488ee8 --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/test/NavigationKeyFixtures.kt @@ -0,0 +1,15 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import kotlinx.serialization.Serializable +import kotlin.uuid.Uuid + +object NavigationKeyFixtures { + @Serializable + data class SimpleKey( + val keyId: String = Uuid.random().toString() + ) : NavigationKey + + @Serializable + class StringResultKey : NavigationKey.WithResult +} \ No newline at end of file diff --git a/enro-runtime/src/commonTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt new file mode 100644 index 000000000..fd90d35ca --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt @@ -0,0 +1,15 @@ +package dev.enro.test.platform + +/** + * Base class for common tests that touch Android framework classes (Bundle, + * SavedState) at runtime. + * + * On the Android host-test target, framework classes come from android.jar + * stubs, and `isReturnDefaultValues = true` makes them silently return + * defaults — e.g. `Bundle.getBundle(...)` returns null for a value that was + * "written" moments earlier, which surfaces as confusing downstream errors + * ("No valid saved state was found for the key ..."). Extending this class + * runs the test under Robolectric on that target, providing functional + * framework implementations; on every other target it is a no-op. + */ +expect abstract class RobolectricHostTest() diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/handle/RootNavigationHandle.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/handle/RootNavigationHandle.desktop.kt new file mode 100644 index 000000000..6db8eed8d --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/handle/RootNavigationHandle.desktop.kt @@ -0,0 +1,58 @@ +package dev.enro.handle + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.RootContext +import dev.enro.platform.desktop.RootWindow +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import dev.enro.ui.destinations.RootWindowDestination +import dev.enro.ui.destinations.isRootContextDestination + + +internal actual fun RootNavigationHandle.handleNavigationOperationForPlatform( + operation: NavigationOperation, + context: RootContext, +): Boolean { + val window = requireNotNull(context.parent as? RootWindow) { + "The context parent must be a RootWindow. Found: ${context.parent::class.simpleName}" + } + val operations = when(operation) { + is NavigationOperation.AggregateOperation -> operation.operations + else -> listOf(operation) + } + val close = operations + .filterIsInstance>() + .firstOrNull { it.instance.id == instance.id } + + val complete = operations.filterIsInstance>() + .firstOrNull { it.instance.id == instance.id } + + val opens = operations.filterIsInstance>() + .filter { + it.instance.isRootContextDestination(context.controller) + } + + if (opens.isEmpty() && close == null && complete == null) return false + opens.forEach { + RootWindowDestination.openAsRootWindow(context, it.instance) + } + when { + complete != null -> { + NavigationResultChannel.registerResult( + NavigationResult.Completed(instance, complete.result), + ) + context.controller.rootContextRegistry.unregister(window.navigationContext) + } + close != null -> { + if (!close.silent) { + NavigationResultChannel.registerResult( + NavigationResult.Closed(instance), + ) + } + context.controller.rootContextRegistry.unregister(window.navigationContext) + } + else -> {} + } + return true +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/EnroLog.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/EnroLog.desktop.kt new file mode 100644 index 000000000..69e2a6313 --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/EnroLog.desktop.kt @@ -0,0 +1,20 @@ +package dev.enro.platform + +@PublishedApi +internal actual object EnroLog { + actual fun debug(message: String) { + println("[Enro] debug: $message") + } + + actual fun warn(message: String) { + println("[Enro] warn: $message") + } + + actual fun error(message: String) { + println("[Enro] error: $message") + } + + actual fun error(message: String, throwable: Throwable) { + println("[Enro] error: $message") + } +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/EnroPlatform.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/EnroPlatform.desktop.kt new file mode 100644 index 000000000..a9f54e0c4 --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/EnroPlatform.desktop.kt @@ -0,0 +1,3 @@ +package dev.enro.platform + +internal object EnroPlatformDesktop : EnroPlatform \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/EnroController.openWindow.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/EnroController.openWindow.kt new file mode 100644 index 000000000..5d5d5140f --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/EnroController.openWindow.kt @@ -0,0 +1,11 @@ +package dev.enro.platform.desktop + +import dev.enro.EnroController +import dev.enro.NavigationKey + +public fun EnroController.openWindow( + window: RootWindow, +) { + rootContextRegistry.register(window.navigationContext) +} + diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/GenericRootWindow.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/GenericRootWindow.kt new file mode 100644 index 000000000..42c191e69 --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/GenericRootWindow.kt @@ -0,0 +1,23 @@ +package dev.enro.platform.desktop + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.platform.desktop.RootWindow.WindowConfiguration +import kotlinx.serialization.Serializable + + +@Suppress("FunctionName") // Mimics constructor +public fun GenericRootWindow( + windowConfiguration: RootWindow<*>.() -> WindowConfiguration = { WindowConfiguration() }, + content: @Composable RootWindowScope<*>.() -> Unit, +): RootWindow<*> { + return RootWindow( + instance = GenericRootWindowKey.asInstance(), + windowConfiguration = windowConfiguration, + content = content, + ) +} + +@Serializable +internal object GenericRootWindowKey : NavigationKey \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/LocalRootFrame.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/LocalRootFrame.kt new file mode 100644 index 000000000..05cd0f57a --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/LocalRootFrame.kt @@ -0,0 +1,9 @@ +package dev.enro.platform.desktop + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import java.awt.Frame + +public val LocalRootFrame: ProvidableCompositionLocal = staticCompositionLocalOf { + error("No root window provided") +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/RootWindow.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/RootWindow.kt new file mode 100644 index 000000000..940070f0f --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/desktop/RootWindow.kt @@ -0,0 +1,241 @@ +package dev.enro.platform.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowState +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.close +import dev.enro.context.RootContext +import dev.enro.handle.RootNavigationHandle +import dev.enro.handle.getOrCreateNavigationHandleHolder +import dev.enro.ui.LocalNavigationHandle +import dev.enro.ui.LocalRootContext +import dev.enro.viewmodel.EnroWrappedViewModelStoreOwner + +@Stable +public class RootWindow internal constructor( + private val instance: NavigationKey.Instance, + windowConfiguration: RootWindow.() -> WindowConfiguration = { WindowConfiguration() }, + private val content: @Composable RootWindowScope.() -> Unit, +) : LifecycleOwner, + ViewModelStoreOwner, + HasDefaultViewModelProviderFactory, + NavigationEventDispatcherOwner { + + private val windowConfiguration: WindowConfiguration by mutableStateOf( + windowConfiguration() + ) + + public val controller: EnroController = requireNotNull(EnroController.instance) { + "EnroController instance has not been initialized yet. Make sure you have installed the EnroController before instantiating a RootWindow." + } + + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + private var windowViewModelStoreOwner: EnroWrappedViewModelStoreOwner? = null + override val viewModelStore: ViewModelStore + get() { + return requireNotNull(windowViewModelStoreOwner) { + "windowViewModelStoreOwner has not been initialized yet" + }.viewModelStore + } + override val defaultViewModelCreationExtras: CreationExtras + get() { + val windowViewModelStoreOwner = requireNotNull(windowViewModelStoreOwner) { + "windowViewModelStoreOwner has not been initialized yet" + } + return windowViewModelStoreOwner.defaultViewModelCreationExtras + } + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() { + val windowViewModelStoreOwner = requireNotNull(windowViewModelStoreOwner) { + "windowViewModelStoreOwner has not been initialized yet" + } + return windowViewModelStoreOwner.defaultViewModelProviderFactory + } + + private var windowNavigationEventDispatcher: NavigationEventDispatcher? = null + override val navigationEventDispatcher: NavigationEventDispatcher + get() = requireNotNull(windowNavigationEventDispatcher) { + "windowNavigationEventDispatcher has not been initialized yet" + } + + private val activeChildId = mutableStateOf(null) + + public val navigationContext: RootContext = RootContext( + id = "RootWindow(${instance.key::class.simpleName})" + "$@${hashCode()}", + parent = this, + controller = controller, + lifecycleOwner = this, + viewModelStoreOwner = this, + defaultViewModelProviderFactory = this, + activeChildId = activeChildId, + ) + + @OptIn(ExperimentalComposeUiApi::class) + internal val movableWindowContent = movableContentOf { + key(navigationContext.id) { + val lazyRootWindowScope = remember?>> { + mutableStateOf(null) + } + if (controller.rootContextRegistry.getAllContexts().contains(navigationContext)) { + val movableContent = remember { + movableContentOf { windowScope: FrameWindowScope -> + val localViewModelStoreOwner = LocalViewModelStoreOwner.current + requireNotNull(localViewModelStoreOwner) { + "No ViewModelStoreOwner was provided for the RootWindow." + } + val viewModelStoreOwner = remember(localViewModelStoreOwner) { + EnroWrappedViewModelStoreOwner( + controller = controller, + viewModelStoreOwner = localViewModelStoreOwner, + savedStateRegistryOwner = null, + ) + } + windowViewModelStoreOwner = viewModelStoreOwner + windowNavigationEventDispatcher = LocalNavigationEventDispatcherOwner.current!!.navigationEventDispatcher + // Get or create the NavigationHandleHolder for this destination + val navigationHandle = remember(viewModelStoreOwner) { + val instance = instance + val holder = viewModelStoreOwner.getOrCreateNavigationHandleHolder { + RootNavigationHandle( + instance = instance, + savedStateHandle = createSavedStateHandle(), + ) + } + val navigationHandle = holder.navigationHandle + require(navigationHandle is RootNavigationHandle) + navigationHandle.bindContext(navigationContext) + return@remember navigationHandle + } + val rootWindowScope = remember(navigationHandle) { + val scope = RootWindowScope( + navigationContext = navigationContext, + navigation = navigationHandle, + frameWindowScope = windowScope, + ) + lazyRootWindowScope.value = scope + return@remember scope + } + + CompositionLocalProvider( + LocalRootContext provides navigationContext, + LocalNavigationHandle provides navigationHandle, + LocalViewModelStoreOwner provides viewModelStoreOwner, + ) { + rootWindowScope.content() + } + } + } + Window( + state = this.windowConfiguration.state, + visible = this.windowConfiguration.visible, + title = this.windowConfiguration.title, + icon = this.windowConfiguration.icon, + transparent = this.windowConfiguration.transparent, + undecorated = this.windowConfiguration.undecorated, + resizable = this.windowConfiguration.resizable, + enabled = this.windowConfiguration.enabled, + focusable = this.windowConfiguration.focusable, + alwaysOnTop = this.windowConfiguration.alwaysOnTop, + onPreviewKeyEvent = { + val scope = lazyRootWindowScope.value ?: return@Window false + this.windowConfiguration.onPreviewKeyEvent(scope, it) + }, + onKeyEvent = { + val scope = lazyRootWindowScope.value ?: return@Window false + this.windowConfiguration.onKeyEvent(scope, it) + }, + onCloseRequest = { + val scope = lazyRootWindowScope.value ?: return@Window + this.windowConfiguration.onCloseRequest(scope) + }, + ) { + val localLifecycleState = LocalLifecycleOwner.current + .lifecycle + .currentStateFlow + .collectAsState() + .value + + lifecycleRegistry.currentState = localLifecycleState + CompositionLocalProvider( + LocalRootFrame provides window, + ) { + movableContent.invoke(this) + } + } + + DisposableEffect(Unit) { + onDispose { + if (!controller.rootContextRegistry.getAllContexts().contains(navigationContext)) { + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } + } + } + } + } + } + + public data class WindowConfiguration( + val state: WindowState = WindowState(), + val visible: Boolean = true, + val title: String = "Untitled", + val icon: Painter? = null, + val undecorated: Boolean = false, +// val decoration: WindowDecoration = WindowDecoration.SystemDefault, + val transparent: Boolean = false, + val resizable: Boolean = true, + val enabled: Boolean = true, + val focusable: Boolean = true, + val alwaysOnTop: Boolean = false, + val onPreviewKeyEvent: RootWindowScope.(KeyEvent) -> Boolean = { false }, + val onKeyEvent: RootWindowScope.(KeyEvent) -> Boolean = { false }, + val onCloseRequest: RootWindowScope.() -> Unit = { navigation.close() }, + ) +} + +public class RootWindowScope internal constructor( + public val navigationContext: RootContext, + public val navigation: NavigationHandle, + private val frameWindowScope: FrameWindowScope, +) : FrameWindowScope by frameWindowScope { + + public val instance: NavigationKey.Instance + get() = navigation.instance + + public val key: T + get() = navigation.key +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/platformNavigationModule.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/platformNavigationModule.desktop.kt new file mode 100644 index 000000000..5f9d55349 --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/platform/platformNavigationModule.desktop.kt @@ -0,0 +1,6 @@ +package dev.enro.platform + +import dev.enro.controller.NavigationModule +import dev.enro.controller.createNavigationModule + +internal actual val platformNavigationModule: NavigationModule = createNavigationModule { } \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/EnroApplicationContent.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/EnroApplicationContent.kt new file mode 100644 index 000000000..1d1e8abd9 --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/EnroApplicationContent.kt @@ -0,0 +1,20 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.ApplicationScope +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.platform.desktop.RootWindow + +@Composable +public fun ApplicationScope.EnroApplicationContent( + controller: EnroController, +) { + val contexts = controller.rootContextRegistry.getAllContexts() + contexts.forEach { context -> + val parent = context.parent + if (parent is RootWindow) { + parent.movableWindowContent() + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/LocalNavigationContext.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/LocalNavigationContext.desktop.kt new file mode 100644 index 000000000..4aaa8663d --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/LocalNavigationContext.desktop.kt @@ -0,0 +1,15 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import dev.enro.context.RootContext + +public val LocalRootContext: ProvidableCompositionLocal = staticCompositionLocalOf { + error("No RootContext provided") +} + +@Composable +internal actual fun findRootNavigationContext(): RootContext { + return LocalRootContext.current +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.desktop.kt new file mode 100644 index 000000000..21364918e --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.desktop.kt @@ -0,0 +1,9 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun rememberShouldRemoveViewModelStoreCallback(): () -> Boolean { + // On desktop, always remove ViewModelStore when destination is removed + return { true } +} \ No newline at end of file diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/destinations/RootWindowDestination.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/destinations/RootWindowDestination.kt new file mode 100644 index 000000000..c34ddc154 --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/ui/destinations/RootWindowDestination.kt @@ -0,0 +1,70 @@ +package dev.enro.ui.destinations + +import androidx.compose.runtime.Composable +import dev.enro.NavigationKey +import dev.enro.context.RootContext +import dev.enro.platform.desktop.RootWindow +import dev.enro.platform.desktop.RootWindowScope +import dev.enro.platform.desktop.openWindow +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.navigationDestination +import kotlin.reflect.KClass + + +public object RootWindowDestination { + internal const val ConfigurationKey = "dev.enro.ui.destinations.RootWindowDestination.ConfigurationKey" + + internal fun openAsRootWindow( + context: RootContext, + instance: NavigationKey.Instance, + ) { + val metadata = context.controller.bindings.bindingFor(instance) + .provider + .peekMetadata(instance) + + @Suppress("UNCHECKED_CAST") + val configuration = metadata[ConfigurationKey] as? RootWindowDestinationConfiguration + if (configuration == null) { + error("RootWindowDestination requires a content block.") + } + context.controller.openWindow( + RootWindow( + instance = instance, + windowConfiguration = configuration.windowConfiguration, + content = configuration.content, + ) + ) + } + + internal class RootWindowDestinationConfiguration( + val windowConfiguration: RootWindow.() -> RootWindow.WindowConfiguration, + val content: @Composable RootWindowScope.() -> Unit, + ) +} + +public inline fun rootWindowDestination( + noinline windowConfiguration: RootWindow.() -> RootWindow.WindowConfiguration = { RootWindow.WindowConfiguration() }, + noinline content: @Composable RootWindowScope.() -> Unit, +): NavigationDestinationProvider { + return rootWindowDestination(T::class, windowConfiguration, content) +} + +public fun rootWindowDestination( + keyType: KClass, + windowConfiguration: RootWindow.() -> RootWindow.WindowConfiguration = { RootWindow.WindowConfiguration() }, + content: @Composable RootWindowScope.() -> Unit, +): NavigationDestinationProvider { + return navigationDestination( + metadata = { + add( + RootWindowDestination.ConfigurationKey to RootWindowDestination.RootWindowDestinationConfiguration( + windowConfiguration = windowConfiguration, + content = content, + ) + ) + rootContextDestination() + } + ) { + error("activityDestination should not be rendered directly. If you are reaching this, please report this as a bug.") + } +} diff --git a/enro-runtime/src/desktopMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.desktop.kt b/enro-runtime/src/desktopMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.desktop.kt new file mode 100644 index 000000000..37d84cdbe --- /dev/null +++ b/enro-runtime/src/desktopMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.desktop.kt @@ -0,0 +1,23 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import dev.enro.NavigationHandle +import kotlin.reflect.KClass + + +public actual class EnroViewModelFactory actual constructor( + private val navigationHandle: NavigationHandle<*>, + private val delegate: ViewModelProvider.Factory, +) : ViewModelProvider.Factory { + public override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T { + NavigationHandleProvider.put(modelClass, navigationHandle) + return delegate.create(modelClass, extras).also { + NavigationHandleProvider.clear(modelClass) + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/desktopTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt b/enro-runtime/src/desktopTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt new file mode 100644 index 000000000..9ad2514b3 --- /dev/null +++ b/enro-runtime/src/desktopTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt @@ -0,0 +1,3 @@ +package dev.enro.test.platform + +actual abstract class RobolectricHostTest actual constructor() diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/handle/RootNavigationHandle.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/handle/RootNavigationHandle.ios.kt new file mode 100644 index 000000000..a33038610 --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/handle/RootNavigationHandle.ios.kt @@ -0,0 +1,95 @@ +package dev.enro.handle + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.RootContext +import dev.enro.result.NavigationResult +import dev.enro.result.NavigationResultChannel +import dev.enro.ui.destinations.UIViewControllerDestination +import dev.enro.ui.destinations.isRootContextDestination +import platform.UIKit.UINavigationController +import platform.UIKit.UIViewController + +internal actual fun RootNavigationHandle.handleNavigationOperationForPlatform( + operation: NavigationOperation, + context: RootContext, +): Boolean { + val uiViewController = requireNotNull(context.parent as? UIViewController) { + "The context parent must be a EnroUINavigationController. Found: ${context.parent::class.simpleName}" + } + val uiNavigationController = findUINavigationController(uiViewController) + + val operations = when(operation) { + is NavigationOperation.AggregateOperation -> operation.operations + else -> listOf(operation) + } + + val close = operations + .filterIsInstance>() + .firstOrNull { it.instance.id == instance.id } + + val complete = operations.filterIsInstance>() + .firstOrNull { it.instance.id == instance.id } + + val opens = operations.filterIsInstance>() + .filter { + it.instance.isRootContextDestination(context.controller) + } + + if (opens.isEmpty() && close == null && complete == null) return false + val configurations = opens.mapNotNull { + val configuration = UIViewControllerDestination.getConfiguration( + controller = context.controller, + instance = it.instance, + ) + if (configuration == null) return@mapNotNull null + it.instance to configuration + } + configurations.forEach { (key, configuration) -> + UIViewControllerDestination.executePresentationAction( + configuration = configuration, + instance = key, + uiViewController = uiViewController, + uiNavigationController = uiNavigationController + ) + } + when { + complete != null -> { + NavigationResultChannel.registerResult( + NavigationResult.Completed(instance, complete.result), + ) + } + close != null -> { + if (!close.silent) { + NavigationResultChannel.registerResult( + NavigationResult.Closed(instance), + ) + } + } + else -> {} + } + if (close != null || complete != null) { + if (uiNavigationController != null) { + uiNavigationController.setViewControllers( + uiNavigationController.viewControllers.filter { it != uiViewController }, + animated = true + ) + } + else { + uiViewController.presentingViewController + ?.dismissViewControllerAnimated(true, null) + } + } + return true +} + +private fun findUINavigationController(from: UIViewController): UINavigationController? { + var current: UIViewController? = from + while (current != null) { + if (current is UINavigationController) { + return current + } + current = current.parentViewController + } + return null +} \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroLog.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroLog.ios.kt new file mode 100644 index 000000000..165aab1ec --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroLog.ios.kt @@ -0,0 +1,21 @@ +package dev.enro.platform + +@PublishedApi +internal actual object EnroLog { + actual fun debug(message: String) { + println("[Enro] DEBUG: $message") + } + + actual fun warn(message: String) { + println("[Enro] WARNING: $message") + } + + actual fun error(message: String) { + println("[Enro] ERROR: $message") + } + + actual fun error(message: String, throwable: Throwable) { + println("[Enro] ERROR: $message") + throwable.printStackTrace() + } +} \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroPlatform.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroPlatform.ios.kt new file mode 100644 index 000000000..38597e53b --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroPlatform.ios.kt @@ -0,0 +1,3 @@ +package dev.enro.platform + +internal object EnroPlatformIOS : EnroPlatform \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroUIViewController.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroUIViewController.kt new file mode 100644 index 000000000..1b7d84917 --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/EnroUIViewController.kt @@ -0,0 +1,96 @@ +package dev.enro.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import androidx.compose.ui.uikit.LocalUIViewController +import androidx.compose.ui.window.ComposeUIViewController +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.context.RootContext +import dev.enro.handle.RootNavigationHandle +import dev.enro.handle.getOrCreateNavigationHandleHolder +import dev.enro.ui.LocalNavigationContext +import dev.enro.ui.LocalNavigationHandle +import dev.enro.viewmodel.EnroWrappedViewModelStoreOwner +import kotlinx.serialization.Serializable +import platform.UIKit.UIViewController + +public fun EnroUIViewController( + configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, + content: @Composable () -> Unit, +): UIViewController { + return ComposeUIViewController( + configure, + ) { + val instance = remember { GenericUIViewControllerKey.asInstance() } + val enroController = remember { + requireNotNull(EnroController.instance) { + "EnroController instance is not available. Ensure that Enro is properly initialized." + } + } + val viewController = LocalUIViewController.current + val lifecycleOwner = LocalLifecycleOwner.current + val localViewModelStoreOwner = requireNotNull(LocalViewModelStoreOwner.current) { + "LocalViewModelStoreOwner is not provided. Ensure that the composable is hosted within a ViewModelStoreOwner." + } + val viewModelStoreOwner = remember(localViewModelStoreOwner) { + EnroWrappedViewModelStoreOwner( + controller = enroController, + viewModelStoreOwner = localViewModelStoreOwner, + savedStateRegistryOwner = null + ) + } + val activeChildId = remember { mutableStateOf(null) } + val (context, navigationHandle) = remember(viewModelStoreOwner) { + val context = RootContext( + id = "UIViewController(${instance.key::class.simpleName})" + "$@${viewController.hashCode()}", + parent = viewController, + controller = enroController, + lifecycleOwner = lifecycleOwner, + viewModelStoreOwner = viewModelStoreOwner, + defaultViewModelProviderFactory = viewModelStoreOwner, + activeChildId = activeChildId, + ) + viewController.internalNavigationContext = context + + val instance = instance + val holder = viewModelStoreOwner.getOrCreateNavigationHandleHolder { + RootNavigationHandle( + instance = instance, + savedStateHandle = createSavedStateHandle(), + ) + } + val navigationHandle = holder.navigationHandle + require(navigationHandle is RootNavigationHandle) + navigationHandle.bindContext(context) + + return@remember context to navigationHandle + } + + DisposableEffect(context) { + enroController.rootContextRegistry.register(context) + onDispose { + enroController.rootContextRegistry.unregister(context) + } + } + + CompositionLocalProvider( + LocalNavigationContext provides context, + LocalNavigationHandle provides navigationHandle, + LocalViewModelStoreOwner provides viewModelStoreOwner, + ) { + content() + } + } +} + +@Serializable +internal object GenericUIViewControllerKey : NavigationKey diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/platform/UIViewController.navigationContext.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/UIViewController.navigationContext.kt new file mode 100644 index 000000000..ec83fd87c --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/UIViewController.navigationContext.kt @@ -0,0 +1,34 @@ +package dev.enro.platform + +import dev.enro.context.AnyNavigationContext +import dev.enro.context.RootContext +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIViewController +import platform.objc.OBJC_ASSOCIATION_RETAIN_NONATOMIC +import platform.objc.objc_getAssociatedObject +import platform.objc.objc_setAssociatedObject + +public val UIViewController.navigationContext: AnyNavigationContext + get() { + return internalNavigationContext ?: error("UIViewController $this is not an EnroUIViewController, and does not have a navigation context.") + } + +@OptIn(ExperimentalForeignApi::class) +private val UIViewControllerNavigationContextKey = kotlinx.cinterop.staticCFunction {} + +@OptIn(ExperimentalForeignApi::class) +internal var UIViewController.internalNavigationContext: AnyNavigationContext? + get() { + return objc_getAssociatedObject( + this, + UIViewControllerNavigationContextKey + ) as? RootContext? + } + set(value) { + objc_setAssociatedObject( + `object` = this, + key = UIViewControllerNavigationContextKey, + value = value, + policy = OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/platform/platformNavigationModule.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/platformNavigationModule.ios.kt new file mode 100644 index 000000000..c435ea436 --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/platform/platformNavigationModule.ios.kt @@ -0,0 +1,8 @@ +package dev.enro.platform + +import dev.enro.controller.NavigationModule +import dev.enro.controller.createNavigationModule + +internal actual val platformNavigationModule: NavigationModule = createNavigationModule { + +} \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/ui/LocalNavigationContext.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/ui/LocalNavigationContext.ios.kt new file mode 100644 index 000000000..f1a54c57c --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/ui/LocalNavigationContext.ios.kt @@ -0,0 +1,26 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.uikit.LocalUIViewController +import dev.enro.context.RootContext +import dev.enro.context.root +import dev.enro.platform.internalNavigationContext +import platform.UIKit.UIViewController + +@Composable +internal actual fun findRootNavigationContext(): RootContext { + val viewController = LocalUIViewController.current + return remember(viewController) { + requireNotNull(viewController) + var active: UIViewController? = viewController + while (active != null) { + val context = active.internalNavigationContext + if (context != null) { + return@remember context.root() + } + active = active.parentViewController + } + error("Could not find a RootContext in the parent view controller hierarchy from $viewController") + } +} \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.ios.kt new file mode 100644 index 000000000..eeaf3ef48 --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.ios.kt @@ -0,0 +1,9 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun rememberShouldRemoveViewModelStoreCallback(): () -> Boolean { + // On iOS, always remove ViewModelStore when destination is removed + return { true } +} \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/ui/destinations/UIViewControllerDestination.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/ui/destinations/UIViewControllerDestination.kt new file mode 100644 index 000000000..7136f811c --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/ui/destinations/UIViewControllerDestination.kt @@ -0,0 +1,149 @@ +package dev.enro.ui.destinations + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitViewController +import dev.enro.EnroController +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.navigationDestination +import platform.UIKit.UIModalPresentationAutomatic +import platform.UIKit.UIModalPresentationStyle +import platform.UIKit.UINavigationController +import platform.UIKit.UIViewController +import kotlin.reflect.KClass + +public object UIViewControllerDestination { + + // Represents the configuration for a UIViewControllerDestination, where the list of + // Configuration.Flags represent what kinds of presentation the UIViewControllerDestination + // should support; the order of the flags is important, as this indicates the order of + // preference for presentation styles; for example, if "SupportsCompose" is first, + // then the UIViewController will prefer being hosted within Compose, and if this is + // possible, will use that presentation format, but if "SupportsPresent" is first, + // the UIViewController will prefer being presented as a modal. + // These flags are provided inside a NavigationDestination.MetadataBuilder, which allows + // different flags to be returned, depending on the NavigationKey.Instance used for the destination. + // For example, a NavigationKey could have "present: Boolean" as a property, causing the + // associated UIViewControllerDestination to return "SupportsPresent" as the first flag. + public class Configuration( + internal val flags: List, + internal val constructor: (NavigationKey.Instance) -> UIViewController + ) { + public sealed interface Flag + } + + // Whether the UIViewController should be able to be hosted inside + // Compose (i.e. within an Enro NavigationContainer) + public object SupportsCompose : Configuration.Flag + + // Whether the UIViewController is able to be presented from another + // UIViewController, will use the "style" presentation style if presented + public class SupportsPresent( + public val style: UIModalPresentationStyle = UIModalPresentationAutomatic, + ) : Configuration.Flag + + // Whether the UIViewController supports being pushed into a UINavigationView + public object SupportsUINavigationView : Configuration.Flag + + internal const val ConfigurationKey: String = " dev.enro.ui.destinations.UIViewControllerDestination.Configuration" + internal object IgnoreComposeKey : NavigationKey.MetadataKey(false) + + internal fun getConfiguration( + controller: EnroController, + instance: NavigationKey.Instance, + ): Configuration? { + val binding = controller.bindings.bindingFor(instance) + val metadata = binding.provider.peekMetadata(instance) + return metadata[ConfigurationKey] as? Configuration + } + + internal fun executePresentationAction( + configuration: Configuration, + instance: NavigationKey.Instance, + uiViewController: UIViewController, + uiNavigationController: UINavigationController? + ) { + configuration.flags.forEach { + when(it) { + is SupportsCompose -> { + return + } + is SupportsPresent -> { + uiViewController.presentViewController( + viewControllerToPresent = configuration.constructor(instance), + animated = true, + completion = null, + ) + return + } + is SupportsUINavigationView -> { + if (uiNavigationController == null) return@forEach + uiNavigationController.pushViewController( + viewController = configuration.constructor(instance), + animated = true, + ) + return + } + } + } + } +} + +public inline fun uiViewControllerDestination( + noinline metadata: NavigationDestination.MetadataBuilder.() -> List, + noinline viewController: (NavigationKey.Instance) -> UIViewController, +) : NavigationDestinationProvider { + return uiViewControllerDestination( + keyType = T::class, + metadata = metadata, + viewController = viewController, + ) +} + +public fun uiViewControllerDestination( + keyType: KClass, + metadata: NavigationDestination.MetadataBuilder.() -> List, + viewController: (NavigationKey.Instance) -> UIViewController, +) : NavigationDestinationProvider { + return navigationDestination( + metadata = { + val flags = metadata().filter { + // If the instance has been set specifically to ignore Compose hosting, + // we're going to filter out any SupportsCompose flags + if (instance.metadata.get(UIViewControllerDestination.IgnoreComposeKey)) { + return@filter it !is UIViewControllerDestination.SupportsCompose + } + return@filter true + } + val config = UIViewControllerDestination.Configuration( + flags = flags, + constructor = { instance -> + @Suppress("UNCHECKED_CAST") + viewController(instance as NavigationKey.Instance) + } + ) + add(UIViewControllerDestination.ConfigurationKey, config) + if (flags.firstOrNull() != UIViewControllerDestination.SupportsCompose) { + rootContextDestination() + } + } + ) { + val config = remember(destinationMetadata) { + val configuration = destinationMetadata[UIViewControllerDestination.ConfigurationKey] as? UIViewControllerDestination.Configuration + requireNotNull(configuration) { + "No UIViewControllerDestination.Configuration found for ${keyType.simpleName}" + } + require(configuration.flags.any { it is UIViewControllerDestination.SupportsCompose }) { + "UIViewControllerDestination for ${keyType.simpleName} does not support being hosted in Compose" + } + return@remember configuration + } + UIKitViewController( + modifier = Modifier.fillMaxSize(), + factory = { config.constructor(navigation.instance) }, + ) + } +} \ No newline at end of file diff --git a/enro-runtime/src/iosMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.ios.kt b/enro-runtime/src/iosMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.ios.kt new file mode 100644 index 000000000..37d84cdbe --- /dev/null +++ b/enro-runtime/src/iosMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.ios.kt @@ -0,0 +1,23 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import dev.enro.NavigationHandle +import kotlin.reflect.KClass + + +public actual class EnroViewModelFactory actual constructor( + private val navigationHandle: NavigationHandle<*>, + private val delegate: ViewModelProvider.Factory, +) : ViewModelProvider.Factory { + public override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T { + NavigationHandleProvider.put(modelClass, navigationHandle) + return delegate.create(modelClass, extras).also { + NavigationHandleProvider.clear(modelClass) + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/iosTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt b/enro-runtime/src/iosTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt new file mode 100644 index 000000000..9ad2514b3 --- /dev/null +++ b/enro-runtime/src/iosTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt @@ -0,0 +1,3 @@ +package dev.enro.test.platform + +actual abstract class RobolectricHostTest actual constructor() diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/handle/RootNavigationHandle.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/handle/RootNavigationHandle.wasmJs.kt new file mode 100644 index 000000000..4d7c74bf0 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/handle/RootNavigationHandle.wasmJs.kt @@ -0,0 +1,16 @@ +package dev.enro.handle + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.context.RootContext + +internal actual fun RootNavigationHandle.handleNavigationOperationForPlatform( + operation: NavigationOperation, + context: RootContext, +): Boolean { + // The browser has no equivalent of opening another root context (a new tab + // can't be reliably opened from app code, and closing the root would close + // the tab itself), so there's no platform-specific handling to do here — + // every operation is processed through normal container flow. + return false +} diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/EnroLog.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/EnroLog.wasmJs.kt new file mode 100644 index 000000000..69e2a6313 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/EnroLog.wasmJs.kt @@ -0,0 +1,20 @@ +package dev.enro.platform + +@PublishedApi +internal actual object EnroLog { + actual fun debug(message: String) { + println("[Enro] debug: $message") + } + + actual fun warn(message: String) { + println("[Enro] warn: $message") + } + + actual fun error(message: String) { + println("[Enro] error: $message") + } + + actual fun error(message: String, throwable: Throwable) { + println("[Enro] error: $message") + } +} \ No newline at end of file diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/EnroPlatform.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/EnroPlatform.wasmJs.kt new file mode 100644 index 000000000..312306821 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/EnroPlatform.wasmJs.kt @@ -0,0 +1,3 @@ +package dev.enro.platform + +internal object EnroPlatformWasmJs : EnroPlatform \ No newline at end of file diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/platformNavigationModule.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/platformNavigationModule.wasmJs.kt new file mode 100644 index 000000000..5f9d55349 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/platform/platformNavigationModule.wasmJs.kt @@ -0,0 +1,6 @@ +package dev.enro.platform + +import dev.enro.controller.NavigationModule +import dev.enro.controller.createNavigationModule + +internal actual val platformNavigationModule: NavigationModule = createNavigationModule { } \ No newline at end of file diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/EnroBrowserContent.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/EnroBrowserContent.kt new file mode 100644 index 000000000..61d9e9a55 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/EnroBrowserContent.kt @@ -0,0 +1,159 @@ +package dev.enro.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import dev.enro.EnroController +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.context.RootContext +import dev.enro.handle.RootNavigationHandle +import dev.enro.handle.getOrCreateNavigationHandleHolder +import dev.enro.viewmodel.EnroWrappedViewModelStoreOwner +import kotlinx.serialization.Serializable +import kotlin.uuid.Uuid + +/** + * A composable that provides the root navigation context for a browser-based Enro application. + * + * This is the main entry point for using Enro in a wasmJs/browser environment. It sets up the + * required navigation context, view model store, and navigation handle that are needed for + * Enro navigation to work. + * + * Usage: + * ```kotlin + * fun main() { + * MyNavigationComponent.installNavigationController(document) + * ComposeViewPort { + * EnroBrowserContent { + * // Your app content here + * MyApplicationContent() + * } + * } + * } + * ``` + * + * @param suppressEscapeAsBack When `true` (the default), pressing the + * Escape key inside the Compose composition is consumed before + * Compose Multiplatform's built-in `BackNavigationEventInput` can + * translate it into a back-navigation event. Browser back/forward via + * the history API is unaffected (it routes through `popstate`, not the + * navigation event dispatcher). Set to `false` if your app deliberately + * wants Escape→back behaviour. + * @param content The composable content of your application + */ +@Composable +public fun EnroBrowserContent( + suppressEscapeAsBack: Boolean = true, + content: @Composable EnroBrowserScope.() -> Unit, +) { + val instance = remember { GenericBrowserKey.asInstance() } + val enroController = remember { + requireNotNull(EnroController.instance) { + "EnroController instance is not available. Ensure that Enro is properly initialized before calling EnroBrowserContent." + } + } + val lifecycleOwner = LocalLifecycleOwner.current + val localViewModelStoreOwner = requireNotNull(LocalViewModelStoreOwner.current) { + "LocalViewModelStoreOwner is not provided. Ensure that the composable is hosted within a ViewModelStoreOwner." + } + val viewModelStoreOwner = remember(localViewModelStoreOwner) { + EnroWrappedViewModelStoreOwner( + controller = enroController, + viewModelStoreOwner = localViewModelStoreOwner, + savedStateRegistryOwner = null + ) + } + val activeChildId = remember { mutableStateOf(null) } + val browserId = remember { Uuid.random().toString() } + val (context, navigationHandle) = remember(viewModelStoreOwner) { + val context = RootContext( + id = "Browser(${instance.key::class.simpleName})@$browserId", + parent = Unit, // Browser tabs don't have a parent object like UIViewController + controller = enroController, + lifecycleOwner = lifecycleOwner, + viewModelStoreOwner = viewModelStoreOwner, + defaultViewModelProviderFactory = viewModelStoreOwner, + activeChildId = activeChildId, + ) + + val holder = viewModelStoreOwner.getOrCreateNavigationHandleHolder { + RootNavigationHandle( + instance = instance, + savedStateHandle = createSavedStateHandle(), + ) + } + val navigationHandle = holder.navigationHandle + require(navigationHandle is RootNavigationHandle) + navigationHandle.bindContext(context) + + return@remember context to navigationHandle + } + + DisposableEffect(context) { + enroController.rootContextRegistry.register(context) + onDispose { + enroController.rootContextRegistry.unregister(context) + } + } + + val browserScope = remember(navigationHandle) { + EnroBrowserScope(navigationHandle) + } + + CompositionLocalProvider( + LocalRootContext provides context, + LocalNavigationContext provides context, + LocalNavigationHandle provides navigationHandle, + LocalViewModelStoreOwner provides viewModelStoreOwner, + ) { + if (suppressEscapeAsBack) { + // Consumes Escape KeyDown via Compose's preview key chain + // (outer → inner) before Compose Multiplatform's + // BackNavigationEventInput can translate it into a back event. + // Inner Popups/Dialogs run in their own focus tree and still + // see Escape — only the main composition's Escape is swallowed. + Box( + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + event.type == KeyEventType.KeyDown && event.key == Key.Escape + }, + ) { + browserScope.content() + } + } else { + browserScope.content() + } + } +} + +/** + * Scope for the EnroBrowserContent composable, providing access to the root navigation handle. + */ +public class EnroBrowserScope internal constructor( + public val navigation: NavigationHandle<*>, +) { + public val instance: NavigationKey.Instance<*> + get() = navigation.instance + + public val key: NavigationKey + get() = navigation.key +} + +@Serializable +internal object GenericBrowserKey : NavigationKey diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/LocalNavigationContext.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/LocalNavigationContext.wasmJs.kt new file mode 100644 index 000000000..4aaa8663d --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/LocalNavigationContext.wasmJs.kt @@ -0,0 +1,15 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import dev.enro.context.RootContext + +public val LocalRootContext: ProvidableCompositionLocal = staticCompositionLocalOf { + error("No RootContext provided") +} + +@Composable +internal actual fun findRootNavigationContext(): RootContext { + return LocalRootContext.current +} \ No newline at end of file diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/WebHistoryPlugin.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/WebHistoryPlugin.kt new file mode 100644 index 000000000..ecbb24d89 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/WebHistoryPlugin.kt @@ -0,0 +1,543 @@ +package dev.enro.ui +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.enro.EnroController +import dev.enro.NavigationBackstack +import dev.enro.NavigationContainer +import dev.enro.NavigationHandle +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.context.ContainerContext +import dev.enro.controller.createNavigationModule +import dev.enro.emptyBackstack +import dev.enro.path.getBackstackFromPath +import dev.enro.path.getPathFromNavigationKey +import dev.enro.platform.EnroLog +import dev.enro.plugin.NavigationPlugin +import kotlinx.browser.window +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlinx.serialization.Serializable +import org.w3c.dom.PopStateEvent +import org.w3c.dom.Window +import org.w3c.dom.events.Event + +// Root-container-only history: only the root container's backstack participates in +// browser history. Inner-container navigation (modals, tabs, list-detail panes, etc.) +// is session-local and not reflected in the URL or back/forward history. This is the +// "Twitter/X / Reddit" model — pages get URLs, page-internal state does not. +// +// Nested URL routing is a known future direction; see docs/ghpages/docs/platform/web.md +// for the model we ship in beta. +// +// Synchronisation model: every input (destination lifecycle callback or browser +// popstate) is enqueued onto a single serial processor, so updates are never dropped +// and the in-memory mirror of browser history can't silently diverge from the real +// session history. History traversals the plugin itself initiates (`history.go`) +// are awaited via their popstate echo, which is consumed before it can be mistaken +// for a user-initiated back/forward. +@ExperimentalEnroApi +internal class WebHistoryPlugin( + private val window: Window, + private val rootContainer: ContainerContext, +) : NavigationPlugin() { + + private val scope = CoroutineScope(Dispatchers.Main) + + /** + * Serial work queue. `null` means "the backstack changed, re-sync browser + * history"; a [PopStateEvent] means "the browser navigated, re-sync the + * backstack". Processing strictly in order is what keeps [historyStates] + * truthful — the previous implementation dropped events that arrived while + * a sync was in flight, which desynced the mirror and made a single + * browser back traverse multiple app screens. + */ + private val events = Channel(capacity = Channel.UNLIMITED) + + /** + * Set while the plugin is awaiting the popstate echo of its own + * `history.go()` call — see [traverse]. The next popstate completes it and + * is consumed instead of being enqueued as user navigation. + */ + private var pendingTraversal: CompletableDeferred? = null + + private val eventListener: (Event) -> Unit = { event -> + if (event is PopStateEvent) { + val traversal = pendingTraversal + if (traversal != null) { + pendingTraversal = null + traversal.complete(Unit) + } else { + events.trySend(event) + } + } + } + + // In-memory representation of the browser history for this session + private val historyStates = mutableListOf() + private var historyIndex = -1 // Index of the current state in historyStates + + private val processor: Job + + init { + window.addEventListener("popstate", eventListener) + processor = scope.launch { + for (event in events) { + try { + when (event) { + null -> syncFromBackstack() + else -> syncFromPopState(event) + } + } catch (c: CancellationException) { + throw c + } catch (t: Throwable) { + // One failed sync must not kill history handling for the + // rest of the session — without this, a single throwing + // serializer/interceptor/path computation would end the + // processor loop and browser back would go silent while + // the URL keeps changing natively. + EnroLog.error("WebHistoryPlugin: history sync failed", t) + } + } + } + } + + override fun onAttached(controller: EnroController) {} + + override fun onDetached(controller: EnroController) { + window.removeEventListener("popstate", eventListener) + processor.cancel() + } + + override fun onOpened(navigationHandle: NavigationHandle<*>) { + events.trySend(null) + } + + override fun onActive(navigationHandle: NavigationHandle<*>) { + events.trySend(null) + } + + override fun onClosed(navigationHandle: NavigationHandle<*>) { + events.trySend(null) + } + + /** + * Computes the URL to write to `window.history`. Uses the `@NavigationPath` + * registered against the root container's active destination. When that key + * has no path binding, the existing address-bar URL is preserved — `pushState` + * still fires (so back/forward works through `history.state`), but the + * visible URL doesn't change. That keeps bookmarkable URLs honest: only + * destinations that opt in to a path produce a path. + * + * Inner-container navigation is also invisible to the URL — see the web + * platform docs for the model. + */ + @OptIn(ExperimentalEnroApi::class) + private fun computeUrl(): String { + val rootKey = rootContainer.activeChild?.key ?: return currentUrl() + return rootContainer.controller.getPathFromNavigationKey(rootKey) ?: currentUrl() + } + + private fun currentUrl(): String { + return window.location.pathname + window.location.search + } + + /** + * Calls `history.go(delta)` and suspends until the browser delivers the + * resulting popstate, consuming that echo. `history.go` is asynchronous — + * the previous implementation `delay(1)`-ed and hoped, which raced the + * traversal (corrupting the history position) and let the echo arrive + * after suppression was lifted, where it was processed as a second + * user back. The timeout is a safety valve for browsers that elide the + * event (e.g. a no-op traversal at a history boundary). + */ + private suspend fun traverse(delta: Int) { + if (delta == 0) return + val deferred = CompletableDeferred() + pendingTraversal = deferred + window.history.go(delta) + try { + withTimeout(TRAVERSAL_TIMEOUT_MS) { deferred.await() } + } catch (t: TimeoutCancellationException) { + EnroLog.warn("WebHistoryPlugin: history traversal ($delta) produced no popstate within ${TRAVERSAL_TIMEOUT_MS}ms") + pendingTraversal = null + } + } + + @OptIn(ExperimentalWasmJsInterop::class) + private fun decodeState(state: JsAny): ContainerNode? { + return runCatching { + EnroController.jsonConfiguration.decodeFromString(state.toString()) + }.onFailure { t -> + EnroLog.warn("WebHistoryPlugin: failed to decode history state (ignoring entry): ${t.message}") + }.getOrNull() + } + + /** + * The browser navigated (user back/forward): drive the backstack to match + * the entry's recorded state. When a recorded state can't be applied (an + * interceptor or EmptyBehavior refuses the close, or the app rewrote the + * backstack concurrently), step past it — bounded, rather than blind-firing + * `history.back()` and re-entering through the listener. + */ + @OptIn(ExperimentalWasmJsInterop::class) + private suspend fun syncFromPopState(event: PopStateEvent) { + // popstate without a state payload (manual address-bar edit, cross-origin + // nav). Under root-only routing we can't safely restore a sensible app + // state from URL alone — no-op and let the user reload if they want the + // URL to take effect. + val rawState = event.state ?: return + var poppedState = decodeState(rawState) + ?: return restoreFromUrl() + + var attempts = 0 + while (attempts < MAX_TRAVERSAL_ATTEMPTS) { + attempts++ + val currentState = createNodeFor(rootContainer) + if (currentState == poppedState) break + applyNodeFor(rootContainer, poppedState) + if (createNodeFor(rootContainer) == poppedState) break + // The recorded state didn't take — step one entry further back and + // try that one instead. + traverse(-1) + val nextRaw = window.history.state ?: return + poppedState = decodeState(nextRaw) + ?: return restoreFromUrl() + } + + val poppedIndex = historyStates.indexOfFirst { it == poppedState } + if (poppedIndex != -1) { + historyIndex = poppedIndex + } else { + historyStates.add(poppedState) + historyIndex = historyStates.lastIndex + } + } + + /** + * Fallback for a history entry whose recorded state can't be decoded — + * typically an entry written by an older build of the app whose + * serialization no longer matches (stale tab history survives deploys), + * or a metadata value that doesn't round-trip. Resolves the entry's URL + * through the controller's path bindings instead — degraded (single + * entry, same semantics as a cold-load deep link) but functional — and + * self-heals the entry by overwriting its unreadable state with the + * freshly serialized equivalent so the next visit decodes normally. + */ + @OptIn(ExperimentalWasmJsInterop::class) + private suspend fun restoreFromUrl() { + val fallback = rootContainer.controller.getBackstackFromPath(currentUrl()) + if (fallback == null) { + EnroLog.warn( + "WebHistoryPlugin: history entry state was unreadable and its URL " + + "('${currentUrl()}') has no path binding — leaving app state unchanged" + ) + return + } + applyNodeFor(rootContainer, ContainerNode( + containerKey = rootContainer.container.key, + backstack = fallback, + children = emptyList(), + )) + val currentState = createNodeFor(rootContainer) + val serializedCurrentState = serializeForHistory(currentState).toJsString() + window.history.replaceState(serializedCurrentState, "", computeUrl()) + val index = historyStates.indexOfFirst { it == currentState } + if (index != -1) { + historyIndex = index + } else { + historyStates.add(currentState) + historyIndex = historyStates.lastIndex + } + } + + /** + * The backstack changed (open/active/close): mirror it into browser history. + */ + /** + * Serializes [state] for storage in `history.state`, verifying the result + * actually decodes. Encode-and-decode-back is cheap insurance against + * serialization shapes kotlinx mishandles (see the discriminator-mode + * note on SerializerRepository.jsonConfiguration for the class of bug + * this guards against). + * + * Verification failure is a hard error — writing a state that can't + * restore would silently break browser back for the entry, and degrading + * (e.g. stripping metadata) would silently lose data such as + * result-channel wiring, which is worse than failing loudly. The error + * includes the live in-memory metadata: the serialized form mangles the + * offending entry, but the in-memory map still has the real keys and + * value types. + */ + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + private fun serializeForHistory(state: ContainerNode): String { + val serialized = EnroController.jsonConfiguration.encodeToString(state) + val verification = runCatching { + EnroController.jsonConfiguration.decodeFromString(serialized) + } + if (verification.isSuccess) return serialized + + val metadataDescription = state.backstack.joinToString { instance -> + val entries = instance.metadata.map.entries.joinToString { (key, value) -> + "$key=${value::class.simpleName}" + } + "${instance.key::class.simpleName}[$entries]" + } + error( + "WebHistoryPlugin: serialized history state failed round-trip verification " + + "(${verification.exceptionOrNull()?.message}). This entry would not restore on " + + "browser back, so it has NOT been written to history. In-memory metadata by " + + "instance: $metadataDescription. State: $serialized" + ) + } + + @OptIn(ExperimentalWasmJsInterop::class) + private suspend fun syncFromBackstack() { + val currentState = createNodeFor(rootContainer) + val serializedCurrentState = serializeForHistory(currentState).toJsString() + + val windowState = window.history.state?.let(::decodeState) + + val isInit = historyStates.isEmpty() && historyIndex == -1 + val isNoOp = windowState != null && windowState == currentState + val closeIndex = historyStates.indexOfLast { it == currentState } + + when { + isInit -> { + historyStates.add(currentState) + historyIndex = 0 + window.history.replaceState(serializedCurrentState, "", computeUrl()) + } + + isNoOp -> { + if (closeIndex >= 0) { + historyIndex = closeIndex + historyStates[historyIndex] = currentState + } + window.history.replaceState(serializedCurrentState, "", computeUrl()) + } + + closeIndex >= 0 -> { + // The current state exists earlier in the history: this is a close, + // pop back to that entry. + val previousIndex = historyIndex + historyIndex = closeIndex + historyStates[historyIndex] = currentState + if (closeIndex == 0) { + traverse(closeIndex - previousIndex) + window.history.replaceState(serializedCurrentState, "", computeUrl()) + } else { + // Land one short of the target and push it fresh: pruning the + // browser's forward entries so forward can't resurrect screens + // the app has closed. (Not possible at index 0 — there's no + // entry before it to land on.) + traverse(closeIndex - previousIndex - 1) + window.history.pushState(serializedCurrentState, "", computeUrl()) + // The push destroyed the browser's forward entries — drop them + // from the mirror too. + historyStates.subList(historyIndex + 1, historyStates.size).clear() + } + } + + else -> { + // A state we haven't seen. Forward navigation (push) only when the + // previous state is a prefix of the new one — i.e. entries were + // added on top of what was already there. Anything else (a root + // reset such as loading → home, or a truncate-and-open section + // switch) REPLACES the current entry: the state it overwrites is + // no longer reachable in the app and must not survive as a browser + // back target. + val previous = historyStates.getOrNull(historyIndex) + val isPush = previous == null || isSubset(old = currentState, new = previous) + historyStates.subList(historyIndex + 1, historyStates.size).clear() + if (isPush) { + historyStates.add(currentState) + historyIndex = historyStates.lastIndex + window.history.pushState(serializedCurrentState, "", computeUrl()) + } else { + historyStates[historyIndex] = currentState + window.history.replaceState(serializedCurrentState, "", computeUrl()) + } + } + } + } + + private companion object { + const val TRAVERSAL_TIMEOUT_MS = 250L + const val MAX_TRAVERSAL_ATTEMPTS = 10 + } +} + + +@Serializable +internal data class ContainerNode( + val containerKey: NavigationContainer.Key, + val backstack: NavigationBackstack, + val children: List, +) { + override fun toString(): String { + val content = "backstack = [${backstack.joinToString { it.navigationKey.toString() }}],\n" + + "children = [${children.joinToString { it.toString() }}],\n" + return buildString { + appendLine("ContainerNode(") + content.lines().forEach { + appendLine(it.prependIndent(" ")) + } + append(")") + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null) return false + if (other::class != this::class) return false + + other as ContainerNode + + if (containerKey != other.containerKey) return false + if (backstack.map { it.id } != other.backstack.map { it.id }) return false + + val filteredChildren = + children.filter { it.backstack.isNotEmpty() }.sortedBy { it.containerKey.name } + val otherFilteredChildren = + other.children.filter { it.backstack.isNotEmpty() }.sortedBy { it.containerKey.name } + if (filteredChildren.size != otherFilteredChildren.size) return false + filteredChildren.forEachIndexed { index, child -> + if (child != otherFilteredChildren[index]) return false + } + + return true + } + + override fun hashCode(): Int { + var result = containerKey.hashCode() + result = 31 * result + backstack.map { it.id }.hashCode() + result = 31 * result + children.filter { it.backstack.isNotEmpty() } + .sortedBy { it.containerKey.name }.hashCode() + return result + } +} + +internal fun createNodeFor( + container: ContainerContext, +): ContainerNode { + return ContainerNode( + containerKey = container.container.key, + backstack = container.container.backstack, + children = emptyList(), + ) +} + +internal suspend fun applyNodeFor( + container: ContainerContext, + node: ContainerNode, +) { + if (container.container.backstack != node.backstack) { + container.container.updateBackstack(container) { node.backstack } + } + // If the backstack is empty, we don't need to do anything else, + // so can return early, otherwise we're going to wait for the + // child context to be set before we continue + if (node.children.isEmpty()) return + val childContext = withTimeout(64) { + while (container.activeChild?.instance?.id != node.backstack.lastOrNull()?.id) { + yield() + } + container.activeChild + } + if (childContext == null) { + EnroLog.warn("WebHistoryPlugin: failed to restore child container while applying popped state") + return + } + val containers = childContext.children + .associateBy { it.container.key } + .toMutableMap() + + node.children.forEach { childNode -> + val child = containers[childNode.containerKey] + if (child != null) { + applyNodeFor(child, childNode) + } + containers.remove(childNode.containerKey) + } + containers.forEach { (_, child) -> + child.container.updateBackstack(child) { emptyBackstack() } + } +} + +/** + * True when [new] is a prefix-subset of [old] — i.e. [new] contains no entries + * that aren't already in [old], in the same order from the root. Used to + * distinguish a genuine forward push (the previous state is a subset of the + * next) from a replacement (entries were swapped out in a single transition). + */ +internal fun isSubset(old: ContainerNode, new: ContainerNode): Boolean { + fun isNodeSubset(oldNode: ContainerNode, newNode: ContainerNode): Boolean { + if (oldNode.containerKey != newNode.containerKey) { + return false + } + + val oldInstructionIds = oldNode.backstack.map { it.id } + val newInstructionIds = newNode.backstack.map { it.id } + + // Check if the new backstack is a prefix of the old backstack + if (!newInstructionIds.zip(oldInstructionIds) + .all { it.first == it.second } || newInstructionIds.size > oldInstructionIds.size + ) { + return false + } + + val oldChildrenSorted = oldNode.children.sortedBy { it.containerKey.name } + val newChildrenSorted = newNode.children.sortedBy { it.containerKey.name } + + if (newChildrenSorted.size > oldChildrenSorted.size) return false + + for (i in newChildrenSorted.indices) { + val matchingOldChild = oldChildrenSorted.getOrNull(i) + if (matchingOldChild == null || !isNodeSubset(matchingOldChild, newChildrenSorted[i])) { + return false + } + } + return true + } + + // We need to find a path in the old tree that matches the structure of the new tree + fun findMatchInOld(oldRoot: ContainerNode, newRoot: ContainerNode): Boolean { + if (oldRoot.containerKey == newRoot.containerKey && isNodeSubset(oldRoot, newRoot)) { + if (newRoot.children.isEmpty()) return true + return newRoot.children.all { newChild -> + oldRoot.children.any { oldChild -> findMatchInOld(oldChild, newChild) } + } + } + return oldRoot.children.any { findMatchInOld(it, newRoot) } + } + + return findMatchInOld(old, new) +} + +/** + * Experimental browser-based back handling + */ +@ExperimentalEnroApi +@Composable +public fun InstallWebHistoryPlugin( + container: NavigationContainerState, +) { + LaunchedEffect(Unit) { + container.context.controller.addModule( + createNavigationModule { + plugin(WebHistoryPlugin( + window = window, + rootContainer = container.context, + )) + } + ) + } +} diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.wasmJs.kt new file mode 100644 index 000000000..f2091afce --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.wasmJs.kt @@ -0,0 +1,9 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun rememberShouldRemoveViewModelStoreCallback(): () -> Boolean { + // On wasmJs, always remove ViewModelStore when destination is removed + return { true } +} \ No newline at end of file diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/rememberInitialBackstackFromUrl.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/rememberInitialBackstackFromUrl.kt new file mode 100644 index 000000000..d22174a79 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/rememberInitialBackstackFromUrl.kt @@ -0,0 +1,40 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.enro.EnroController +import dev.enro.NavigationBackstack +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.path.getBackstackFromPath +import kotlinx.browser.window + +/** + * Reads the current `window.location` once at composition and tries to resolve it + * to a [NavigationBackstack] using the controller's path bindings. If no binding + * matches (or no [EnroController] is installed yet), returns [default] instead. + * + * Intended to be used as the `backstack` argument of `rememberNavigationContainer` + * so that opening a deep-link URL on a cold load lands on the right destination: + * + * ```kotlin + * EnroBrowserContent { + * val container = rememberNavigationContainer( + * backstack = rememberInitialBackstackFromUrl { + * backstackOf(Home.asInstance()) + * }, + * ) + * InstallWebHistoryPlugin(container) + * NavigationDisplay(container) + * } + * ``` + */ +@ExperimentalEnroApi +@Composable +public fun rememberInitialBackstackFromUrl( + default: () -> NavigationBackstack, +): NavigationBackstack { + return remember { + val path = window.location.pathname + window.location.search + EnroController.instance?.getBackstackFromPath(path) ?: default() + } +} diff --git a/enro-runtime/src/wasmJsMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.wasmJs.kt b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.wasmJs.kt new file mode 100644 index 000000000..37d84cdbe --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/viewmodel/EnroViewModelFactory.wasmJs.kt @@ -0,0 +1,23 @@ +package dev.enro.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import dev.enro.NavigationHandle +import kotlin.reflect.KClass + + +public actual class EnroViewModelFactory actual constructor( + private val navigationHandle: NavigationHandle<*>, + private val delegate: ViewModelProvider.Factory, +) : ViewModelProvider.Factory { + public override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T { + NavigationHandleProvider.put(modelClass, navigationHandle) + return delegate.create(modelClass, extras).also { + NavigationHandleProvider.clear(modelClass) + } + } +} \ No newline at end of file diff --git a/enro-runtime/src/wasmJsTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt b/enro-runtime/src/wasmJsTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt new file mode 100644 index 000000000..9ad2514b3 --- /dev/null +++ b/enro-runtime/src/wasmJsTest/kotlin/dev/enro/test/platform/RobolectricHostTest.kt @@ -0,0 +1,3 @@ +package dev.enro.test.platform + +actual abstract class RobolectricHostTest actual constructor() diff --git a/enro-test/README.md b/enro-test/README.md new file mode 100644 index 000000000..bfc62de36 --- /dev/null +++ b/enro-test/README.md @@ -0,0 +1,56 @@ +# `enro-test` + +Test helpers for Enro destinations and the runtime as a whole. Lets you +exercise navigation behaviour from a unit test — without spinning up an +Android instrumentation harness — by installing a controllable +`EnroController` for the duration of the test and exposing assertion +helpers around handles, backstacks, paths, operations, and synthetic +destinations. + +Targets the same set as `enro-runtime` (Android, iOS, JVM Desktop, +WASM JS), so multiplatform code can be tested on any host the runtime +supports. + +## What's in here + +- **`runEnroTest { … }`** — installs an isolated controller per test + and tears it down afterwards. Use this as the outer wrapper for any + test that needs the runtime. +- **`TestNavigationHandle`** — a handle implementation that records + every operation executed against it. Combine with `createTestNavigationHandle(key)` + for tests of destination logic that doesn't need the full backstack. +- **Backstack / path / operation assertions** — `assertBackstackKeys`, + `assertBackstackSize`, `assertBackstackContains`, `assertPathResolvesTo`, + `assertOperationSequence(*KClass)`, `lastOperationOfType()`. +- **Synthetic destination tester** — `testSyntheticDestination(key)` / + `testSyntheticDestination(key, provider)` plus an outcome DSL + (`assertOpens`, `assertCompletesFrom`, `assertCloses`, + `assertCompletes`, `assertSideEffect`). Lets you unit-test a synthetic's + decision without rendering it. +- **Fixtures** — `NavigationContextFixtures`, `NavigationContainerFixtures`, + `NavigationKeyFixtures` for building the bits of state a test needs. +- **`installNavigationModule(module)` / `installPathBindings(vararg)`** + — one-line shortcuts for registering destinations or path bindings on + the test controller. + +## Typical usage + +```kotlin +dependencies { + testImplementation("dev.enro:enro-test:3.0.0-beta01") +} +``` + +```kotlin +@Test +fun `profile button opens profile destination`() = runEnroTest { + val handle = createTestNavigationHandle(HomeKey) + HomeViewModel(handle).onProfileClicked() + + handle.assertOpened { it.key.userId == "user-123" } +} +``` + +For end-to-end Compose tests of `NavigationDisplay` and friends, pair +this module with `androidx.compose.ui:ui-test` and `runComposeUiTest` +— see `enro-runtime`'s `SceneIntegrationTests` for examples. diff --git a/enro-test/build.gradle b/enro-test/build.gradle deleted file mode 100644 index 806c6fac8..000000000 --- a/enro-test/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -androidLibrary() -publishAndroidModule("dev.enro", "enro-test") - -dependencies { - releaseApi "dev.enro:enro-core:$versionName" - debugApi project(":enro-core") - - implementation deps.androidx.core - implementation deps.androidx.appcompat - - implementation deps.testing.junit - implementation deps.testing.androidx.runner - implementation deps.testing.androidx.core - implementation deps.testing.androidx.espresso - //noinspection FragmentGradleConfiguration - implementation deps.testing.androidx.fragment -} - -afterEvaluate { - tasks.findByName("preReleaseBuild") - .dependsOn(":enro-core:publishToMavenLocal") -} \ No newline at end of file diff --git a/enro-test/build.gradle.kts b/enro-test/build.gradle.kts new file mode 100644 index 000000000..857266821 --- /dev/null +++ b/enro-test/build.gradle.kts @@ -0,0 +1,45 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("configure-library") + id("configure-publishing") + id("configure-compose") +} + +tasks.withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xfriend-paths=../enro-core/src/main") + } +} + +kotlin { + explicitApi = ExplicitApiMode.Disabled + sourceSets { + desktopMain.dependencies { + } + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.compose.lifecycle) + implementation(libs.androidx.viewmodel) + implementation(libs.compose.viewmodel) + implementation(libs.androidx.savedState) + api("dev.enro:enro-runtime:${project.enroVersionName}") + } + androidMain.dependencies { + + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + + implementation(libs.testing.junit) + implementation(libs.testing.androidx.runner) + implementation(libs.testing.androidx.core) + implementation(libs.testing.androidx.espresso) + //noinspection FragmentGradleConfiguration + implementation(libs.testing.androidx.fragment) + + } + } +} + diff --git a/enro-test/src/androidMain/AndroidManifest.xml b/enro-test/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..227314eeb --- /dev/null +++ b/enro-test/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/enro-test/src/androidMain/kotlin/dev/enro/test/EnroTestRule.kt b/enro-test/src/androidMain/kotlin/dev/enro/test/EnroTestRule.kt new file mode 100644 index 000000000..e0bb56902 --- /dev/null +++ b/enro-test/src/androidMain/kotlin/dev/enro/test/EnroTestRule.kt @@ -0,0 +1,32 @@ +package dev.enro.test + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * The EnroTestRule can be used in both pure JVM based unit tests and instrumented tests that run on devices. + * + * In both cases, this rule is designed to install a NavigationController that is accessible by + * Enro's test extensions, and allow [TestNavigationHandles] to be created, which will record + * navigation instructions that are made against the navigation handle. The recorded navigation + * instructions can then be asserted on, in particular by using extensions such as + * [expectOpenInstruction], [assertActive], [assertClosed], [assertOpen] and others. + * + * When EnroTestRule is used in an instrumented test, it will *prevent* regular navigation from + * occurring, and is designed for testing individual screens in isolation from one another. If you + * want to perform "real" navigation in instrumented tests, you do not need any Enro test extensions. + * + * If you have other TestRules, particularly those that launch Activities or Fragments, you may need + * to order this TestRule as the first in the sequence, as the rule will need to be executed before + * an Activity or Fragment under test has been instantiated. + */ +class EnroTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + runEnroTest { base.evaluate() } + } + } + } +} \ No newline at end of file diff --git a/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.assertClosedWithResult.kt b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.assertClosedWithResult.kt new file mode 100644 index 000000000..24da1a9b7 --- /dev/null +++ b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.assertClosedWithResult.kt @@ -0,0 +1,58 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.test.assertClosed +import dev.enro.test.assertNotClosed +import kotlin.reflect.KClass + +@Deprecated("Use assertCompleted") +fun TestNavigationHandle.assertClosedWithResult( + type: KClass, + predicate: (T) -> Boolean = { true }, +) : T = assertCompleted(type, predicate) + +/** + * Asserts that the NavigationHandle has executed a Close.WithResult instruction, and that the result matches the provided predicate + * + * @return the result of the Close.WithResult instruction + */ +@Deprecated("Use assertCompleted") +inline fun TestNavigationHandle.assertClosedWithResult( + noinline predicate: (T) -> Boolean = { true }, +) : T = assertCompleted(predicate) + +/** + * Asserts that the NavigationHandle has executed a Close.WithResult instruction, and that the result matches the provided predicate + * + * @return the result of the Close.WithResult instruction + */ +@Deprecated("Use assertCompleted") +inline fun TestNavigationHandle.assertClosedWithResult( + result: T, +) : T = assertCompleted(T::class) { it == result} + + +/** + * Asserts that the NavigationHandle has executed a Close.WithResult instruction, and that the result is equal to [expected] + */ +@Deprecated("Use assertCompleted") +fun TestNavigationHandle.assertClosedWithResult( + type: KClass, + expected: T, +): T = assertCompleted(type, expected) + + +/** + * Asserts that the NavigationHandle has not executed a Close.WithResult instruction + */ +@Deprecated("Use assertNotClosed and assertNotCompleted") +fun TestNavigationHandle.assertNotClosedWithResult() { + assertNotCompleted() + assertNotClosed() +} + + +@Deprecated("Use assertNotClosed") +fun TestNavigationHandle.assertClosedWithNoResult() { + assertClosed() +} \ No newline at end of file diff --git a/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.assertResults.kt b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.assertResults.kt new file mode 100644 index 000000000..3ff5388d2 --- /dev/null +++ b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.assertResults.kt @@ -0,0 +1,29 @@ +package dev.enro.test + + + +@Deprecated("Use assertClosedWithResult") +@Suppress("UNCHECKED_CAST") +fun TestNavigationHandle<*>.assertResultDelivered(predicate: (T) -> Boolean): T { + return assertCompleted { + @Suppress("SafeCastWithReturn") + it as? T ?: return@assertCompleted false + predicate(it) + } as T +} + +@Deprecated("Use assertClosedWithResult") +@Suppress("UNCHECKED_CAST") +fun TestNavigationHandle<*>.assertResultDelivered(expected: T): T { + return assertCompleted(expected) as T +} + +@Deprecated("Use assertClosedWithResult") +inline fun TestNavigationHandle<*>.assertResultDelivered(): T { + return assertResultDelivered { true } +} + +@Deprecated("Use assertNotClosedWithResult") +fun TestNavigationHandle<*>.assertNoResultDelivered() { + assertNotCompleted() +} \ No newline at end of file diff --git a/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.expectInstruction.kt b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.expectInstruction.kt new file mode 100644 index 000000000..f3544dc48 --- /dev/null +++ b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/TestNavigationHandle.expectInstruction.kt @@ -0,0 +1,75 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import kotlin.reflect.KClass + +@Deprecated("Use assertClosed instead") +fun TestNavigationHandle<*>.expectCloseInstruction() { + @Suppress("UNCHECKED_CAST") + this as TestNavigationHandle + assertClosed() +} +@Deprecated("Use assertOpened instead") +fun TestNavigationHandle<*>.expectOpenInstruction( + type: Class, + filter: (T) -> Boolean = { true } +) { + expectOpenInstruction(type.kotlin, filter) +} + +/** + * Asserts that the NavigationHandle has received a NavigationInstruction with a NavigationKey that is assignable to type [T] and + * which matches the provided filter, and then returns that NavigationInstruction. + */ +@Deprecated("Use assertOpened instead") +fun TestNavigationHandle<*>.expectOpenInstruction( + type: KClass, + filter: (T) -> Boolean = { true } +): NavigationKey.Instance { + val openInstructions = operations.filterIsInstance>() + if (openInstructions.isEmpty()) { + enroAssertionError("NavigationHandle has not executed any NavigationInstruction.Open") + } + val instructionsWithCorrectType = openInstructions.filter { + type.isInstance(it.instance.key) + } + if (instructionsWithCorrectType.isEmpty()) { + enroAssertionError("NavigationHandle has not executed any NavigationInstruction.Open with a NavigationKey of type $type") + } + val instruction = instructionsWithCorrectType.lastOrNull { + runCatching { + @Suppress("UNCHECKED_CAST") + filter(it.instance.key as T) + }.getOrDefault(false) + } + if (instruction == null) { + enroAssertionError("NavigationHandle has not executed any NavigationInstruction.Open with a NavigationKey of type $type that matches the provided filter") + } + @Suppress("UNCHECKED_CAST") + return instruction.instance as NavigationKey.Instance +} + + +/** + * Asserts that the NavigationHandle has received a NavigationInstruction with a NavigationKey that is equal to the provided + * NavigationKey [key], and then returns that NavigationInstruction. + */ +@Deprecated("Use assertOpened instead") +fun TestNavigationHandle<*>.expectOpenInstruction( + key: T, + disambiguation: Unit = Unit // to differentiate from the other expectOpenInstruction method +): NavigationKey.Instance { + return expectOpenInstruction(key::class) { it == key } +} + +/** + * Asserts that the NavigationHandle has received a NavigationInstruction with a NavigationKey that is assignable to type [T] and + * which matches the provided filter, and then returns that NavigationInstruction. + */ +@Deprecated("Use assertOpened instead") +inline fun TestNavigationHandle<*>.expectOpenInstruction( + noinline filter: (T) -> Boolean = { true } +): NavigationKey.Instance { + return expectOpenInstruction(T::class, filter) +} diff --git a/enro-test/src/androidMain/kotlin/dev/enro/test/compat/putNavigationHandleForViewModel.kt b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/putNavigationHandleForViewModel.kt new file mode 100644 index 000000000..b2e6b454c --- /dev/null +++ b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/putNavigationHandleForViewModel.kt @@ -0,0 +1,22 @@ +package dev.enro.test.extensions + +import androidx.lifecycle.ViewModel +import dev.enro.NavigationKey +import dev.enro.test.TestNavigationHandle +import dev.enro.test.putNavigationHandleForViewModel as realPutNavigationHandleForViewModel +import kotlin.reflect.KClass + +@Deprecated("Use dev.enro.test.putNavigationHandleForViewModel") +inline fun putNavigationHandleForViewModel( + key: K, +) : TestNavigationHandle { + return realPutNavigationHandleForViewModel(T::class, key) +} + +@Deprecated("Use dev.enro.test.putNavigationHandleForViewModel") +fun putNavigationHandleForViewModel( + viewModel: KClass, + key: K, +) : TestNavigationHandle { + return realPutNavigationHandleForViewModel(viewModel, key) +} \ No newline at end of file diff --git a/enro-test/src/androidMain/kotlin/dev/enro/test/compat/sendResultForTest.kt b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/sendResultForTest.kt new file mode 100644 index 000000000..5154eba26 --- /dev/null +++ b/enro-test/src/androidMain/kotlin/dev/enro/test/compat/sendResultForTest.kt @@ -0,0 +1,16 @@ +package dev.enro.test.extensions + +import dev.enro.NavigationKey +import dev.enro.asCompleteOperation +import dev.enro.test.fixtures.NavigationContainerFixtures.ContainerFixtureKey + +fun NavigationKey.Instance>.sendResultForTest( + result: T +) { + val containerFixture = metadata.get(ContainerFixtureKey) + val completeOperation = asCompleteOperation(result) + when (containerFixture) { + null -> completeOperation.registerResult() + else -> containerFixture.execute(completeOperation) + } +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTest.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTest.kt new file mode 100644 index 000000000..149e296b5 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTest.kt @@ -0,0 +1,62 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro.test + +import dev.enro.EnroController + +object EnroTest { + + private var navigationController: EnroController? = null + private var wasInstalled = false + + // TODO: Would be nice to add functionality to temporarily install a NavigationModule for a particular test + fun installNavigationController() { + if (navigationController != null) { + uninstallNavigationController() + } + + // Reuse an already-installed controller if one is present — this is the + // path Android-instrumented tests take, where the test Application has + // already installed Enro via ActivityPlugin before the test rule runs. + navigationController = EnroController.instance + if (navigationController != null) { + wasInstalled = true + return + } + + // For commonTest running on desktop/iOS/wasm — and for unit tests that + // need a fresh controller without going through an Application — we + // install with a null platform reference. EnroController.platformReference + // is only consumed by Android-specific runtime code (ActivityPlugin, + // EnroLog.android), all of which null-check before use, so this is safe. + navigationController = EnroController().apply { + install(reference = null) + } + wasInstalled = false + } + + fun uninstallNavigationController() { + // Only uninstall if we created it + if (!wasInstalled) { + navigationController?.uninstall() + } + navigationController = null + + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + dev.enro.viewmodel.NavigationHandleProvider.clearAllForTest() + } + + fun getCurrentNavigationController(): EnroController { + return navigationController ?: throw IllegalStateException("NavigationController is not installed") + } + + fun disableAnimations(controller: EnroController) { + // Animation control might need to be handled differently in the new API + // For now, we'll leave this as a no-op + } + + fun enableAnimations(controller: EnroController) { + // Animation control might need to be handled differently in the new API + // For now, we'll leave this as a no-op + } +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTestAssertions.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTestAssertions.kt new file mode 100644 index 000000000..9d2af2782 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTestAssertions.kt @@ -0,0 +1,127 @@ +package dev.enro.test + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import kotlin.jvm.JvmName + +class EnroTestAssertionException(message: String) : AssertionError(message) + +@PublishedApi +internal fun enroAssertionError(message: String): Nothing { + throw EnroTestAssertionException(message) +} + +@OptIn(ExperimentalContracts::class) +@PublishedApi +internal fun enroAssert(condition: Boolean, lazyMessage: () -> String) { + contract { + returns() implies condition + } + if (!condition) { + throw EnroTestAssertionException(lazyMessage()) + } +} + +data class EnroAssertionContext( + val expected: Any?, + val actual: Any?, +) + +@PublishedApi +internal fun T.shouldBeEqualTo(expected: Any?, message: EnroAssertionContext.() -> String): T { + if (this != expected) { + val assertionContext = EnroAssertionContext( + expected = expected, + actual = this + ) + throw EnroTestAssertionException(message(assertionContext)) + } + return this +} + +@PublishedApi +internal fun T.shouldNotBeEqualTo(expected: Any?, message: EnroAssertionContext.() -> String): T { + if (this == expected) { + val assertionContext = EnroAssertionContext( + expected = expected, + actual = this + ) + throw EnroTestAssertionException(message(assertionContext)) + } + return this +} + +@PublishedApi +internal fun T.shouldMatchPredicate(predicate: (T) -> Boolean, message: EnroAssertionContext.() -> String): T { + val predicateResult = predicate(this) + if (!predicateResult) { + val assertionContext = EnroAssertionContext( + expected = null, + actual = this + ) + throw EnroTestAssertionException(message(assertionContext)) + } + return this +} + +@PublishedApi +internal fun T.shouldNotMatchPredicate(predicate: (T) -> Boolean, message: EnroAssertionContext.() -> String): T { + val predicateResult = predicate(this) + if (predicateResult) { + val assertionContext = EnroAssertionContext( + expected = null, + actual = this + ) + throw EnroTestAssertionException(message(assertionContext)) + } + return this +} + +@PublishedApi +@JvmName("nullableShouldMatchPredicateNotNull") +internal fun T?.shouldMatchPredicateNotNull(predicate: (T) -> Boolean, message: EnroAssertionContext.() -> String): T { + if (this == null) { + throw EnroTestAssertionException("Expected a non-null value, but was null.") + } + + val predicateResult = predicate(this) + if (!predicateResult) { + val assertionContext = EnroAssertionContext( + expected = null, + actual = this + ) + throw EnroTestAssertionException(message(assertionContext)) + } + return this +} + +@PublishedApi +@JvmName("nullableShouldNotMatchPredicate") +internal fun T?.shouldNotMatchPredicate( + predicate: (T?) -> Boolean, + message: EnroAssertionContext.() -> String, +): T? { + val predicateResult = predicate(this) + if (predicateResult) { + val assertionContext = EnroAssertionContext( + expected = null, + actual = this + ) + throw EnroTestAssertionException(message(assertionContext)) + } + return this +} + +@PublishedApi +@JvmName("nullableShouldBeInstanceOf") +internal inline fun Any?.shouldBeInstanceOf(): T { + if (this == null) { + throw EnroTestAssertionException("Expected a non-null value, but was null.") + } + + val isCorrectType = this is T + if (!isCorrectType) { + throw EnroTestAssertionException("Expected type ${T::class.simpleName}, but was ${this::class.simpleName}") + } + return this as T +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContainer.assertBackstack.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContainer.assertBackstack.kt new file mode 100644 index 000000000..5d7509170 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContainer.assertBackstack.kt @@ -0,0 +1,92 @@ +package dev.enro.test + +import dev.enro.NavigationContainer +import dev.enro.NavigationKey +import dev.enro.ui.NavigationContainerState +import kotlin.reflect.KClass + +/** + * Asserts the container's backstack has exactly [expected] entries. + */ +public fun NavigationContainer.assertBackstackSize(expected: Int) { + enroAssert(backstack.size == expected) { + "Expected backstack to have $expected entries, but had ${backstack.size}: ${backstack.map { it.key }}" + } +} + +public fun NavigationContainerState.assertBackstackSize(expected: Int): Unit = + container.assertBackstackSize(expected) + +/** + * Asserts the container's backstack contains exactly the given [keys] in order. + * Matches on key equality, not instance identity. + */ +public fun NavigationContainer.assertBackstackKeys(vararg keys: NavigationKey) { + val actual = backstack.map { it.key } + val expected = keys.toList() + enroAssert(actual == expected) { + "Expected backstack keys to be $expected, but was $actual" + } +} + +public fun NavigationContainerState.assertBackstackKeys(vararg keys: NavigationKey): Unit = + container.assertBackstackKeys(*keys) + +/** + * Asserts the backstack contains at least one entry of [keyType], optionally + * matching [predicate]. Returns the first matching instance for further + * assertions. + */ +public fun NavigationContainer.assertBackstackContains( + keyType: KClass, + predicate: (T) -> Boolean = { true }, +): NavigationKey.Instance { + val matching = backstack + .filter { keyType.isInstance(it.key) } + .filter { + @Suppress("UNCHECKED_CAST") + predicate(it.key as T) + } + enroAssert(matching.isNotEmpty()) { + "Expected backstack to contain a ${keyType.simpleName} matching the predicate, " + + "but backstack was: ${backstack.map { it.key }}" + } + @Suppress("UNCHECKED_CAST") + return matching.first() as NavigationKey.Instance +} + +public inline fun NavigationContainer.assertBackstackContains( + noinline predicate: (T) -> Boolean = { true }, +): NavigationKey.Instance = assertBackstackContains(T::class, predicate) + +public inline fun NavigationContainerState.assertBackstackContains( + noinline predicate: (T) -> Boolean = { true }, +): NavigationKey.Instance = container.assertBackstackContains(T::class, predicate) + +/** + * Asserts the backstack does NOT contain any entry of [keyType]. + */ +public fun NavigationContainer.assertBackstackDoesNotContain(keyType: KClass) { + val matching = backstack.filter { keyType.isInstance(it.key) } + enroAssert(matching.isEmpty()) { + "Expected backstack to not contain any ${keyType.simpleName}, " + + "but found ${matching.size}: ${matching.map { it.key }}" + } +} + +public inline fun NavigationContainer.assertBackstackDoesNotContain(): Unit = + assertBackstackDoesNotContain(T::class) + +public inline fun NavigationContainerState.assertBackstackDoesNotContain(): Unit = + container.assertBackstackDoesNotContain(T::class) + +/** + * Asserts the backstack is empty. + */ +public fun NavigationContainer.assertBackstackEmpty() { + enroAssert(backstack.isEmpty()) { + "Expected backstack to be empty, but had ${backstack.size} entries: ${backstack.map { it.key }}" + } +} + +public fun NavigationContainerState.assertBackstackEmpty(): Unit = container.assertBackstackEmpty() diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContainerState.assertActive.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContainerState.assertActive.kt new file mode 100644 index 000000000..10536c482 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContainerState.assertActive.kt @@ -0,0 +1,40 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.ui.NavigationContainerState +import kotlin.reflect.KClass + +fun NavigationContainerState.assertActive( + keyType: KClass, + predicate: (T) -> Boolean = { true } +) : NavigationKey.Instance { + val activeInstance = backstack.lastOrNull() + activeInstance.shouldNotBeEqualTo(null) { + "Expected $keyType to be the active NavigationKey, but the backstack is empty" + } + + val activeKey = requireNotNull(activeInstance).key + enroAssert(keyType.isInstance(activeKey)) { + "Expected key of type ${keyType.simpleName} to be the active NavigationKey, but found $activeKey instead" + } + @Suppress("UNCHECKED_CAST") + activeKey as T + + activeKey.shouldMatchPredicate(predicate) { + "Expected $activeKey to match the provided predicate, but it did not" + } + @Suppress("UNCHECKED_CAST") + return activeInstance as NavigationKey.Instance +} + +fun NavigationContainerState.assertActive( + key: T, +) : NavigationKey.Instance { + return assertActive(key::class) { it == key } +} + +inline fun NavigationContainerState.assertActive( + noinline predicate: (T) -> Boolean = { true } +) : NavigationKey.Instance { + return assertActive(T::class, predicate) +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContext.assertPath.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContext.assertPath.kt new file mode 100644 index 000000000..b323a1792 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/NavigationContext.assertPath.kt @@ -0,0 +1,83 @@ +@file:OptIn(ExperimentalEnroApi::class) + +package dev.enro.test + +import dev.enro.EnroController +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.path.getNavigationKeyFromPath +import dev.enro.path.getPathFromNavigationKey +import kotlin.reflect.KClass + +/** + * Asserts [path] resolves to a [NavigationKey] of type [keyType] via the + * controller's registered path bindings, optionally matching [predicate]. + * Returns the resolved key for further assertions. + */ +public fun EnroController.assertPathResolvesTo( + path: String, + keyType: KClass, + predicate: (T) -> Boolean = { true }, +): T { + val resolved = getNavigationKeyFromPath(path) + resolved.shouldNotBeEqualTo(null) { + "Expected $path to resolve to ${keyType.simpleName}, but no registered path binding matched" + } + enroAssert(keyType.isInstance(resolved)) { + "Expected $path to resolve to ${keyType.simpleName}, but resolved to ${resolved!!::class.simpleName} ($resolved)" + } + @Suppress("UNCHECKED_CAST") + val typed = resolved as T + typed.shouldMatchPredicate(predicate) { + "Expected $path to resolve to a ${keyType.simpleName} matching the predicate, but got: $typed" + } + return typed +} + +public inline fun EnroController.assertPathResolvesTo( + path: String, + noinline predicate: (T) -> Boolean = { true }, +): T = assertPathResolvesTo(path, T::class, predicate) + +public inline fun NavigationContext.assertPathResolvesTo( + path: String, + noinline predicate: (T) -> Boolean = { true }, +): T = controller.assertPathResolvesTo(path, T::class, predicate) + +/** + * Asserts [path] does NOT resolve to any [NavigationKey] via the controller's + * registered path bindings. + */ +public fun EnroController.assertPathDoesNotResolve(path: String) { + val resolved = runCatching { getNavigationKeyFromPath(path) }.getOrNull() + enroAssert(resolved == null) { + "Expected $path to not resolve, but it resolved to $resolved" + } +} + +public fun NavigationContext.assertPathDoesNotResolve(path: String): Unit = + controller.assertPathDoesNotResolve(path) + +/** + * Asserts that serialising [key] via the controller's registered path bindings + * produces exactly [expectedPath]. Tests the reverse direction of + * `assertPathResolvesTo`. + */ +public fun EnroController.assertPathFor( + key: NavigationKey, + expectedPath: String, +) { + val actual = getPathFromNavigationKey(key) + actual.shouldNotBeEqualTo(null) { + "Expected $key to serialise to $expectedPath, but no registered path binding matched" + } + actual.shouldBeEqualTo(expectedPath) { + "Expected $key to serialise to $expectedPath, but produced $actual" + } +} + +public fun NavigationContext.assertPathFor( + key: NavigationKey, + expectedPath: String, +): Unit = controller.assertPathFor(key, expectedPath) diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/SyntheticDestinationTester.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/SyntheticDestinationTester.kt new file mode 100644 index 000000000..4eb824aae --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/SyntheticDestinationTester.kt @@ -0,0 +1,141 @@ +@file:OptIn(AdvancedEnroApi::class) + +package dev.enro.test + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.asInstance +import dev.enro.test.fixtures.NavigationContainerFixtures +import dev.enro.test.fixtures.NavigationContextFixtures +import dev.enro.ui.NavigationDestinationProvider +import dev.enro.ui.destinations.SyntheticOutcome +import dev.enro.ui.destinations.peekSyntheticOutcome + +/** + * Executes the synthetic destination bound to [key] on the currently-installed + * navigation controller and returns the [SyntheticOutcome] the block decided on. + * + * Use this when the synthetic is registered through a `NavigationModule` on the + * controller — typical setup is `runEnroTest { MyComponent.installNavigationController(this); testSyntheticDestination(MyKey) }`. + * + * For unit-testing a synthetic's logic without installing a component, pass the + * provider directly: see the `testSyntheticDestination(key, provider, ...)` overload. + * + * Throws [IllegalStateException] if [key] isn't bound to a synthetic destination + * on the current controller. + */ +public fun testSyntheticDestination( + key: K, + fromContext: NavigationContext = NavigationContextFixtures.createRootContext(), +): SyntheticOutcome { + val controller = EnroTest.getCurrentNavigationController() + return controller.peekSyntheticOutcome(key, fromContext) + ?: error( + "Key ${key::class.simpleName} is not bound to a synthetic destination on the current " + + "EnroController. Install a NavigationModule that registers the synthetic, or pass " + + "the NavigationDestinationProvider directly to the testSyntheticDestination(key, provider) overload." + ) +} + +/** + * Executes the synthetic destination bound to [provider] for [key] and returns + * the [SyntheticOutcome] the block decided on. + * + * Use this when you have a `val synthetic = syntheticDestination { ... }` you + * want to unit-test without installing a controller component — pass the provider + * value directly. + * + * Throws [IllegalStateException] if [provider] isn't a synthetic destination. + */ +public fun testSyntheticDestination( + key: K, + provider: NavigationDestinationProvider, + fromContext: NavigationContext = NavigationContextFixtures.createRootContext(), +): SyntheticOutcome { + val instance = key.asInstance() + return provider.peekSyntheticOutcome(fromContext, instance) + ?: error( + "The provided NavigationDestinationProvider for ${key::class.simpleName} is not a " + + "synthetic destination — it doesn't carry the SyntheticDestinationKey metadata. " + + "Was the provider built with `syntheticDestination { ... }`?" + ) +} + +/** + * Executes the side-effect block with default fixture context and container. + * Equivalent to calling [SyntheticOutcome.SideEffect.runWith] with freshly-built + * test fixtures — convenient when the side effect doesn't care about the + * specific context/container it's given. + */ +public fun SyntheticOutcome.SideEffect.runWith() { + val context = NavigationContextFixtures.createRootContext() + val containerState = NavigationContainerFixtures.create(parentContext = context) + runWith(context = context, container = containerState.container) +} + +/** + * Asserts the outcome is [SyntheticOutcome.Open] of [Expected], optionally + * matching [keyPredicate]. Returns the opened key for further assertions. + */ +public inline fun SyntheticOutcome.assertOpens( + keyPredicate: (Expected) -> Boolean = { true }, +): Expected { + val open = this as? SyntheticOutcome.Open + ?: enroAssertionError("Expected synthetic to open ${Expected::class.simpleName}, but outcome was $this") + val key = open.key as? Expected + ?: enroAssertionError("Expected synthetic to open ${Expected::class.simpleName}, but opened ${open.key::class.simpleName}") + if (!keyPredicate(key)) { + enroAssertionError("Synthetic opened ${Expected::class.simpleName}, but the key didn't match the predicate; got: $key") + } + return key +} + +/** + * Asserts the outcome is [SyntheticOutcome.CompleteFrom] of [Expected], optionally + * matching [keyPredicate]. Returns the forwarded key for further assertions. + */ +public inline fun SyntheticOutcome.assertCompletesFrom( + keyPredicate: (Expected) -> Boolean = { true }, +): Expected { + val completeFrom = this as? SyntheticOutcome.CompleteFrom + ?: enroAssertionError("Expected synthetic to completeFrom ${Expected::class.simpleName}, but outcome was $this") + val key = completeFrom.key as? Expected + ?: enroAssertionError("Expected synthetic to completeFrom ${Expected::class.simpleName}, but completed from ${completeFrom.key::class.simpleName}") + if (!keyPredicate(key)) { + enroAssertionError("Synthetic completed from ${Expected::class.simpleName}, but the key didn't match the predicate; got: $key") + } + return key +} + +/** + * Asserts the outcome is a close, optionally matching the silent flag. + */ +public fun SyntheticOutcome.assertCloses(silent: Boolean? = null) { + val close = this as? SyntheticOutcome.Close + ?: enroAssertionError("Expected synthetic to close, but outcome was $this") + if (silent != null && close.silent != silent) { + enroAssertionError("Expected close with silent=$silent, but got silent=${close.silent}") + } +} + +/** + * Asserts the outcome is a complete with the expected payload. Pass `null` for + * non-result synthetics that call `complete()`. + */ +public fun SyntheticOutcome.assertCompletes(expectedResult: Any?) { + val complete = this as? SyntheticOutcome.Complete + ?: enroAssertionError("Expected synthetic to complete, but outcome was $this") + if (complete.result != expectedResult) { + enroAssertionError("Expected complete with result $expectedResult, but got ${complete.result}") + } +} + +/** + * Asserts the outcome is a side effect and returns it for further assertion + * (e.g. calling [SyntheticOutcome.SideEffect.runWith] to execute it). + */ +public fun SyntheticOutcome.assertSideEffect(): SyntheticOutcome.SideEffect { + return this as? SyntheticOutcome.SideEffect + ?: enroAssertionError("Expected synthetic to produce a side effect, but outcome was $this") +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertClosed.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertClosed.kt new file mode 100644 index 000000000..49d500d89 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertClosed.kt @@ -0,0 +1,29 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation + +/** + * Asserts that the NavigationHandle's instance has been closed + */ +fun TestNavigationHandle.assertClosed() { + val last = operations.lastOrNull() + enroAssert(last != null) { + "Expected the last operation to be a close operation, but there were no operations" + } + enroAssert(last is NavigationOperation.Close<*>) { + "Expected the last operation to be a close operation, but was ${last::class.simpleName}" + } + enroAssert(last.instance.id == instance.id) { + "Expected the last operation to be a close operation for this NavigationHandle's instance ${instance.id}, but was ${last.instance.id}" + } +} + +fun TestNavigationHandle.assertNotClosed() { + val last = operations.lastOrNull() + if (last !is NavigationOperation.Close<*>) return + enroAssert(last.instance.id != instance.id) { + "Expected the last operation to not be a close operation for instance ${instance.id}" + } +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertCompleted.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertCompleted.kt new file mode 100644 index 000000000..2131c1ed0 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertCompleted.kt @@ -0,0 +1,73 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import kotlin.reflect.KClass + +fun TestNavigationHandle.assertCompleted() { + val operation = operations.lastOrNull() + enroAssert(operation != null) { + "Expected the last operation to be a complete operation, but there were no operations." + } + enroAssert(operation is NavigationOperation.Complete<*>) { + "Expected the last operation to be a complete operation, but it was ${operation::class.simpleName}" + } + enroAssert(operation.instance.id == instance.id) { + "Expected the last operation to be a complete operation for this NavigationHandle's instance, but it was for ${operation.instance.id}" + } +} + +fun TestNavigationHandle.assertCompleted( + type: KClass, + expected: R, +): R = assertCompleted(type) { it == expected } + +inline fun TestNavigationHandle.assertCompleted( + expected: R +): R = assertCompleted(R::class) { it == expected } + +inline fun TestNavigationHandle.assertCompleted( + noinline predicate: (T) -> Boolean = { true }, +): T = assertCompleted(T::class, predicate) + +fun TestNavigationHandle.assertCompleted( + type: KClass, + predicate: (T) -> Boolean = { true }, +): T { + val operation = operations.lastOrNull() + enroAssert(key is NavigationKey.WithResult<*>) { + "Expected TestNavigationHandle's NavigationKey to be a NavigationKey.WithResult, but was ${key::class.simpleName}" + } + enroAssert(operation != null) { + "Expected the last operation to be a complete operation, but there were no operations." + } + enroAssert(operation is NavigationOperation.Complete<*>) { + "Expected the last operation to be a complete operation, but it was ${operation::class.simpleName}" + } + enroAssert(operation.instance.id == instance.id) { + "Expected the last operation to be a complete operation for this NavigationHandle's instance, but it was for ${operation.instance.id}" + } + val result = operation.result + enroAssert(result != null) { + "Expected the last operation to be a complete operation with a result, but it contained a null result" + } + enroAssert(type.isInstance(result)) { + "Expected the last operation to be a complete operation with a result of type ${type::class.simpleName}, but it was ${result::class.simpleName}" + } + @Suppress("UNCHECKED_CAST") + result as T + enroAssert(predicate(result)) { + "Expected the last operation to be a complete operation with a result that matches the predicate, but it did not" + } + return result +} + +fun TestNavigationHandle<*>.assertNotCompleted() { + val last = operations.lastOrNull() + if (last !is NavigationOperation.Complete<*>) return + enroAssert(last.instance.id != instance.id) { + "Expected the last operation to not be a complete operation for instance ${instance.id}" + } +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertOpened.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertOpened.kt new file mode 100644 index 000000000..0119348db --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertOpened.kt @@ -0,0 +1,35 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation + +inline fun TestNavigationHandle<*>.assertOpened(): NavigationKey.Instance { + return assertOperationExecuted>().instance +} + +inline fun TestNavigationHandle<*>.assertOpened( + instance: NavigationKey.Instance, +): NavigationKey.Instance { + return assertOpened { it == instance } +} + +inline fun TestNavigationHandle<*>.assertOpened( + predicate: (NavigationKey.Instance) -> Boolean = { true }, +): NavigationKey.Instance { + return assertOperationExecuted> { + predicate(it.instance) + }.instance +} + +inline fun TestNavigationHandle<*>.assertOpened( + key: T, +): NavigationKey.Instance { + return assertOpened { it.key == key } +} + +fun TestNavigationHandle<*>.assertNoneOpened() { + val openInstructions = operations.filterIsInstance>() + if (openInstructions.isNotEmpty()) { + enroAssertionError("NavigationHandle should not have executed any NavigationInstruction.Open, but NavigationInstruction.Open instructions were found") + } +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertOperationExecuted.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertOperationExecuted.kt new file mode 100644 index 000000000..554134a8d --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.assertOperationExecuted.kt @@ -0,0 +1,44 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation + +/** + * Asserts that the NavigationContainer's backstack contains at least one NavigationKey.Instance that matches the + * provided predicate. + * + * @return The first NavigationKey.Instance that matches the predicate + */ +inline fun TestNavigationHandle<*>.assertOperationExecuted( + predicate: (T) -> Boolean = { true }, +): T { + operations + .filterIsInstance() + .lastOrNull { predicate(it) } + .shouldNotBeEqualTo(null) { + "TestNavigationHandle should have executed an operation matching the predicate.\n\tOperations: $operations" + } + .let { + return it!! + } +} + +/** + * Asserts that the NavigationContainer's backstack does not contain a NavigationKey.Instance that matches the provided + * predicate + */ +inline fun TestNavigationHandle<*>.assertOperationNotExecuted( + predicate: (NavigationKey.Instance) -> Boolean, +) { + operations + .filterIsInstance>() + .lastOrNull { + predicate(it.instance) + } + .shouldBeEqualTo( + null, + ) { + "NavigationHandle should not have executed an operation matching the predicate.\n\tOperations: $operations" + } +} + diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.createContainerForNavigationFlow.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.createContainerForNavigationFlow.kt new file mode 100644 index 000000000..1da8e07db --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.createContainerForNavigationFlow.kt @@ -0,0 +1,28 @@ +package dev.enro.test + +import dev.enro.NavigationKey +import dev.enro.result.flow.navigationFlow +import dev.enro.test.fixtures.NavigationContainerFixtures +import dev.enro.ui.NavigationContainerState + +private object TestFlowContainer : + NavigationKey.TransientMetadataKey(null) + +fun TestNavigationHandle<*>.createContainerForNavigationFlow(): NavigationContainerState { + val flow = navigationFlow ?: error( + "No NavigationFlow associated with this TestNavigationHandle" + ) + val existingContainer = instance.metadata.get(TestFlowContainer) + if (existingContainer != null) { + error("A NavigationContainer is already associated with this TestNavigationHandle") + } + val container = NavigationContainerFixtures.createForFlow(flow) + instance.metadata.set(TestFlowContainer, container) + return container +} + +val TestNavigationHandle<*>.containerForNavigationFlow: NavigationContainerState + get() { + return instance.metadata.get(TestFlowContainer) + ?: error("No NavigationContainer is associated with this TestNavigationHandle") + } diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.kt new file mode 100644 index 000000000..4a1c92cd9 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.kt @@ -0,0 +1,80 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro.test + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle +import dev.enro.NavigationHandle +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.asInstance + +class TestNavigationHandle( + override val instance: NavigationKey.Instance, + override val savedStateHandle: SavedStateHandle = SavedStateHandle(), +) : NavigationHandle() { + @PublishedApi + internal val operations = mutableListOf() + + override val lifecycle: LifecycleRegistry = LifecycleRegistry.createUnsafe(this).apply { + currentState = Lifecycle.State.RESUMED + } + + fun clearOperationHistory() { + operations.clear() + } + + override fun execute(operation: NavigationOperation) { + val lastOperation = operations.lastOrNull() + when (lastOperation) { + is NavigationOperation.Close<*> -> { + require(lastOperation.instance.id != instance.id) { + "Cannot execute NavigationOperation on TestNavigationHandle that is closed. If you want to continue using the TestNavigationHandle after it is closed, you need to call clearOperationHistory." + } + } + is NavigationOperation.Complete<*> -> { + require(lastOperation.instance.id != instance.id) { + "Cannot execute NavigationOperation on TestNavigationHandle that is completed. If you want to continue using the TestNavigationHandle after it is completed, you need to call clearOperationHistory." + } + } + else -> { + // this is fine, continue + } + } + when (operation) { + is NavigationOperation.AggregateOperation -> operations.addAll(operation.operations) + is NavigationOperation.Close<*> -> operations.add(operation) + is NavigationOperation.Complete<*> -> operations.add(operation) + is NavigationOperation.Open<*> -> operations.add(operation) + is NavigationOperation.SideEffect -> {} + } + } +} + +/** + * Create a TestNavigationHandle to be used in tests. + */ +fun createTestNavigationHandle( + key: T, +): TestNavigationHandle { + return createTestNavigationHandle(key.asInstance()) +} + +/** + * Create a TestNavigationHandle to be used in tests with a NavigationKey.WithMetadata. + */ +fun createTestNavigationHandle( + key: NavigationKey.WithMetadata, +): TestNavigationHandle { + return createTestNavigationHandle(key.asInstance()) +} + +/** + * Create a TestNavigationHandle to be used in tests with a NavigationKey.Instance. + */ +fun createTestNavigationHandle( + instance: NavigationKey.Instance, +): TestNavigationHandle { + return TestNavigationHandle(instance) +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.lastOperation.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.lastOperation.kt new file mode 100644 index 000000000..b07a0f042 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/TestNavigationHandle.lastOperation.kt @@ -0,0 +1,48 @@ +package dev.enro.test + +import dev.enro.NavigationOperation +import kotlin.reflect.KClass + +/** + * Returns the most-recently executed operation. Throws if no operations have + * been executed yet — use `operations.lastOrNull()` directly if you need + * to handle the empty case. + */ +public fun TestNavigationHandle<*>.lastOperation(): NavigationOperation.RootOperation { + val last = operations.lastOrNull() + last.shouldNotBeEqualTo(null) { + "TestNavigationHandle should have executed at least one operation, but none were executed" + } + return last!! +} + +/** + * Returns the most-recently executed operation of type [T], or throws if no + * operation of that type has been executed. Useful when you want to drill + * into operation-specific fields (instance, result, etc.) without filtering + * the whole `operations` list manually. + */ +public inline fun TestNavigationHandle<*>.lastOperationOfType(): T { + val matching = operations.filterIsInstance() + matching.lastOrNull().shouldNotBeEqualTo(null) { + "TestNavigationHandle should have executed at least one ${T::class.simpleName}, " + + "but none were found.\n\tOperations: $operations" + } + return matching.last() +} + +/** + * Asserts the executed operations match [expectedSequence] exactly in order + * and count. Compares operation types by `KClass`, not by content — for + * field-level assertions, use [assertOperationExecuted] per step. + */ +public fun TestNavigationHandle<*>.assertOperationSequence( + vararg expectedSequence: KClass, +) { + val actualTypes = operations.map { it::class } + val expectedTypes = expectedSequence.toList() + enroAssert(actualTypes == expectedTypes) { + "Expected operation sequence ${expectedTypes.map { it.simpleName }}, " + + "but executed ${actualTypes.map { it.simpleName }}.\n\tOperations: $operations" + } +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationContainerFixtures.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationContainerFixtures.kt new file mode 100644 index 000000000..25410a32c --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationContainerFixtures.kt @@ -0,0 +1,118 @@ +package dev.enro.test.fixtures + +import androidx.savedstate.savedState +import dev.enro.NavigationBackstack +import dev.enro.NavigationContainer +import dev.enro.NavigationContainerFilter +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.acceptAll +import dev.enro.backstackOf +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext +import dev.enro.emptyBackstack +import dev.enro.interceptor.NavigationInterceptor +import dev.enro.interceptor.NoOpNavigationInterceptor +import dev.enro.interceptor.builder.navigationInterceptor +import dev.enro.result.NavigationResultChannel +import dev.enro.result.flow.NavigationFlow +import dev.enro.result.flow.flowStepId +import dev.enro.test.EnroTest +import dev.enro.ui.EmptyBehavior +import dev.enro.ui.NavigationContainerState +import dev.enro.ui.decorators.NavigationSavedStateHolder +import kotlin.uuid.Uuid + +object NavigationContainerFixtures { + internal object ContainerFixtureKey : NavigationKey.TransientMetadataKey(null) + + fun create( + parentContext: NavigationContext = NavigationContextFixtures.createRootContext(), + key: NavigationContainer.Key = NavigationContainer.Key("TestNavigationContainer@${Uuid.random()}"), + backstack: NavigationBackstack = backstackOf(), + emptyBehavior: EmptyBehavior = EmptyBehavior.preventEmpty(), + interceptor: NavigationInterceptor = NoOpNavigationInterceptor, + filter: NavigationContainerFilter = acceptAll(), + ): NavigationContainerState { + require(parentContext is RootContext || parentContext is DestinationContext<*>) { + "NavigationContainer can only be used within a RootContext or DestinationContext" + } + val controller = requireNotNull(EnroTest.getCurrentNavigationController()) { + "EnroController instance is not initialized" + } + val container = NavigationContainer( + key = key, + controller = controller, + backstack = backstack, + ) + container.setFilter(filter) + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + container.addEmptyInterceptor(emptyBehavior.interceptor) + container.addInterceptor(interceptor) + + val context = ContainerContext( + container = container, + parent = parentContext, + ) + + val savedState = NavigationSavedStateHolder(savedState()) + val containerState = NavigationContainerState( + container = container, + emptyBehavior = emptyBehavior, + context = context, + savedStateHolder = savedState, + ) + container.addInterceptor( + navigationInterceptor { + onOpened { + instance.metadata.set(ContainerFixtureKey, containerState) + continueWithOpen() + } + } + ) + return containerState + } + + fun createForFlow( + flow: NavigationFlow<*> + ): NavigationContainerState { + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + return create( + key = NavigationContainer.Key("TestNavigationFlow"), + backstack = emptyBackstack(), + filter = run { + NavigationContainerFilter( + fromChildrenOnly = true, + block = { true }, + ) + }, + interceptor = navigationInterceptor { + onClosed { + val stepId = instance.flowStepId + if (stepId != null && !isSilent) { + flow.onStepClosed(stepId) + } + continueWithClose() + } + onCompleted { + val stepId = instance.flowStepId + ?: instance.metadata.get(NavigationResultChannel.ResultIdKey) + ?.let { resultId -> + flow.getSteps() + .firstOrNull { it.id.value == resultId.resultId } + ?.id + } + if (stepId == null) continueWithComplete() + cancelAnd { + flow.onStepCompleted(stepId, data ?: Unit) + flow.update() + } + } + } + ).also { + val state = it + flow.container = state + } + } +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationContextFixtures.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationContextFixtures.kt new file mode 100644 index 000000000..eb3df55d0 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationContextFixtures.kt @@ -0,0 +1,54 @@ +package dev.enro.test.fixtures + +import androidx.compose.runtime.mutableStateOf +import dev.enro.NavigationContainer +import dev.enro.NavigationKey +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.NavigationContext +import dev.enro.context.RootContext +import dev.enro.test.EnroTest +import dev.enro.ui.NavigationDestination +import kotlin.uuid.Uuid + +object NavigationContextFixtures { + fun createRootContext(): RootContext { + val owners = TestLifecycleAndViewModelStoreOwner() + return RootContext( + id = "TestRootContext", + parent = Unit, + controller = EnroTest.getCurrentNavigationController(), + lifecycleOwner = owners, + viewModelStoreOwner = owners, + defaultViewModelProviderFactory = owners, + activeChildId = mutableStateOf(null) + ) + } + + fun createContainerContext( + parent: NavigationContext.WithContainerChildren<*>, + ): ContainerContext { + val container = NavigationContainer( + key = NavigationContainer.Key(Uuid.Companion.random().toString()), + controller = parent.controller, + ) + return ContainerContext( + parent = parent, + container = container, + ) + } + + fun createDestinationContext( + parent: ContainerContext, + destination: NavigationDestination, + ): DestinationContext { + return DestinationContext( + parent = parent, + destination = destination, + lifecycleOwner = destination.lifecycleOwner, + viewModelStoreOwner = destination.viewModelStoreOwner, + defaultViewModelProviderFactory = destination.defaultViewModelProviderFactory, + activeChildId = mutableStateOf(null), + ) + } +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationDestinationFixtures.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationDestinationFixtures.kt new file mode 100644 index 000000000..c57fc4210 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/NavigationDestinationFixtures.kt @@ -0,0 +1,40 @@ +package dev.enro.test.fixtures + +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner +import dev.enro.NavigationKey +import dev.enro.asInstance +import dev.enro.ui.NavigationDestination +import dev.enro.ui.navigationDestination + +private const val TEST_OWNERS = "dev.enro.test.fixtures.NavigationDestinationFixtures.TEST_OWNERS" + +object NavigationDestinationFixtures { + fun create( + key: T, + metadata: NavigationDestination.MetadataBuilder.() -> Unit = { }, + ): NavigationDestination { + return navigationDestination( + metadata = { + apply(metadata) + add(TEST_OWNERS to TestLifecycleAndViewModelStoreOwner()) + }, + content = { + // Test NavigationDestination doesn't have any content + } + ).create(key.asInstance()) + } +} + +val NavigationDestination<*>.lifecycleOwner: LifecycleOwner get() { + return metadata[TEST_OWNERS] as LifecycleOwner +} + +val NavigationDestination<*>.viewModelStoreOwner: ViewModelStoreOwner get() { + return metadata[TEST_OWNERS] as ViewModelStoreOwner +} + +val NavigationDestination<*>.defaultViewModelProviderFactory: HasDefaultViewModelProviderFactory get() { + return metadata[TEST_OWNERS] as HasDefaultViewModelProviderFactory +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/TestLifecycleAndViewModelStoreOwner.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/TestLifecycleAndViewModelStoreOwner.kt new file mode 100644 index 000000000..054c015f6 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/fixtures/TestLifecycleAndViewModelStoreOwner.kt @@ -0,0 +1,31 @@ +package dev.enro.test.fixtures + +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.InitializerViewModelFactoryBuilder +import androidx.lifecycle.viewmodel.viewModelFactory + +class TestLifecycleAndViewModelStoreOwner( + viewModels: InitializerViewModelFactoryBuilder.() -> Unit = {} +) : LifecycleOwner, + ViewModelStoreOwner, + HasDefaultViewModelProviderFactory { + + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + override val viewModelStore: ViewModelStore = ViewModelStore() + override val defaultViewModelCreationExtras: CreationExtras = CreationExtras.Empty + override val defaultViewModelProviderFactory: ViewModelProvider.Factory = viewModelFactory(viewModels) + + fun setLifecycleState(state: Lifecycle.State) { + lifecycleRegistry.currentState = state + } +} \ No newline at end of file diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/installNavigationModule.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/installNavigationModule.kt new file mode 100644 index 000000000..304f4842c --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/installNavigationModule.kt @@ -0,0 +1,32 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package dev.enro.test + +import dev.enro.controller.NavigationModule +import dev.enro.controller.createNavigationModule +import dev.enro.path.NavigationPathBinding + +/** + * Installs [module] onto the test controller managed by [runEnroTest]. + * + * Replaces the verbose `EnroTest.getCurrentNavigationController().addModule(module)` + * pattern that nearly every path-binding or interceptor test repeats. Must be + * called from inside a `runEnroTest { }` block; throws if no controller is + * installed. + */ +public fun installNavigationModule(module: NavigationModule) { + EnroTest.getCurrentNavigationController().addModule(module) +} + +/** + * Convenience for the common case of installing a small set of + * [NavigationPathBinding]s for a path-routing test, without manually wrapping + * them in a `createNavigationModule { path(...) }` block. + */ +public fun installPathBindings(vararg bindings: NavigationPathBinding<*>) { + installNavigationModule( + createNavigationModule { + bindings.forEach { path(it) } + } + ) +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/putNavigationHandleForViewModel.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/putNavigationHandleForViewModel.kt new file mode 100644 index 000000000..b0de6daaf --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/putNavigationHandleForViewModel.kt @@ -0,0 +1,22 @@ +package dev.enro.test + +import androidx.lifecycle.ViewModel +import dev.enro.NavigationKey +import kotlin.reflect.KClass + + +inline fun putNavigationHandleForViewModel( + key: K, +) : TestNavigationHandle { + return putNavigationHandleForViewModel(T::class, key) +} + +fun putNavigationHandleForViewModel( + viewModel: KClass, + key: K, +) : TestNavigationHandle { + val testNavigationHandle = createTestNavigationHandle(key) + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + dev.enro.viewmodel.NavigationHandleProvider.put(viewModel, testNavigationHandle) + return testNavigationHandle +} diff --git a/enro-test/src/commonMain/kotlin/dev/enro/test/runEnroTest.kt b/enro-test/src/commonMain/kotlin/dev/enro/test/runEnroTest.kt new file mode 100644 index 000000000..4b1c56fc3 --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/runEnroTest.kt @@ -0,0 +1,20 @@ +package dev.enro.test + +/** + * runEnroTest is a way to perform the same behaviour as that of the EnroTestRule, but without + * using a JUnit TestRule. It is designed to wrap the entire block of a test, as in: + * ``` + * @Test + * fun exampleTest() = runEnroTest { ... } + * ``` + * + * See the documentation for [dev.enro.test.EnroTestRule] for more information. + */ +fun runEnroTest(block: () -> Unit) { + EnroTest.installNavigationController() + try { + block() + } finally { + EnroTest.uninstallNavigationController() + } +} \ No newline at end of file diff --git a/enro-test/src/main/AndroidManifest.xml b/enro-test/src/main/AndroidManifest.xml deleted file mode 100644 index ed66bcf6b..000000000 --- a/enro-test/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/enro-test/src/main/java/dev/enro/test/EnroTest.kt b/enro-test/src/main/java/dev/enro/test/EnroTest.kt deleted file mode 100644 index 99b08fc5b..000000000 --- a/enro-test/src/main/java/dev/enro/test/EnroTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package dev.enro.test - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import androidx.test.platform.app.InstrumentationRegistry -import dev.enro.core.controller.NavigationApplication -import dev.enro.core.controller.NavigationComponentBuilder -import dev.enro.core.controller.NavigationController -import dev.enro.core.plugins.EnroLogger - -object EnroTest { - - private var navigationController: NavigationController? = null - - fun installNavigationController() { - if (navigationController != null) { - uninstallNavigationController() - } - navigationController = NavigationComponentBuilder() - .apply { - plugin(EnroLogger()) - } - .callPrivate("build") - .apply { - isInTest = true - } - - if (isInstrumented()) { - val application = ApplicationProvider.getApplicationContext() - if (application is NavigationApplication) { - navigationController = application.navigationController.apply { - isInTest = true - } - return - } - navigationController?.apply { install(application) } - } else { - navigationController?.callPrivate("installForJvmTests") - } - } - - fun uninstallNavigationController() { - val providerClass = - Class.forName("dev.enro.viewmodel.EnroViewModelNavigationHandleProvider") - val instance = providerClass.getDeclaredField("INSTANCE").get(null)!! - instance.callPrivate("clearAllForTest") - navigationController?.apply { - isInTest = false - } - - val uninstallNavigationController = navigationController - navigationController = null - - if (isInstrumented()) { - val application = ApplicationProvider.getApplicationContext() - if (application is NavigationApplication) return - uninstallNavigationController?.callPrivate("uninstall", application) - } - } - - fun getCurrentNavigationController(): NavigationController { - return navigationController!! - } - - private fun isInstrumented(): Boolean { - runCatching { - InstrumentationRegistry.getInstrumentation() - return true - } - return false - } -} - - -private fun Any.callPrivate(methodName: String, vararg args: Any): T { - val method = this::class.java.declaredMethods.filter { it.name.startsWith(methodName) }.first() - method.isAccessible = true - val result = method.invoke(this, *args) - method.isAccessible = false - return result as T -} - - -private var NavigationController.isInTest: Boolean - get() { - return NavigationController::class.java.getDeclaredField("isInTest") - .let { - it.isAccessible = true - val result = it.get(this) as Boolean - it.isAccessible = false - - return@let result - } - } - set(value) { - NavigationController::class.java.getDeclaredField("isInTest") - .let { - it.isAccessible = true - val result = it.set(this, value) - it.isAccessible = false - - return@let result - } - } \ No newline at end of file diff --git a/enro-test/src/main/java/dev/enro/test/EnroTestRule.kt b/enro-test/src/main/java/dev/enro/test/EnroTestRule.kt deleted file mode 100644 index 4fc9e6993..000000000 --- a/enro-test/src/main/java/dev/enro/test/EnroTestRule.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.enro.test - -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement - -class EnroTestRule : TestRule { - override fun apply(base: Statement, description: Description): Statement { - return object : Statement() { - override fun evaluate() { - runEnroTest { base.evaluate() } - } - } - } -} - -fun runEnroTest(block: () -> Unit) { - EnroTest.installNavigationController() - try { - block() - } finally { - EnroTest.uninstallNavigationController() - } -} \ No newline at end of file diff --git a/enro-test/src/main/java/dev/enro/test/TestNavigationHandle.kt b/enro-test/src/main/java/dev/enro/test/TestNavigationHandle.kt deleted file mode 100644 index 34a86baf4..000000000 --- a/enro-test/src/main/java/dev/enro/test/TestNavigationHandle.kt +++ /dev/null @@ -1,183 +0,0 @@ -package dev.enro.test - -import android.annotation.SuppressLint -import android.os.Bundle -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleRegistry -import dev.enro.core.* -import dev.enro.core.controller.NavigationController -import dev.enro.test.extensions.getTestResultForId -import junit.framework.TestCase -import org.junit.Assert.* -import java.lang.ref.WeakReference - -class TestNavigationHandle( - private val navigationHandle: NavigationHandle -) : TypedNavigationHandle { - override val id: String - get() = navigationHandle.id - - override val controller: NavigationController - get() = navigationHandle.controller - - override val additionalData: Bundle - get() = navigationHandle.additionalData - - override val key: T - get() = navigationHandle.key as T - - override val instruction: NavigationInstruction.Open - get() = navigationHandle.instruction - - internal var internalOnCloseRequested: () -> Unit = { close() } - - override fun getLifecycle(): Lifecycle { - return navigationHandle.lifecycle - } - - val instructions: List - get() = navigationHandle::class.java.getDeclaredField("instructions").let { - it.isAccessible = true - val instructions = it.get(navigationHandle) - it.isAccessible = false - return instructions as List - } - - override fun executeInstruction(navigationInstruction: NavigationInstruction) { - navigationHandle.executeInstruction(navigationInstruction) - } -} - -fun createTestNavigationHandle( - key: T -): TestNavigationHandle { - val instruction = NavigationInstruction.Forward( - navigationKey = key - ) - lateinit var navigationHandle: WeakReference> - navigationHandle = WeakReference(TestNavigationHandle(object : NavigationHandle { - private val instructions = mutableListOf() - - @SuppressLint("VisibleForTests") - private val lifecycle = LifecycleRegistry.createUnsafe(this).apply { - currentState = Lifecycle.State.RESUMED - } - - override val id: String = instruction.instructionId - override val additionalData: Bundle = instruction.additionalData - override val key: NavigationKey = key - override val instruction: NavigationInstruction.Open = instruction - - override val controller: NavigationController = EnroTest.getCurrentNavigationController() - - override fun executeInstruction(navigationInstruction: NavigationInstruction) { - instructions.add(navigationInstruction) - if(navigationInstruction is NavigationInstruction.RequestClose) { - navigationHandle.get()?.internalOnCloseRequested?.invoke() - } - } - - override fun getLifecycle(): Lifecycle { - return lifecycle - } - })) - return navigationHandle.get()!! -} - -fun TestNavigationHandle<*>.expectCloseInstruction() { - TestCase.assertTrue(instructions.last() is NavigationInstruction.Close) -} - -fun TestNavigationHandle<*>.expectOpenInstruction(type: Class): NavigationInstruction.Open { - val instruction = instructions.filterIsInstance().last() - assertTrue(type.isAssignableFrom(instruction.navigationKey::class.java)) - return instruction -} - -inline fun TestNavigationHandle<*>.expectOpenInstruction(): NavigationInstruction.Open { - return expectOpenInstruction(T::class.java) -} - -fun TestNavigationHandle<*>.assertRequestedClose() { - val instruction = instructions.filterIsInstance() - .lastOrNull() - assertNotNull(instruction) -} - -fun TestNavigationHandle<*>.assertClosed() { - val instruction = instructions.filterIsInstance() - .lastOrNull() - assertNotNull(instruction) -} - -fun TestNavigationHandle<*>.assertNotClosed() { - val instruction = instructions.filterIsInstance() - .lastOrNull() - assertNull(instruction) -} - -fun TestNavigationHandle<*>.assertOpened(type: Class, direction: NavigationDirection? = null): T { - val instruction = instructions.filterIsInstance() - .lastOrNull() - - assertNotNull(instruction) - requireNotNull(instruction) - - assertTrue(type.isAssignableFrom(instruction.navigationKey::class.java)) - if(direction != null) { - assertEquals(direction, instruction.navigationDirection) - } - return instruction.navigationKey as T -} - -inline fun TestNavigationHandle<*>.assertOpened(direction: NavigationDirection? = null): T { - return assertOpened(T::class.java, direction) -} - -fun TestNavigationHandle<*>.assertAnyOpened(type: Class, direction: NavigationDirection? = null): T { - val instruction = instructions.filterIsInstance() - .lastOrNull { type.isAssignableFrom(it.navigationKey::class.java) } - - assertNotNull(instruction) - requireNotNull(instruction) - - assertTrue(type.isAssignableFrom(instruction.navigationKey::class.java)) - if(direction != null) { - assertEquals(direction, instruction.navigationDirection) - } - return instruction.navigationKey as T -} - -inline fun TestNavigationHandle<*>.assertAnyOpened(direction: NavigationDirection? = null): T { - return assertAnyOpened(T::class.java, direction) -} - -fun TestNavigationHandle<*>.assertNoneOpened() { - val instruction = instructions.filterIsInstance() - .lastOrNull() - assertNull(instruction) -} - -fun TestNavigationHandle<*>.assertResultDelivered(predicate: (T) -> Boolean): T { - val result = getTestResultForId(id) - assertNotNull(result) - requireNotNull(result) - result as T - assertTrue(predicate(result)) - return result -} - -fun TestNavigationHandle<*>.assertResultDelivered(expected: T): T { - val result = getTestResultForId(id) - assertEquals(expected, result) - return result as T -} - -inline fun TestNavigationHandle<*>.assertResultDelivered(): T { - return assertResultDelivered { true } -} - -fun TestNavigationHandle<*>.assertNoResultDelivered() { - val result = getTestResultForId(id) - assertNull(result) -} \ No newline at end of file diff --git a/enro-test/src/main/java/dev/enro/test/extensions/ActivityScenarioExtensions.kt b/enro-test/src/main/java/dev/enro/test/extensions/ActivityScenarioExtensions.kt deleted file mode 100644 index d6576e327..000000000 --- a/enro-test/src/main/java/dev/enro/test/extensions/ActivityScenarioExtensions.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.enro.test.extensions - -import androidx.fragment.app.FragmentActivity -import androidx.test.core.app.ActivityScenario -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.getNavigationHandle -import dev.enro.test.TestNavigationHandle - -fun ActivityScenario.getTestNavigationHandle(type: Class): TestNavigationHandle { - var result: NavigationHandle? = null - onActivity { - result = it.getNavigationHandle() - } - - val handle = result - ?: throw IllegalStateException("Could not retrieve NavigationHandle from Activity") - - if (!type.isAssignableFrom(handle.key::class.java)) { - throw IllegalStateException("Handle was of incorrect type. Expected ${type.name} but was ${handle.key::class.java.name}") - } - return TestNavigationHandle(handle) -} - -inline fun ActivityScenario.getTestNavigationHandle(): TestNavigationHandle = - getTestNavigationHandle(T::class.java) \ No newline at end of file diff --git a/enro-test/src/main/java/dev/enro/test/extensions/FragmentScenarioExtensions.kt b/enro-test/src/main/java/dev/enro/test/extensions/FragmentScenarioExtensions.kt deleted file mode 100644 index 5d08251cd..000000000 --- a/enro-test/src/main/java/dev/enro/test/extensions/FragmentScenarioExtensions.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.enro.test.extensions - -import androidx.fragment.app.Fragment -import androidx.fragment.app.testing.FragmentScenario -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.getNavigationHandle -import dev.enro.test.TestNavigationHandle - -fun FragmentScenario<*>.getTestNavigationHandle(type: Class): TestNavigationHandle { - @Suppress("UNCHECKED_CAST") - this as FragmentScenario - - var result: NavigationHandle? = null - onFragment { - result = it.getNavigationHandle() - } - - val handle = result - ?: throw IllegalStateException("Could not retrieve NavigationHandle from Fragment") - - if (!type.isAssignableFrom(handle.key::class.java)) { - throw IllegalStateException("Handle was of incorrect type. Expected ${type.name} but was ${handle.key::class.java.name}") - } - return TestNavigationHandle(handle) -} - -inline fun FragmentScenario<*>.getTestNavigationHandle(): TestNavigationHandle = - getTestNavigationHandle(T::class.java) \ No newline at end of file diff --git a/enro-test/src/main/java/dev/enro/test/extensions/ResultExtensions.kt b/enro-test/src/main/java/dev/enro/test/extensions/ResultExtensions.kt deleted file mode 100644 index e99855308..000000000 --- a/enro-test/src/main/java/dev/enro/test/extensions/ResultExtensions.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.enro.test.extensions - -import dev.enro.core.NavigationInstruction -import dev.enro.core.controller.NavigationController -import dev.enro.core.result.internal.ResultChannelId -import dev.enro.test.EnroTest -import kotlin.reflect.KClass - -fun NavigationInstruction.Open.sendResultForTest(type: Class, result: T) { - val navigationController = EnroTest.getCurrentNavigationController() - - val resultChannelClass = Class.forName("dev.enro.core.result.internal.ResultChannelImplKt") - val getResultId = resultChannelClass.getDeclaredMethod("getResultId", NavigationInstruction.Open::class.java) - getResultId.isAccessible = true - val resultId = getResultId.invoke(null, this) - getResultId.isAccessible = false - - val pendingResultClass = Class.forName("dev.enro.core.result.internal.PendingResult") - val pendingResultConstructor = pendingResultClass.getDeclaredConstructor( - resultId::class.java, - KClass::class.java, - Any::class.java - ) - val pendingResult = pendingResultConstructor.newInstance(resultId, type.kotlin, result) - - val enroResultClass = Class.forName("dev.enro.core.result.EnroResult") - val getEnroResult = enroResultClass.getDeclaredMethod("from", NavigationController::class.java) - getEnroResult.isAccessible = true - val enroResult = getEnroResult.invoke(null, navigationController) - getEnroResult.isAccessible = false - - val addPendingResult = enroResultClass.declaredMethods.first { it.name.startsWith("addPendingResult") } - addPendingResult.isAccessible = true - addPendingResult.invoke(enroResult, pendingResult) - addPendingResult.isAccessible = false -} - -inline fun NavigationInstruction.Open.sendResultForTest(result: T) { - sendResultForTest(T::class.java, result) -} - -@Suppress("UNCHECKED_CAST") -internal fun getTestResultForId(id: String): Any? { - val navigationController = EnroTest.getCurrentNavigationController() - - val enroResultClass = Class.forName("dev.enro.core.result.EnroResult") - val getEnroResult = enroResultClass.getDeclaredMethod("from", NavigationController::class.java) - getEnroResult.isAccessible = true - val enroResult = getEnroResult.invoke(null, navigationController) - getEnroResult.isAccessible = false - - val addPendingResult = enroResultClass.declaredFields.first { it.name.startsWith("pendingResults") } - addPendingResult.isAccessible = true - val results = addPendingResult.get(enroResult) as Map - addPendingResult.isAccessible = false - - val resultChannelId = ResultChannelId(ownerId = id, resultId = id) - val result = results[resultChannelId] ?: return null - - val pendingResultClass = Class.forName("dev.enro.core.result.internal.PendingResult") - val resultField = pendingResultClass.declaredFields.first { it.name == "result" } - resultField.isAccessible = true - return resultField.get(result) -} diff --git a/enro-test/src/main/java/dev/enro/test/extensions/ViewModelExtensions.kt b/enro-test/src/main/java/dev/enro/test/extensions/ViewModelExtensions.kt deleted file mode 100644 index df4e91e6b..000000000 --- a/enro-test/src/main/java/dev/enro/test/extensions/ViewModelExtensions.kt +++ /dev/null @@ -1,27 +0,0 @@ -package dev.enro.test.extensions - -import androidx.lifecycle.ViewModel -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.test.TestNavigationHandle -import dev.enro.test.createTestNavigationHandle -import kotlin.reflect.KClass - - -inline fun putNavigationHandleForViewModel( - key: NavigationKey -) : TestNavigationHandle { - return putNavigationHandleForViewModel(T::class, key) -} - -fun putNavigationHandleForViewModel( - viewModel: KClass, - key: NavigationKey -) : TestNavigationHandle { - val providerClass = Class.forName("dev.enro.viewmodel.EnroViewModelNavigationHandleProvider") - val instance = providerClass.getDeclaredField("INSTANCE").get(null) - val putMethod = providerClass.getDeclaredMethod("put", java.lang.Class::class.java, NavigationHandle::class.java) - val mockedNavigationHandle = createTestNavigationHandle(key) - putMethod.invoke(instance, viewModel.java, mockedNavigationHandle) - return mockedNavigationHandle -} \ No newline at end of file diff --git a/enro/build.gradle b/enro/build.gradle deleted file mode 100644 index 164aa22da..000000000 --- a/enro/build.gradle +++ /dev/null @@ -1,68 +0,0 @@ -androidLibrary() -useCompose() -apply plugin: "kotlin-kapt" -publishAndroidModule("dev.enro", "enro") - -android { - lintOptions { - textReport true - textOutput 'stdout' - } - packagingOptions { - resources.excludes.add("META-INF/*") - } -} - -dependencies { - releaseApi "dev.enro:enro-core:$versionName" - debugApi project(":enro-core") - - releaseApi "dev.enro:enro-masterdetail:$versionName" - debugApi project(":enro-masterdetail") - - releaseApi "dev.enro:enro-multistack:$versionName" - debugApi project(":enro-multistack") - - releaseApi "dev.enro:enro-annotations:$versionName" - debugApi project(":enro-annotations") - - lintPublish(project(":enro-lint")) - - kaptAndroidTest project(":enro-processor") - - testImplementation deps.testing.junit - testImplementation deps.testing.androidx.junit - testImplementation deps.testing.androidx.runner - testImplementation deps.testing.robolectric - testImplementation project(":enro-test") - - androidTestImplementation project(":enro-test") - - androidTestImplementation deps.testing.junit - - androidTestImplementation deps.androidx.core - androidTestImplementation deps.androidx.appcompat - androidTestImplementation deps.androidx.fragment - androidTestImplementation deps.androidx.activity - androidTestImplementation deps.androidx.recyclerview - - androidTestImplementation deps.testing.androidx.fragment - androidTestImplementation deps.testing.androidx.junit - androidTestImplementation deps.testing.androidx.espresso - androidTestImplementation deps.testing.androidx.espressoRecyclerView - androidTestImplementation deps.testing.androidx.espressoIntents - androidTestImplementation deps.testing.androidx.runner - - androidTestImplementation deps.testing.androidx.compose -} - -afterEvaluate { - tasks.findByName("preReleaseBuild") - .dependsOn( - ":enro-core:publishToMavenLocal", - ":enro-masterdetail:publishToMavenLocal", - ":enro-multistack:publishToMavenLocal", - ":enro-annotations:publishToMavenLocal" - ) -} - diff --git a/enro/build.gradle.kts b/enro/build.gradle.kts new file mode 100644 index 000000000..6b56d8f19 --- /dev/null +++ b/enro/build.gradle.kts @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.google.devtools.ksp") + id("configure-library") + id("configure-publishing") + id("configure-compose") + kotlin("plugin.serialization") +} + +tasks.withType() { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xfriend-paths=../enro-core/src/main") + } +} + +kotlin { + androidLibrary { + lint { + textReport = true + } + packaging { + resources.excludes.add("META-INF/*") + } + } + sourceSets { + desktopMain.dependencies { + + } + commonMain.dependencies { + api("dev.enro:enro-common:${project.enroVersionName}") + api("dev.enro:enro-runtime:${project.enroVersionName}") + api("dev.enro:enro-annotations:${project.enroVersionName}") + } + + androidMain.dependencies { + + } + } +} + +// Some android dependencies need to be declared at the top level like this, +// it's a bit gross but I can't figure out how to get it to work otherwise +dependencies { + lintPublish(project(":enro-lint")) +} diff --git a/enro/hilt-test/build.gradle b/enro/hilt-test/build.gradle deleted file mode 100644 index 5d5e508ef..000000000 --- a/enro/hilt-test/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -androidLibrary() -useCompose() -apply plugin: "kotlin-kapt" -apply plugin: "dagger.hilt.android.plugin" - -android { - lintOptions { - textReport true - textOutput 'stdout' - } - defaultConfig { - testInstrumentationRunner "dev.enro.HiltTestApplicationRunner" - } - packagingOptions { - resources.excludes.add("META-INF/*") - } -} - -dependencies { - implementation(project(":enro")) - - kaptAndroidTest project(":enro-processor") - - androidTestImplementation project(":enro-test") - androidTestImplementation deps.testing.junit - androidTestImplementation deps.androidx.core - androidTestImplementation deps.androidx.appcompat - androidTestImplementation deps.androidx.fragment - androidTestImplementation deps.androidx.activity - androidTestImplementation deps.androidx.recyclerview - androidTestImplementation deps.hilt.android - androidTestImplementation deps.hilt.testing - kaptAndroidTest deps.hilt.compiler - kaptAndroidTest deps.hilt.androidCompiler - - androidTestImplementation deps.testing.androidx.fragment - androidTestImplementation deps.testing.androidx.junit - androidTestImplementation deps.testing.androidx.espresso - androidTestImplementation deps.testing.androidx.espressoRecyclerView - androidTestImplementation deps.testing.androidx.espressoIntents - androidTestImplementation deps.testing.androidx.runner - - androidTestImplementation deps.testing.androidx.compose -} \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/AndroidManifest.xml b/enro/hilt-test/src/androidTest/AndroidManifest.xml deleted file mode 100644 index 3414bf147..000000000 --- a/enro/hilt-test/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/HiltTestApplicationRunner.kt b/enro/hilt-test/src/androidTest/java/dev/enro/HiltTestApplicationRunner.kt deleted file mode 100644 index 52d7cec32..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/HiltTestApplicationRunner.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.enro - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.CustomTestApplication -import dev.enro.core.controller.createNavigationComponent -import dev.enro.core.controller.navigationController - -@CustomTestApplication(TestApplication::class) -interface HiltTestApplication - -class HiltTestApplicationRunner : AndroidJUnitRunner() { - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication_Application::class.java.name, context).apply { - navigationController.addComponent(createNavigationComponent { - TestApplicationNavigation().execute(this) - }) - } - } - -} - diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/TestApplication.kt b/enro/hilt-test/src/androidTest/java/dev/enro/TestApplication.kt deleted file mode 120000 index 6146af85e..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/TestApplication.kt +++ /dev/null @@ -1 +0,0 @@ -../../../../../../src/androidTest/java/dev/enro/TestApplication.kt \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/TestDestinations.kt b/enro/hilt-test/src/androidTest/java/dev/enro/TestDestinations.kt deleted file mode 120000 index fe6a2c67a..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/TestDestinations.kt +++ /dev/null @@ -1 +0,0 @@ -../../../../../../src/androidTest/java/dev/enro/TestDestinations.kt \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/TestExtensions.kt b/enro/hilt-test/src/androidTest/java/dev/enro/TestExtensions.kt deleted file mode 120000 index 1fe10b578..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/TestExtensions.kt +++ /dev/null @@ -1 +0,0 @@ -../../../../../../src/androidTest/java/dev/enro/TestExtensions.kt \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/TestPlugin.kt b/enro/hilt-test/src/androidTest/java/dev/enro/TestPlugin.kt deleted file mode 120000 index 37dd9fbd8..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/TestPlugin.kt +++ /dev/null @@ -1 +0,0 @@ -../../../../../../src/androidTest/java/dev/enro/TestPlugin.kt \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/TestViews.kt b/enro/hilt-test/src/androidTest/java/dev/enro/TestViews.kt deleted file mode 120000 index 6d794748f..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/TestViews.kt +++ /dev/null @@ -1 +0,0 @@ -../../../../../../src/androidTest/java/dev/enro/TestViews.kt \ No newline at end of file diff --git a/enro/hilt-test/src/androidTest/java/dev/enro/hilt/test/HiltViewModelCreationTests.kt b/enro/hilt-test/src/androidTest/java/dev/enro/hilt/test/HiltViewModelCreationTests.kt deleted file mode 100644 index a8e8d86f1..000000000 --- a/enro/hilt-test/src/androidTest/java/dev/enro/hilt/test/HiltViewModelCreationTests.kt +++ /dev/null @@ -1,185 +0,0 @@ -package dev.enro.hilt.test - -import android.app.Application -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.layout.size -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dev.enro.* -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.composableManger -import dev.enro.core.forward -import dev.enro.core.getNavigationHandle -import dev.enro.core.navigationHandle -import dev.enro.viewmodel.enroViewModels -import dev.enro.viewmodel.navigationHandle -import junit.framework.TestCase.assertTrue -import kotlinx.parcelize.Parcelize -import org.junit.Rule -import org.junit.Test -import javax.inject.Inject -import javax.inject.Singleton - - -@HiltAndroidTest -class HiltViewModelCreationTests { - - @get:Rule - val hilt = HiltAndroidRule(this) - - @Test - fun whenActivityFragmentComposable_requestHiltInjectedViewModels_thenViewModelsAreCreated() { - ActivityScenario.launch(DefaultActivity::class.java) - - expectContext() - .navigation - .forward(ContainerActivity.Key()) - - expectContext() - .apply { - InstrumentationRegistry.getInstrumentation().runOnMainSync { - context.viewModel.hashCode() - } - } - .navigation - .forward(ContainerFragment.Key()) - - val fragment = expectContext() - - fragment - .apply { - InstrumentationRegistry.getInstrumentation().runOnMainSync { - context.viewModel.hashCode() - } - } - .navigation - .forward(Compose.Key()) - - // TODO: Once Enro 2.0 is released, this hacky way of checking the current top composable can be removed - val activeNavigation = waitOnMain { - fragment.context.composableManger.activeContainer?.activeContext?.getNavigationHandle() - } - Thread.sleep(1000) - assertTrue(activeNavigation.key is Compose.Key) - } - - @AndroidEntryPoint - @NavigationDestination(ContainerActivity.Key::class) - class ContainerActivity : TestActivity() { - - val viewModel by enroViewModels() - private val navigation by navigationHandle { - container(primaryFragmentContainer) { - it is ContainerFragment.Key - } - } - - @Parcelize - class Key : NavigationKey - - @HiltViewModel - class TestViewModel @Inject constructor( - val useCaseOne: ExampleDependencies.UseCaseOne, - val useCaseTwo: ExampleDependencies.UseCaseTwo, - val application: Application, - val savedStateHandle: SavedStateHandle - ): ViewModel() { - val navigation by navigationHandle() - } - } - - @AndroidEntryPoint - @NavigationDestination(ContainerFragment.Key::class) - class ContainerFragment : androidx.fragment.app.Fragment() { - - val viewModel by enroViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setContent { - EnroContainer( - modifier = Modifier.size(width = 200.dp, height = 200.dp) - ) - } - } - } - - @Parcelize - class Key : NavigationKey - - @HiltViewModel - class TestViewModel @Inject constructor( - val useCaseOne: ExampleDependencies.UseCaseOne, - val useCaseTwo: ExampleDependencies.UseCaseTwo, - val application: Application, - val savedStateHandle: SavedStateHandle - ): ViewModel() { - val navigation by navigationHandle() - } - } - - - object Compose { - @Composable - @ExperimentalComposableDestination - @NavigationDestination(Key::class) - fun Draw() { - val viewModel = viewModel() - - Text("Text with ${viewModel.navigation.key}") - } - - @Parcelize - class Key : NavigationKey - - @HiltViewModel - class TestViewModel @Inject constructor( - val useCaseOne: ExampleDependencies.UseCaseOne, - val useCaseTwo: ExampleDependencies.UseCaseTwo, - val application: Application, - val savedStateHandle: SavedStateHandle - ): ViewModel() { - val navigation by navigationHandle() - } - } -} - -object ExampleDependencies { - - @Singleton - class RepositoryOne @Inject constructor() {} - - @Singleton - class RepositoryTwo @Inject constructor() {} - - class UseCaseOne @Inject constructor( - val repositoryOne: RepositoryOne - ) {} - - class UseCaseTwo @Inject constructor( - val repositoryOne: RepositoryOne, - val repositoryTwo: RepositoryTwo - ) {} -} \ No newline at end of file diff --git a/enro/hilt-test/src/main/AndroidManifest.xml b/enro/hilt-test/src/main/AndroidManifest.xml deleted file mode 100644 index c42f6df0b..000000000 --- a/enro/hilt-test/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/enro/src/androidMain/AndroidManifest.xml b/enro/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..227314eeb --- /dev/null +++ b/enro/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/enro/src/androidTest/AndroidManifest.xml b/enro/src/androidTest/AndroidManifest.xml deleted file mode 100644 index af66dca0a..000000000 --- a/enro/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/TestApplication.kt b/enro/src/androidTest/java/dev/enro/TestApplication.kt deleted file mode 100644 index d94eacbd1..000000000 --- a/enro/src/androidTest/java/dev/enro/TestApplication.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.enro - -import android.app.Application -import dev.enro.annotations.NavigationComponent -import dev.enro.core.controller.NavigationApplication -import dev.enro.core.controller.navigationController -import dev.enro.core.plugins.EnroLogger - -@NavigationComponent -open class TestApplication : Application(), NavigationApplication { - override val navigationController = navigationController { - plugin(EnroLogger()) - plugin(TestPlugin) - } -} - diff --git a/enro/src/androidTest/java/dev/enro/TestDestinations.kt b/enro/src/androidTest/java/dev/enro/TestDestinations.kt deleted file mode 100644 index eb04e5d8a..000000000 --- a/enro/src/androidTest/java/dev/enro/TestDestinations.kt +++ /dev/null @@ -1,111 +0,0 @@ -package dev.enro -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.Composable -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.navigationHandle -import kotlinx.parcelize.Parcelize - -@Parcelize -data class DefaultActivityKey(val id: String) : NavigationKey - -@NavigationDestination(DefaultActivityKey::class) -class DefaultActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(defaultKey) - } - - companion object { - val defaultKey = DefaultActivityKey("default") - } -} - -@Parcelize -data class GenericActivityKey(val id: String) : NavigationKey - -@NavigationDestination(GenericActivityKey::class) -class GenericActivity : TestActivity() - -@Parcelize -data class ActivityWithFragmentsKey(val id: String) : NavigationKey - -@NavigationDestination(ActivityWithFragmentsKey::class) -class ActivityWithFragments : TestActivity() { - private val navigation by navigationHandle { - defaultKey(ActivityWithFragmentsKey("default")) - container(primaryFragmentContainer) { - it is ActivityChildFragmentKey || it is ActivityChildFragmentTwoKey - } - } -} - -@Parcelize -data class ActivityChildFragmentKey(val id: String) : NavigationKey - -@NavigationDestination(ActivityChildFragmentKey::class) -class ActivityChildFragment : TestFragment() { - val navigation by navigationHandle() { - container(primaryFragmentContainer) { - it is Nothing - } - } -} - -@Parcelize data class ActivityWithComposablesKey( - val id: String, - val primaryContainerAccepts: List>, - val secondaryContainerAccepts: List> -) : NavigationKey - -@NavigationDestination(ActivityWithComposablesKey::class) -class ActivityWithComposables : AppCompatActivity() { - - val navigation by navigationHandle { - defaultKey(ActivityWithComposablesKey( - id = "default", - primaryContainerAccepts = listOf(NavigationKey::class.java), - secondaryContainerAccepts = emptyList() - )) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - TestComposable( - name = "ActivityWithComposablesKey(id = ${navigation.key.id})", - primaryContainerAccepts = { key -> - navigation.key.primaryContainerAccepts.any { - it.isAssignableFrom(key::class.java) - } - } - ) - } - } -} - -@Parcelize -data class ActivityChildFragmentTwoKey(val id: String) : NavigationKey - -@NavigationDestination(ActivityChildFragmentTwoKey::class) -class ActivityChildFragmentTwo : TestFragment() - -@Parcelize -data class GenericFragmentKey(val id: String) : NavigationKey - -@NavigationDestination(GenericFragmentKey::class) -class GenericFragment : TestFragment() - -@Parcelize -data class GenericComposableKey(val id: String) : NavigationKey - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(GenericComposableKey::class) -fun GenericComposableDestination() = TestComposable(name = "GenericComposableDestination") - -class UnboundActivity : TestActivity() - -class UnboundFragment : TestFragment() \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/TestExtensions.kt b/enro/src/androidTest/java/dev/enro/TestExtensions.kt deleted file mode 100644 index dc6698382..000000000 --- a/enro/src/androidTest/java/dev/enro/TestExtensions.kt +++ /dev/null @@ -1,207 +0,0 @@ -package dev.enro - -import android.app.Activity -import android.app.Application -import android.util.Log -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry -import androidx.test.runner.lifecycle.Stage -import dev.enro.core.* -import dev.enro.core.compose.ComposableDestination -import dev.enro.core.compose.composableManger -import dev.enro.core.controller.NavigationController -import dev.enro.core.controller.navigationController -import dev.enro.core.result.EnroResultChannel - -private val debug = false - -inline fun ActivityScenario.getNavigationHandle(): TypedNavigationHandle { - var result: NavigationHandle? = null - onActivity{ - result = it.getNavigationHandle() - } - - val handle = result ?: throw IllegalStateException("Could not retrieve NavigationHandle from Activity") - val key = handle.key as? T - ?: throw IllegalStateException("Handle was of incorrect type. Expected ${T::class.java.name} but was ${handle.key::class.java.name}") - return handle.asTyped() -} - -class TestNavigationContext( - val context: Context, - val navigation: TypedNavigationHandle -) - -inline fun expectContext( - crossinline selector: (TestNavigationContext) -> Boolean = { true } -): TestNavigationContext { - return when { - ComposableDestination::class.java.isAssignableFrom(ContextType::class.java) -> { - waitOnMain { - val activities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) - val activity = activities.firstOrNull() as? FragmentActivity ?: return@waitOnMain null - var composableContext = activity.composableManger.activeContainer?.activeContext - ?: activity.supportFragmentManager.primaryNavigationFragment?.composableManger?.activeContainer?.activeContext - - while(composableContext != null) { - if (KeyType::class.java.isAssignableFrom(composableContext.getNavigationHandle().key::class.java)) { - val context = TestNavigationContext( - composableContext.contextReference as ContextType, - composableContext.getNavigationHandle().asTyped() - ) - if (selector(context)) return@waitOnMain context - } - composableContext = composableContext.childComposableManager.activeContainer?.activeContext - } - return@waitOnMain null - } - } - Fragment::class.java.isAssignableFrom(ContextType::class.java) -> { - waitOnMain { - val activities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) - val activity = activities.firstOrNull() as? FragmentActivity ?: return@waitOnMain null - var fragment = activity.supportFragmentManager.primaryNavigationFragment - - while(fragment != null) { - if (fragment is ContextType) { - val context = TestNavigationContext( - fragment as ContextType, - fragment.getNavigationHandle().asTyped() - ) - if (selector(context)) return@waitOnMain context - } - fragment = fragment.childFragmentManager.primaryNavigationFragment - } - return@waitOnMain null - } - } - FragmentActivity::class.java.isAssignableFrom(ContextType::class.java) -> waitOnMain { - val activities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) - val activity = activities.firstOrNull() - if(activity !is FragmentActivity) return@waitOnMain null - if(activity !is ContextType) return@waitOnMain null - - val context = TestNavigationContext( - activity as ContextType, - activity.getNavigationHandle().asTyped() - ) - return@waitOnMain if(selector(context)) context else null - } - else -> throw RuntimeException("Failed to get context type ${ContextType::class.java.name}") - } -} - - -fun getActiveActivity(): Activity? { - val activities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) - return activities.firstOrNull() -} - -inline fun expectActivity(crossinline selector: (FragmentActivity) -> Boolean = { it is T }): T { - return waitOnMain { - val activity = getActiveActivity() - - return@waitOnMain when { - activity !is FragmentActivity -> null - activity !is T -> null - selector(activity) -> activity - else -> null - } - } -} - -internal inline fun expectFragment(crossinline selector: (Fragment) -> Boolean = { it is T }): T { - return waitOnMain { - val activity = getActiveActivity() as? FragmentActivity ?: return@waitOnMain null - val fragment = activity.supportFragmentManager.primaryNavigationFragment - Log.e("FRAGMENT", "$fragment") - return@waitOnMain when { - fragment == null -> null - fragment !is T -> null - selector(fragment) -> fragment - else -> null - } - } -} - -internal inline fun expectNoFragment(crossinline selector: (Fragment) -> Boolean = { it is T }): Boolean { - return waitOnMain { - val activity = getActiveActivity() as? FragmentActivity ?: return@waitOnMain null - val fragment = activity.supportFragmentManager.primaryNavigationFragment ?: return@waitOnMain true - if(selector(fragment)) return@waitOnMain null else true - } -} - -fun expectNoActivity() { - waitOnMain { - val activities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.PRE_ON_CREATE).toList() + - ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.CREATED).toList() + - ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.STARTED).toList() + - ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).toList() + - ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.PAUSED).toList() + - ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.STOPPED).toList() + - ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESTARTED).toList() - return@waitOnMain if(activities.isEmpty()) true else null - } -} - -fun waitFor(block: () -> Boolean) { - val maximumTime = 7_000 - val startTime = System.currentTimeMillis() - - while(true) { - if(block()) return - Thread.sleep(33) - if(System.currentTimeMillis() - startTime > maximumTime) throw IllegalStateException("Took too long waiting") - } -} - -fun waitOnMain(block: () -> T?): T { - if(debug) { Thread.sleep(3000) } - - val maximumTime = 7_000 - val startTime = System.currentTimeMillis() - var currentResponse: T? = null - - while(true) { - if (System.currentTimeMillis() - startTime > maximumTime) throw IllegalStateException("Took too long waiting") - InstrumentationRegistry.getInstrumentation().runOnMainSync { - currentResponse = block() - } - currentResponse?.let { return it } - Thread.sleep(33) - } -} - -fun getActiveEnroResultChannels(): List> { - val enroResultClass = Class.forName("dev.enro.core.result.EnroResult") - val getEnroResult = enroResultClass.getDeclaredMethod("from", NavigationController::class.java) - getEnroResult.isAccessible = true - val enroResult = getEnroResult.invoke(null, application.navigationController) - getEnroResult.isAccessible = false - - val channels = enroResult.getPrivate>>("channels") - return channels.values.toList() -} - -fun Any.callPrivate(methodName: String, vararg args: Any): T { - val method = this::class.java.declaredMethods.filter { it.name.startsWith(methodName) }.first() - method.isAccessible = true - val result = method.invoke(this, *args) - method.isAccessible = false - return result as T -} - -fun Any.getPrivate(methodName: String): T { - val method = this::class.java.declaredFields.filter { it.name.startsWith(methodName) }.first() - method.isAccessible = true - val result = method.get(this) - method.isAccessible = false - return result as T -} - -val application: Application get() = - InstrumentationRegistry.getInstrumentation().context.applicationContext as Application diff --git a/enro/src/androidTest/java/dev/enro/TestPlugin.kt b/enro/src/androidTest/java/dev/enro/TestPlugin.kt deleted file mode 100644 index 864bdfe8e..000000000 --- a/enro/src/androidTest/java/dev/enro/TestPlugin.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.enro - -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.plugins.EnroPlugin - -object TestPlugin : EnroPlugin() { - var activeKey: NavigationKey? = null - - override fun onActive(navigationHandle: NavigationHandle) { - activeKey = navigationHandle.key - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/TestViews.kt b/enro/src/androidTest/java/dev/enro/TestViews.kt deleted file mode 100644 index 5eeb1970d..000000000 --- a/enro/src/androidTest/java/dev/enro/TestViews.kt +++ /dev/null @@ -1,247 +0,0 @@ -package dev.enro - -import android.os.Bundle -import android.util.Log -import android.util.TypedValue -import android.view.Gravity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.view.setPadding -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment -import dev.enro.core.NavigationKey -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.navigationHandle -import dev.enro.core.compose.rememberEnroContainerController -import dev.enro.core.getNavigationHandle - -abstract class TestActivity : AppCompatActivity() { - - val layout by lazy { - val key = try { - getNavigationHandle().key - } catch(t: Throwable) {} - - Log.e("TestActivity", "Opened $key") - - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - - addView(TextView(this@TestActivity).apply { - text = this@TestActivity::class.java.simpleName - setTextSize(TypedValue.COMPLEX_UNIT_SP, 32.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(TextView(this@TestActivity).apply { - text = key.toString() - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(TextView(this@TestActivity).apply { - id = debugText - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(FrameLayout(this@TestActivity).apply { - id = primaryFragmentContainer - setBackgroundColor(0x22FF0000) - setPadding(50) - }) - - addView(FrameLayout(this@TestActivity).apply { - id = secondaryFragmentContainer - setBackgroundColor(0x220000FF) - setPadding(50) - }) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(layout) - } - - companion object { - val debugText = View.generateViewId() - val primaryFragmentContainer = View.generateViewId() - val secondaryFragmentContainer = View.generateViewId() - } -} - -abstract class TestFragment : Fragment() { - - lateinit var layout: LinearLayout - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val key = try { - getNavigationHandle().key - } catch(t: Throwable) {} - - Log.e("TestFragment", "Opened $key") - - layout = LinearLayout(requireContext()).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - - addView(TextView(requireContext()).apply { - text = this@TestFragment::class.java.simpleName - setTextSize(TypedValue.COMPLEX_UNIT_SP, 32.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(TextView(requireContext()).apply { - text = key.toString() - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(TextView(requireContext()).apply { - id = debugText - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(FrameLayout(requireContext()).apply { - id = primaryFragmentContainer - setPadding(50) - setBackgroundColor(0x22FF0000) - }) - - addView(FrameLayout(requireContext()).apply { - id = secondaryFragmentContainer - setPadding(50) - setBackgroundColor(0x220000FF) - }) - } - - return layout - } - - companion object { - val debugText = View.generateViewId() - val primaryFragmentContainer = View.generateViewId() - val secondaryFragmentContainer = View.generateViewId() - - } -} - -abstract class TestDialogFragment : DialogFragment() { - - lateinit var layout: LinearLayout - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val key = try { - getNavigationHandle().key - } catch(t: Throwable) {} - - Log.e("TestFragment", "Opened $key") - - layout = LinearLayout(requireContext()).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - - addView(TextView(requireContext()).apply { - text = this@TestDialogFragment::class.java.simpleName - setTextSize(TypedValue.COMPLEX_UNIT_SP, 32.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(TextView(requireContext()).apply { - text = key.toString() - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(TextView(requireContext()).apply { - id = debugText - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14.0f) - textAlignment = TextView.TEXT_ALIGNMENT_CENTER - gravity = Gravity.CENTER - }) - - addView(FrameLayout(requireContext()).apply { - id = primaryFragmentContainer - setPadding(50) - setBackgroundColor(0x22FF0000) - }) - - addView(FrameLayout(requireContext()).apply { - id = secondaryFragmentContainer - setPadding(50) - setBackgroundColor(0x220000FF) - }) - } - - return layout - } - - companion object { - val debugText = View.generateViewId() - val primaryFragmentContainer = View.generateViewId() - val secondaryFragmentContainer = View.generateViewId() - - } -} - -@Composable -fun TestComposable( - name: String, - primaryContainerAccepts: (NavigationKey) -> Boolean = { false }, - secondaryContainerAccepts: (NavigationKey) -> Boolean = { false } -) { - val primaryContainer = rememberEnroContainerController( - accept = primaryContainerAccepts - ) - - val secondaryContainer = rememberEnroContainerController( - accept = primaryContainerAccepts - ) - - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize()) { - Text(text = name, fontSize = 32.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(20.dp)) - Text(text = navigationHandle().key.toString(), fontSize = 14.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(20.dp)) - EnroContainer( - controller = primaryContainer, - modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).background(Color(0x22FF0000)).padding(horizontal = 20.dp) - ) - EnroContainer( - controller = secondaryContainer, - modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).background(Color(0x220000FF)).padding(20.dp) - ) - } -} diff --git a/enro/src/androidTest/java/dev/enro/core/ActivityToActivityTests.kt b/enro/src/androidTest/java/dev/enro/core/ActivityToActivityTests.kt deleted file mode 100644 index 379b4bcd6..000000000 --- a/enro/src/androidTest/java/dev/enro/core/ActivityToActivityTests.kt +++ /dev/null @@ -1,151 +0,0 @@ -package dev.enro.core - -import android.content.Intent -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertNotNull -import dev.enro.* -import dev.enro.DefaultActivity -import org.junit.Test -import java.util.* - -class ActivityToActivityTests { - - @Test - fun givenDefaultActivityOpenedWithoutNavigationKeySet_thenDefaultKeyIsUsed() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - assertEquals(DefaultActivity.defaultKey, handle.key) - } - - @Test - fun givenDefaultActivityRecreated_thenNavigationHandleIdIsStable() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val id = scenario.getNavigationHandle().id - scenario.recreate() - - val recreatedId = expectActivity().getNavigationHandle().id - assertEquals(id, recreatedId) - } - - @Test - fun givenDefaultActivity_whenCloseInstructionIsExecuted_thenNoActivitiesAreOpen() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - handle.close() - expectNoActivity() - } - - @Test - fun givenDefaultActivity_whenNavigationInstructionIsExecuted_thenCorrectActivityIsOpened() { - val id = UUID.randomUUID().toString() - - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - handle.forward(GenericActivityKey(id)) - - val next = expectActivity() - val nextHandle = next.getNavigationHandle().asTyped() - - assertEquals(id, nextHandle.key.id) - } - - @Test - fun givenActivityOpenedWithChildren_thenFinalOpenedActivityIsLastChild() { - val id = UUID.randomUUID().toString() - - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - handle.executeInstruction( - NavigationInstruction.Forward( - GenericActivityKey(UUID.randomUUID().toString()), - listOf( - GenericActivityKey(UUID.randomUUID().toString()), - GenericActivityKey(UUID.randomUUID().toString()), - GenericActivityKey(UUID.randomUUID().toString()), - GenericActivityKey(id) - ) - ) - ) - - expectActivity { - it.getNavigationHandle().asTyped().key.id == id - } - } - - @Test - fun givenDefaultActivity_whenSpecificActivityIsOpened_andThenSpecificActivityIsClosed_thenDefaultActivityIsOpen() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - handle.forward(GenericActivityKey("close")) - - val next = expectActivity() - val nextHandle = next.getNavigationHandle() - nextHandle.close() - - val activeActivity = expectActivity() - val activeHandle = activeActivity.getNavigationHandle().asTyped() - assertEquals(DefaultActivity.defaultKey, activeHandle.key) - } - - @Test(expected = IllegalStateException::class) - fun givenActivityDoesNotHaveDefaultKey_whenActivityOpenedWithoutNavigationKeySet_thenNavigationHandleCannotRetrieveKey() { - val scenario = ActivityScenario.launch(GenericActivity::class.java) - val handle = scenario.getNavigationHandle() - assertNotNull(handle.key) - } - - @Test - fun whenSpecificActivityOpenedWithNavigationKeySet_thenNavigationKeyIsAvailable() { - val id = UUID.randomUUID().toString() - val intent = - Intent( - InstrumentationRegistry.getInstrumentation().context, - GenericActivity::class.java - ) - .addOpenInstruction( - NavigationInstruction.Replace( - navigationKey = GenericActivityKey(id) - ) - ) - - val scenario = ActivityScenario.launch(intent) - val handle = scenario.getNavigationHandle() - - assertEquals(id, handle.key.id) - } - - @Test - fun whenActivityIsReplaced_andReplacementIsClosed_thenNoActivitiesAreOpen() { - val id = UUID.randomUUID().toString() - - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - handle.replace(GenericActivityKey(id)) - - val next = expectActivity() - val nextHandle = next.getNavigationHandle() - - nextHandle.close() - expectNoActivity() - } - - @Test - fun whenActivityIsReplaced_andActivityHasParent_andReplacementIsClosed_thenParentIsOpen() { - val first = UUID.randomUUID().toString() - val second = UUID.randomUUID().toString() - - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - handle.forward(GenericActivityKey(first)) - - val firstActivity = expectActivity { it.getNavigationHandle().asTyped().key.id == first } - firstActivity.getNavigationHandle().replace(GenericActivityKey(second)) - - val secondActivity = expectActivity { it.getNavigationHandle().asTyped().key.id == second } - secondActivity.getNavigationHandle().close() - - expectActivity() - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/ActivityToComposableTests.kt b/enro/src/androidTest/java/dev/enro/core/ActivityToComposableTests.kt deleted file mode 100644 index b689d74d0..000000000 --- a/enro/src/androidTest/java/dev/enro/core/ActivityToComposableTests.kt +++ /dev/null @@ -1,87 +0,0 @@ -package dev.enro.core - -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.* -import androidx.test.core.app.ActivityScenario -import dev.enro.* -import dev.enro.core.compose.ComposableDestination -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.* - -private fun expectSingleFragmentActivity(): FragmentActivity { - return expectActivity { it::class.java.simpleName == "SingleFragmentActivity" } -} - -class ActivityToComposableTests { - - @Test - fun whenActivityOpensComposable_andActivityDoesNotHaveComposeContainer_thenComposableIsLaunchedAsComposableFragmentHost() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(GenericComposableKey(id)) - - expectSingleFragmentActivity() - expectContext { - it.navigation.key.id == id - } - } - - @Test - fun givenStandaloneComposable_whenHostActivityCloses_thenComposableViewModelStoreIsCleared() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - - handle.forward(GenericComposableKey(id = "StandaloneComposable")) - - expectSingleFragmentActivity() - - val context = expectContext() - - val viewModel by ViewModelLazy( - viewModelClass = OnClearedTrackingViewModel::class, - storeProducer = { context.context.viewModelStore }, - factoryProducer = { ViewModelProvider.NewInstanceFactory() } - ) - - assertFalse(viewModel.onClearedCalled) - - context.navigation.close() - - expectActivity() - waitFor { viewModel.onClearedCalled } - assertTrue(viewModel.onClearedCalled) - } - - @Test - fun givenActivityHostedComposable_whenHostActivityCloses_thenComposableViewModelStoreIsCleared() { - val scenario = ActivityScenario.launch(ActivityWithComposables::class.java) - val handle = scenario.getNavigationHandle() - - handle.forward(GenericComposableKey(id = "ComposableViewModelExample")) - - val context = expectContext() - - val viewModel by ViewModelLazy( - viewModelClass = OnClearedTrackingViewModel::class, - storeProducer = { context.context.viewModelStore }, - factoryProducer = { ViewModelProvider.NewInstanceFactory() } - ) - - handle.close() - - waitFor { viewModel.onClearedCalled } - assertTrue(viewModel.onClearedCalled) - } -} - -class OnClearedTrackingViewModel : ViewModel() { - var onClearedCalled = false - - override fun onCleared() { - onClearedCalled = true - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/ActivityToFragmentTests.kt b/enro/src/androidTest/java/dev/enro/core/ActivityToFragmentTests.kt deleted file mode 100644 index cb308f35b..000000000 --- a/enro/src/androidTest/java/dev/enro/core/ActivityToFragmentTests.kt +++ /dev/null @@ -1,491 +0,0 @@ -package dev.enro.core - -import android.os.Bundle -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.test.core.app.ActivityScenario -import dev.enro.* -import dev.enro.annotations.NavigationDestination -import junit.framework.TestCase.assertTrue -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertNull -import kotlinx.parcelize.Parcelize -import org.junit.Ignore -import org.junit.Test -import java.util.* - -private fun expectSingleFragmentActivity(): FragmentActivity { - return expectActivity { it::class.java.simpleName == "SingleFragmentActivity" } -} - -class ActivityToFragmentTests { - - @Test - fun whenActivityOpensFragment_andActivityDoesNotHaveFragmentHost_thenFragmentIsLaunchedAsSingleFragmentActivity() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(GenericFragmentKey(id)) - - val activity = expectSingleFragmentActivity() - val activeFragment = activity.supportFragmentManager.primaryNavigationFragment!! - val fragmentHandle = activeFragment.getNavigationHandle().asTyped() - assertEquals(id, fragmentHandle.key.id) - } - - @Test - fun whenActivityOpensFragmentWithChildrenStack_andActivityDoesNotHaveFragmentHost_thenFragmentAndChildrenAreLaunchedAsSingleFragmentActivity() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - - val target = GenericFragmentKey(UUID.randomUUID().toString()) - handle.forward( - GenericFragmentKey("1"), - GenericFragmentKey("2"), - target - ) - - val activity = expectSingleFragmentActivity() - val fragment = expectFragment { it.getNavigationHandle().key == target} - - val fragmentHandle = fragment.getNavigationHandle().asTyped() - assertEquals(target.id, fragmentHandle.key.id) - assertEquals(fragment, activity.supportFragmentManager.primaryNavigationFragment!!) - } - - - @Test - fun whenActivityOpensFragment_andActivityHasFragmentHostForFragment_thenFragmentIsLaunchedIntoHost() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(ActivityChildFragmentKey(id)) - - expectActivity() - val activeFragment = expectFragment() - val fragmentHandle = - activeFragment.getNavigationHandle().asTyped() - assertEquals(id, fragmentHandle.key.id) - } - - @Test - fun whenActivityReplacedByFragment_andActivityHasFragmentHostForFragment_thenFragmentIsLaunchedAsSingleActivity_andCloseLeavesNoActivityActive() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.replace(ActivityChildFragmentKey(id)) - - expectSingleFragmentActivity() - val activeFragment = expectFragment() - val fragmentHandle = - activeFragment.getNavigationHandle().asTyped() - assertEquals(id, fragmentHandle.key.id) - - fragmentHandle.close() - expectNoActivity() - } - - - @Test - fun whenActivityOpensFragment_andActivityHasFragmentHostThatDoesNotAcceptFragment_thenFragmentIsLaunchedAsSingleFragmentActivity() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(GenericFragmentKey(id)) - - val activity = expectSingleFragmentActivity() - val activeFragment = activity.supportFragmentManager.primaryNavigationFragment!! - val fragmentHandle = activeFragment.getNavigationHandle().asTyped() - assertEquals(id, fragmentHandle.key.id) - } - - @Test - fun whenActivityOpensFragmentAsReplacement_andActivityHasFragmentHostForFragment_thenFragmentIsLaunchedAsSingleFragmentActivity() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.replace(ActivityChildFragmentKey(id)) - - val activity = expectSingleFragmentActivity() - val activeFragment = activity.supportFragmentManager.primaryNavigationFragment!! - val fragmentHandle = - activeFragment.getNavigationHandle().asTyped() - assertEquals(id, fragmentHandle.key.id) - } - - @Test - fun whenActivityOpensTwoFragmentsImmediatelyIntoDifferentContainers_thenBothFragmentsAreCorrectlyAddedToContainers() { - val scenario = ActivityScenario.launch(ImmediateOpenChildActivity::class.java) - - scenario.onActivity { - assertEquals( - "one", - it.supportFragmentManager.findFragmentById(TestActivity.primaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "two", - it.supportFragmentManager.findFragmentById(TestActivity.secondaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - } - } - - @Test - fun whenActivityOpensTwoFragmentsImmediatelyIntoDifferentContainers_andThoseFragmentsOpenTwoChildrenImmediately_thenAllFragmentsAreOpenedCorrectly() { - val scenario = ActivityScenario.launch(ImmediateOpenFragmentChildActivity::class.java) - - scenario.onActivity { - val primary = - it.supportFragmentManager.findFragmentById(TestActivity.primaryFragmentContainer)!! - val secondary = - it.supportFragmentManager.findFragmentById(TestActivity.secondaryFragmentContainer)!! - - assertEquals( - "one", primary.childFragmentManager - .findFragmentById(TestFragment.primaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "two", primary.childFragmentManager - .findFragmentById(TestFragment.secondaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "one", secondary.childFragmentManager - .findFragmentById(TestFragment.primaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "two", secondary.childFragmentManager - .findFragmentById(TestFragment.secondaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - } - } - - /** - * Executing navigation instructions as a response to fragment creation (i.e. in "onCreate") may cause issues - * with attempting to access viewmodels from a detached fragment. This test should verify - * that the behaviour of the test above will continue to work after activity re-creation - */ - @Test - fun whenActivityOpensTwoFragmentsImmediatelyIntoDifferentContainers_andThoseFragmentsOpenTwoChildrenImmediately_thenAllFragmentsAreOpenedCorrectly_recreated() { - val scenario = ActivityScenario.launch(ImmediateOpenFragmentChildActivity::class.java) - scenario.recreate() - - scenario.onActivity { - val primary = - it.supportFragmentManager.findFragmentById(TestActivity.primaryFragmentContainer)!! - val secondary = - it.supportFragmentManager.findFragmentById(TestActivity.secondaryFragmentContainer)!! - - assertEquals( - "one", primary.childFragmentManager - .findFragmentById(TestFragment.primaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "two", primary.childFragmentManager - .findFragmentById(TestFragment.secondaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "one", secondary.childFragmentManager - .findFragmentById(TestFragment.primaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - - assertEquals( - "two", secondary.childFragmentManager - .findFragmentById(TestFragment.secondaryFragmentContainer)!! - .getNavigationHandle() - .asTyped() - .key.id - ) - } - } - - @Test - fun givenActivityWithChildFragment_whenMultipleChildrenAreOpenedOnActivity_andStaleChildFragmentHandleIsUsedToOpenAnotherChild_thenStaleNavigationActionIsIgnored_andOtherNavigationActionsSucceed() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - expectActivity() - .getNavigationHandle() - .forward(ActivityChildFragmentKey(UUID.randomUUID().toString())) - - val activityHandle = expectActivity().getNavigationHandle() - val fragmentHandle = expectFragment().getNavigationHandle() - - scenario.onActivity { - activityHandle - .forward(ActivityChildFragmentKey("one")) - - activityHandle - .forward(ActivityChildFragmentKey("two")) - - fragmentHandle - .forward(ActivityChildFragmentKey("three")) - - activityHandle - .forward(ActivityChildFragmentKey("four")) - } - - val id = expectFragment().getNavigationHandle().asTyped().key.id - assertEquals("four", id) - } - - @Test - fun givenActivityWithChildFragment_whenFragmentIsDetached_andStaleFragmentNavigationHandleIsUsedForNavigation_thenNothingHappens() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - expectActivity() - .getNavigationHandle() - .forward(ActivityChildFragmentKey(UUID.randomUUID().toString())) - - val fragment = expectFragment() - val fragmentHandle = fragment.getNavigationHandle() - - scenario.onActivity { - it.supportFragmentManager.beginTransaction() - .detach(fragment) - .commit() - - fragmentHandle.forward(ActivityChildFragmentKey("should not appear")) - } - - assertNull(expectActivity().supportFragmentManager.primaryNavigationFragment) - } - - @Test - fun givenFragmentOpenInActivity_whenFragmentIsClosedAfterInstanceStateIsSaved_thenNavigationIsNotClosed_untilActivityIsActiveAgain() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - expectActivity() - .getNavigationHandle() - .forward(ActivityChildFragmentKey(UUID.randomUUID().toString())) - - val fragment = expectFragment() - val fragmentHandle = fragment.getNavigationHandle() - - scenario.moveToState(Lifecycle.State.CREATED) - scenario.onActivity { - assertTrue(it.supportFragmentManager.isStateSaved) - } - fragmentHandle.close() - scenario.moveToState(Lifecycle.State.RESUMED) - expectNoFragment() - } - - @Test - fun givenFragmentOpenInActivity_whenFragmentIsClosedAfterInstanceStateIsSaved_thenNavigationIsNotClosed_untilActivityIsActiveAgain_recreation() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - expectActivity() - .getNavigationHandle() - .forward(ActivityChildFragmentKey(UUID.randomUUID().toString())) - - val fragment = expectFragment() - val fragmentHandle = fragment.getNavigationHandle() - - scenario.moveToState(Lifecycle.State.CREATED) - scenario.onActivity { - assertTrue(it.supportFragmentManager.isStateSaved) - } - fragmentHandle.close() - scenario.recreate() - scenario.moveToState(Lifecycle.State.RESUMED) - expectActivity() - expectNoFragment() - } - - @Test - fun givenTwoFragmentsOpenInActivity_whenTopFragmentIsClosedAfterInstanceStateIsSaved_thenNavigationIsNotClosed_untilActivityIsActiveAgain_andCorrectFragmentIsActive() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val firstFragmentKey = ActivityChildFragmentKey(UUID.randomUUID().toString()) - val secondFragmentKey = ActivityChildFragmentKey(UUID.randomUUID().toString()) - - expectActivity() - .getNavigationHandle() - .forward(firstFragmentKey) - - expectFragment { it.getNavigationHandle().key == firstFragmentKey } - .navigation - .forward(secondFragmentKey) - - val fragment = expectFragment { it.getNavigationHandle().key == secondFragmentKey } - val fragmentHandle = fragment.getNavigationHandle() - - scenario.moveToState(Lifecycle.State.CREATED) - scenario.onActivity { - assertTrue(it.supportFragmentManager.isStateSaved) - } - fragmentHandle.close() - scenario.moveToState(Lifecycle.State.RESUMED) - expectFragment { it.getNavigationHandle().key == firstFragmentKey } - expectNoFragment { it.getNavigationHandle().key == secondFragmentKey } - } - - @Test - fun givenTwoFragmentsOpenInActivity_whenTopFragmentIsClosedAfterInstanceStateIsSaved_thenNavigationIsNotClosed_untilActivityIsActiveAgain_andCorrectFragmentIsActive_recreation() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val firstFragmentKey = ActivityChildFragmentKey(UUID.randomUUID().toString()) - val secondFragmentKey = ActivityChildFragmentKey(UUID.randomUUID().toString()) - - expectActivity() - .getNavigationHandle() - .forward(firstFragmentKey) - - expectFragment { it.getNavigationHandle().key == firstFragmentKey } - .navigation - .forward(secondFragmentKey) - - val fragment = expectFragment { it.getNavigationHandle().key == secondFragmentKey } - val fragmentHandle = fragment.getNavigationHandle() - - scenario.moveToState(Lifecycle.State.CREATED) - scenario.onActivity { - assertTrue(it.supportFragmentManager.isStateSaved) - } - fragmentHandle.close() - scenario.recreate() - scenario.moveToState(Lifecycle.State.RESUMED) - expectFragment { it.getNavigationHandle().key == firstFragmentKey } - expectNoFragment { it.getNavigationHandle().key == secondFragmentKey } - } - - // https://github.com/isaac-udy/Enro/issues/34 - /** - * givenActivityOpensFragmentA - * andFragmentAPerformsForwardNavigationToFragmentB - * andFragmentBPerformsForwardNavigationToFragmentC - * - * whenActivityLaterPerformsForwardNavigationToFragmentD - * andFragmentDIsClosed - * - * thenFragmentCIsActiveInContainer - */ - @Test - @Ignore - fun givenActivityOpensFragment_andFragmentOpensForward_thenActivityOpensAnotherFragment_thenContainerBackstackIsRetained() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val fragmentAKey = ActivityChildFragmentKey("Fragment A") - val fragmentBKey = ActivityChildFragmentKey("Fragment B") - val fragmentCKey = ActivityChildFragmentKey("Fragment C") - val fragmentDKey = ActivityChildFragmentKey("Fragment D") - - val activity = expectActivity() - activity.getNavigationHandle() - .forward(fragmentAKey) - - expectContext { it.navigation.key == fragmentAKey } - .navigation - .forward(fragmentBKey) - - expectContext { it.navigation.key == fragmentBKey } - .navigation - .forward(fragmentCKey) - - expectContext { it.navigation.key == fragmentCKey } - - activity.getNavigationHandle() - .forward(fragmentDKey) - - expectContext { it.navigation.key == fragmentDKey } - .navigation - .close() - - expectContext { it.navigation.key == fragmentCKey } - } -} - -@Parcelize -class ImmediateOpenChildActivityKey : NavigationKey - -@NavigationDestination(ImmediateOpenChildActivityKey::class) -class ImmediateOpenChildActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(ImmediateOpenChildActivityKey()) - container(primaryFragmentContainer) { - it is GenericFragmentKey && it.id == "one" - } - container(secondaryFragmentContainer) { - it is GenericFragmentKey && it.id == "two" - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - navigation.forward(GenericFragmentKey("one")) - navigation.forward(GenericFragmentKey("two")) - } -} - -@Parcelize -class ImmediateOpenFragmentChildActivityKey : NavigationKey - -@NavigationDestination(ImmediateOpenFragmentChildActivityKey::class) -class ImmediateOpenFragmentChildActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(ImmediateOpenFragmentChildActivityKey()) - container(primaryFragmentContainer) { - it is ImmediateOpenChildFragmentKey && it.name == "one" - } - container(secondaryFragmentContainer) { - it is ImmediateOpenChildFragmentKey && it.name == "two" - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - navigation.forward(ImmediateOpenChildFragmentKey("one")) - navigation.forward(ImmediateOpenChildFragmentKey("two")) - } -} - - -@Parcelize -data class ImmediateOpenChildFragmentKey(val name: String) : NavigationKey - -@NavigationDestination(ImmediateOpenChildFragmentKey::class) -class ImmediateOpenChildFragment : TestFragment() { - private val navigation by navigationHandle { - container(primaryFragmentContainer) { - it is GenericFragmentKey && it.id == "one" - } - container(secondaryFragmentContainer) { - it is GenericFragmentKey && it.id == "two" - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - navigation.forward(GenericFragmentKey("one")) - navigation.forward(GenericFragmentKey("two")) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/EnroContainerControllerStabilityTests.kt b/enro/src/androidTest/java/dev/enro/core/EnroContainerControllerStabilityTests.kt deleted file mode 100644 index 417b0e1ef..000000000 --- a/enro/src/androidTest/java/dev/enro/core/EnroContainerControllerStabilityTests.kt +++ /dev/null @@ -1,189 +0,0 @@ -package dev.enro.core - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Column -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.test.core.app.ActivityScenario -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.compose.EmptyBehavior -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.navigationHandle -import dev.enro.core.compose.rememberEnroContainerController -import kotlinx.parcelize.Parcelize -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Rule -import org.junit.Test -import java.util.* - -class EnroContainerControllerStabilityTests { - - @get:Rule - val composeContentRule = createComposeRule() - - @Test - fun whenActivityIsRecreated_thenStabilitySnapshotIsStable() { - val scenario = ActivityScenario.launch(ComposableTestActivity::class.java) - val snapshot = getSnapshot() - scenario.recreate() - val secondSnapshot = getSnapshot() - assertEquals(snapshot, secondSnapshot) - } - - @Test - fun whenSelectedControllerChanges_thenStabilitySnapshotIsCompletelyDifferent() { - val scenario = ActivityScenario.launch(ComposableTestActivity::class.java) - val snapshot = getSnapshot() - scenario.onActivity { - it.selectedIndex.value = 1 - } - val secondSnapshot = getSnapshot() - assertSnapshotsAreCompletelyDifferent(snapshot, secondSnapshot) - } - - @Test - fun whenSelectedControllerChanges_andThenChangesBackToOriginalController_thenStabilitySnapshotIsStable() { - val scenario = ActivityScenario.launch(ComposableTestActivity::class.java) - - val snapshot = getSnapshot() - scenario.onActivity { - it.selectedIndex.value = 1 - } - val secondSnapshot = getSnapshot() - scenario.onActivity { - it.selectedIndex.value = 0 - } - val thirdSnapshot = getSnapshot() - - assertEquals(snapshot, thirdSnapshot) - assertSnapshotsAreCompletelyDifferent(snapshot, secondSnapshot) - } - - private fun getTextFromNode(testTag: String): String { - return composeContentRule.onNodeWithTag(testTag) - .fetchSemanticsNode() - .config[SemanticsProperties.Text] - .first() - .text - } - - private fun getSnapshot(): EnroStabilitySnapshot = EnroStabilitySnapshot( - viewModelHashCode = getTextFromNode("viewModelHashCode"), - viewModelStoreHashCode = getTextFromNode("viewModelStoreHashCode"), - navigationId = getTextFromNode("navigationId"), - keyId = getTextFromNode("keyId"), - rememberSaveableItem = getTextFromNode("rememberSaveableItem"), - ) -} - -class ComposableTestActivity : AppCompatActivity() { - internal val selectedIndex = mutableStateOf(0) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val screens = listOf( - EnroStabilityKey(UUID.randomUUID().toString()), - EnroStabilityKey(UUID.randomUUID().toString()), - EnroStabilityKey(UUID.randomUUID().toString()), - ) - - setContent { - val controllers = screens.map { key -> - val instruction = NavigationInstruction.Forward(key) - rememberEnroContainerController( - initialState = listOf(instruction), - accept = { false }, - emptyBehavior = EmptyBehavior.CloseParent - ) - } - EnroContainer( - controller = controllers[selectedIndex.value], - ) - } - } -} - -@Parcelize -class EnroStabilityKey( - val id: String -) : NavigationKey - -class EnroStabilityViewModel : ViewModel() - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(EnroStabilityKey::class) -fun EnroStabilityScreen() { - val navigation = navigationHandle() - val viewModelHashCode = viewModel().hashCode().toString() - val viewModelStoreHashCode = LocalViewModelStoreOwner.current.hashCode().toString() - - val navigationId = navigation.id - val keyId = navigation.key.id - - val rememberSaveableItem = rememberSaveable { UUID.randomUUID().toString() } - - Column { - Text( - text = viewModelHashCode, - modifier = Modifier.semantics { - testTag = "viewModelHashCode" - } - ) - Text( - text = viewModelStoreHashCode, - modifier = Modifier.semantics { - testTag = "viewModelStoreHashCode" - } - ) - Text( - text = navigationId, - modifier = Modifier.semantics { - testTag = "navigationId" - } - ) - Text( - text = keyId, - modifier = Modifier.semantics { - testTag = "keyId" - } - ) - Text( - text = rememberSaveableItem, - modifier = Modifier.semantics { - testTag = "rememberSaveableItem" - } - ) - } -} - -data class EnroStabilitySnapshot( - val viewModelHashCode: String, - val viewModelStoreHashCode: String, - val navigationId: String, - val keyId: String, - val rememberSaveableItem: String, -) - -fun assertSnapshotsAreCompletelyDifferent(left: EnroStabilitySnapshot, right: EnroStabilitySnapshot) { - assertNotEquals(left.viewModelHashCode, right.viewModelHashCode) - assertNotEquals(left.viewModelStoreHashCode, right.viewModelStoreHashCode) - assertNotEquals(left.navigationId, right.navigationId) - assertNotEquals(left.keyId, right.keyId) - assertNotEquals(left.rememberSaveableItem, right.rememberSaveableItem) -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/FragmentToComposableTests.kt b/enro/src/androidTest/java/dev/enro/core/FragmentToComposableTests.kt deleted file mode 100644 index 90b7d9b15..000000000 --- a/enro/src/androidTest/java/dev/enro/core/FragmentToComposableTests.kt +++ /dev/null @@ -1,27 +0,0 @@ -package dev.enro.core - -import androidx.test.core.app.ActivityScenario -import dev.enro.* -import dev.enro.core.compose.ComposableDestination -import org.junit.Test -import java.util.* - -class FragmentToComposableTests { - - @Test - fun whenFragmentOpensComposable_andFragmentDoesNotHaveComposeContainer_thenComposableIsLaunchedAsComposableFragmentHost() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(ActivityChildFragmentKey(id)) - - val parentFragment = expectFragment() - - parentFragment.getNavigationHandle().forward(GenericComposableKey(id)) - - expectContext { - it.navigation.key.id == id - } - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/FragmentToFragmentTests.kt b/enro/src/androidTest/java/dev/enro/core/FragmentToFragmentTests.kt deleted file mode 100644 index 3db95ad0f..000000000 --- a/enro/src/androidTest/java/dev/enro/core/FragmentToFragmentTests.kt +++ /dev/null @@ -1,81 +0,0 @@ -package dev.enro.core - -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.commit -import androidx.fragment.app.commitNow -import androidx.test.core.app.ActivityScenario -import dev.enro.* -import dev.enro.expectFragment -import junit.framework.TestCase -import org.junit.Test -import java.util.* - -private fun expectSingleFragmentActivity(): FragmentActivity { - return expectActivity { it::class.java.simpleName == "SingleFragmentActivity"} -} - -class FragmentToFragmentTests { - - @Test - fun whenFragmentOpensFragment_andFragmentIsInAHost_thenFragmentIsLaunchedIntoHost() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(ActivityChildFragmentKey(id)) - - val parentFragment = expectFragment() - val id2 = UUID.randomUUID().toString() - parentFragment.getNavigationHandle().forward(ActivityChildFragmentTwoKey(id2)) - - val childFragment = expectFragment() - val fragmentHandle = childFragment.getNavigationHandle().asTyped() - TestCase.assertEquals(id2, fragmentHandle.key.id) - } - - @Test - fun whenFragmentOpensFragment_andFragmentIsNotInAHost_thenFragmentIsLaunchedAsSingleFragmentActivity() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - val handle = scenario.getNavigationHandle() - - val id = UUID.randomUUID().toString() - handle.forward(ActivityChildFragmentKey(id)) - - val activity = expectSingleFragmentActivity() - val parentFragment = activity.supportFragmentManager.primaryNavigationFragment!! - val id2 = UUID.randomUUID().toString() - parentFragment.getNavigationHandle().forward(ActivityChildFragmentTwoKey(id2)) - - val activity2 = expectSingleFragmentActivity() - val childFragment = activity2.supportFragmentManager.primaryNavigationFragment!! - val fragmentHandle = childFragment.getNavigationHandle().asTyped() - TestCase.assertEquals(id2, fragmentHandle.key.id) - } - - @Test - fun whenFragmentOpensFragment_andFragmentIsInAHost_andIsDestroyed_thenClosingChildFragmentCreatesNewParentFragment() { - val scenario = ActivityScenario.launch(ActivityWithFragments::class.java) - val handle = scenario.getNavigationHandle() - - val id = "UUID.randomUUID().toString()" - handle.forward(ActivityChildFragmentKey(id)) - - val parentFragment = expectFragment() - val id2 = UUID.randomUUID().toString() - parentFragment.getNavigationHandle().forward(ActivityChildFragmentTwoKey(id2)) - - val parentFragmentManager = parentFragment.parentFragmentManager - - val childFragment = expectFragment() - val fragmentHandle = childFragment.getNavigationHandle().asTyped() - TestCase.assertEquals(id2, fragmentHandle.key.id) - - // This will destroy the parent fragment, making it unavailable to re-use on close - parentFragmentManager.commit { remove(parentFragment) } - - childFragment.getNavigationHandle().close() - val newParentFragment = expectFragment() - TestCase.assertEquals(id, newParentFragment.getNavigationHandle().asTyped().key.id) - TestCase.assertNotSame(parentFragment, newParentFragment) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/PluginTests.kt b/enro/src/androidTest/java/dev/enro/core/PluginTests.kt deleted file mode 100644 index 64543290a..000000000 --- a/enro/src/androidTest/java/dev/enro/core/PluginTests.kt +++ /dev/null @@ -1,163 +0,0 @@ -package dev.enro.core - -import androidx.test.core.app.ActivityScenario -import dev.enro.* -import kotlinx.parcelize.Parcelize -import dev.enro.annotations.NavigationDestination -import junit.framework.TestCase.assertEquals -import org.junit.Test -import java.util.* - -class PluginTests { - - @Test - fun whenActivityIsStarted_thenActivityIsActive() { - ActivityScenario.launch(PluginTestActivity::class.java) - - assertEquals( - expectContext() - .navigation - .key, - TestPlugin.activeKey - ) - } - - @Test - fun whenFragmentIsStarted_thenFragmentIsActive() { - ActivityScenario.launch(PluginTestActivity::class.java) - - expectContext() - .navigation - .forward(PluginPrimaryTestFragmentKey()) - - val context = expectContext() - waitFor { context.navigation.key == TestPlugin.activeKey } - } - - @Test - fun whenFragmentIsStartedAndClosed_thenActivityIsActive() { - ActivityScenario.launch(PluginTestActivity::class.java) - - expectContext() - .navigation - .forward(PluginPrimaryTestFragmentKey()) - - expectContext() - .navigation - .close() - - val context = expectContext() - waitFor { context.navigation.key == TestPlugin.activeKey } - - } - - @Test - fun whenFragmentIsStarted_thenSecondaryFragmentIsStarted_thenSecondaryFragmentIsActive() { - ActivityScenario.launch(PluginTestActivity::class.java) - - val activityNavigation = expectContext() - .navigation - - activityNavigation.forward(PluginPrimaryTestFragmentKey()) - expectContext() - - activityNavigation.forward(PluginSecondaryTestFragmentKey()) - - val context = expectContext() - waitFor { - context.navigation.key == TestPlugin.activeKey - } - } - - @Test - fun whenFragmentIsStarted_thenSecondaryFragmentIsStartedAndClosed_thenPrimaryFragmentIsActive() { - ActivityScenario.launch(PluginTestActivity::class.java) - - val activityNavigation = expectContext() - .navigation - - activityNavigation.forward(PluginPrimaryTestFragmentKey()) - expectContext() - - activityNavigation.forward(PluginSecondaryTestFragmentKey()) - expectContext() - .navigation - .close() - - val context = expectContext() - waitFor { - context.navigation.key == TestPlugin.activeKey - } - } - - @Test - fun whenFragmentIsStartedWithNestedChild_thenSecondaryFragmentIsStartedAndClosed_thenPrimaryFragmentIsActive() { - ActivityScenario.launch(PluginTestActivity::class.java) - - val activityNavigation = expectContext() - .navigation - - activityNavigation.forward(PluginPrimaryTestFragmentKey()) - expectContext() - .navigation - .forward(PluginPrimaryTestFragmentKey("nested")) - - activityNavigation.forward(PluginSecondaryTestFragmentKey()) - expectContext() - .navigation - .close() - - val context = expectContext { - it.navigation.key.keyId == "nested" - } - waitFor { - context.navigation.key == TestPlugin.activeKey - } - } -} - -@Parcelize -data class PluginTestActivityKey(val keyId: String = UUID.randomUUID().toString()) : NavigationKey - -@NavigationDestination(PluginTestActivityKey::class) -class PluginTestActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(PluginTestActivityKey()) - container(primaryFragmentContainer) { - it is PluginPrimaryTestFragmentKey - } - container(secondaryFragmentContainer) { - it is PluginSecondaryTestFragmentKey - } - } -} - -@Parcelize -data class PluginPrimaryTestFragmentKey(val keyId: String = UUID.randomUUID().toString()) : NavigationKey - -@NavigationDestination(PluginPrimaryTestFragmentKey::class) -class PluginPrimaryTestFragment : TestFragment() { - private val navigation by navigationHandle { - container(primaryFragmentContainer) { - it is PluginPrimaryTestFragmentKey - } - container(secondaryFragmentContainer) { - it is PluginSecondaryTestFragmentKey - } - } -} - -@Parcelize -data class PluginSecondaryTestFragmentKey(val keyId: String = UUID.randomUUID().toString()) : NavigationKey - -@NavigationDestination(PluginSecondaryTestFragmentKey::class) -class PluginSecondaryTestFragment : TestFragment() { - private val navigation by navigationHandle { - container(primaryFragmentContainer) { - it is PluginPrimaryTestFragmentKey - } - container(secondaryFragmentContainer) { - it is PluginSecondaryTestFragmentKey - } - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt b/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt deleted file mode 100644 index e2828362c..000000000 --- a/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package dev.enro.core - -import android.content.Intent -import androidx.test.core.app.ActivityScenario -import junit.framework.Assert.* -import dev.enro.* -import org.junit.Test - -class UnboundActivitiesTest { - - @Test - fun whenUnboundActivityIsOpened_thenNavigationKeyIsUnbound() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.startActivity(Intent(it, UnboundActivity::class.java)) - } - val unboundActivity = expectActivity() - val unboundHandle = unboundActivity.getNavigationHandle() - - lateinit var caught: Throwable - try { - val key = unboundHandle.key - } - catch (t: Throwable) { - caught = t - } - assertTrue(caught is IllegalStateException) - } - - @Test - fun whenUnboundActivityIsOpened_thenUnboundActivityHasAnId() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.startActivity(Intent(it, UnboundActivity::class.java)) - } - val unboundActivity = expectActivity() - val unboundHandle = unboundActivity.getNavigationHandle() - - assertNotNull(unboundHandle.id) - } - - @Test - fun whenUnboundActivityIsRecreated_thenUnboundActivityIdIsStable() { - val scenario = ActivityScenario.launch(UnboundActivity::class.java) - val id = expectActivity().getNavigationHandle().id - scenario.recreate() - - val recreatedId = expectActivity().getNavigationHandle().id - - assertEquals(id, recreatedId) - } - - @Test - fun givenUnboundActivity_whenNavigationHandleIsUsedToClose_thenActivityClosesCorrectly() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.startActivity(Intent(it, UnboundActivity::class.java)) - } - val unboundActivity = expectActivity() - unboundActivity.getNavigationHandle().close() - - val defaultActivity = expectActivity() - assertNotNull(defaultActivity) - } - - @Test - fun givenUnboundActivity_whenNavigationHandleIsUsedToOpenActivityKey_thenActivityIsOpenedCorrectly() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.startActivity(Intent(it, UnboundActivity::class.java)) - } - val unboundActivity = expectActivity() - unboundActivity.getNavigationHandle().forward(GenericActivityKey("opened-from-unbound")) - - val genericActivity = expectActivity() - assertEquals("opened-from-unbound", genericActivity.getNavigationHandle().asTyped().key.id) - } - - @Test - fun givenUnboundActivity_whenNavigationHandleIsUsedToOpenFragmentKey_thenFragmentIsOpenedCorrectly() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.startActivity(Intent(it, UnboundActivity::class.java)) - } - val unboundActivity = expectActivity() - unboundActivity.getNavigationHandle().forward(GenericFragmentKey("opened-from-unbound")) - - val genericActivity = expectFragment() - assertEquals("opened-from-unbound", genericActivity.getNavigationHandle().asTyped().key.id) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/UnboundFragmentsTest.kt b/enro/src/androidTest/java/dev/enro/core/UnboundFragmentsTest.kt deleted file mode 100644 index 757860e19..000000000 --- a/enro/src/androidTest/java/dev/enro/core/UnboundFragmentsTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -package dev.enro.core - -import androidx.fragment.app.commitNow -import androidx.test.core.app.ActivityScenario -import junit.framework.Assert.* -import dev.enro.* -import org.junit.Ignore -import org.junit.Test - -class UnboundFragmentsTest { - - @Test - fun whenUnboundFragmentIsOpened_thenNavigationKeyIsUnbound() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.supportFragmentManager.commitNow { - val fragment = UnboundFragment() - replace(android.R.id.content, fragment) - setPrimaryNavigationFragment(fragment) - } - } - val unboundFragment = expectFragment() - val unboundHandle = unboundFragment.getNavigationHandle() - - lateinit var caught: Throwable - try { - val key = unboundHandle.key - } - catch (t: Throwable) { - caught = t - } - assertTrue(caught is IllegalStateException) - assertNotNull(caught.message) - assertTrue(caught.message!!.matches(Regex("The navigation handle for the context UnboundFragment.*has no NavigationKey"))) - } - - @Test - fun whenUnboundFragmentIsOpened_thenUnboundActivityHasAnId() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.supportFragmentManager.commitNow { - val fragment = UnboundFragment() - replace(android.R.id.content, fragment) - setPrimaryNavigationFragment(fragment) - } - } - val unboundFragment = expectFragment() - val unboundHandle = unboundFragment.getNavigationHandle() - - assertNotNull(unboundHandle.id) - } - - @Test - fun givenUnboundFragment_whenNavigationHandleIsUsedToClose_thenFragmentClosesCorrectly() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.supportFragmentManager.commitNow { - val fragment = UnboundFragment() - replace(android.R.id.content, fragment) - setPrimaryNavigationFragment(fragment) - } - } - val unboundFragment = expectFragment() - unboundFragment.getNavigationHandle().close() - - val defaultActivity = expectActivity() - val fragmentWasRemoved = expectNoFragment() - assertNotNull(defaultActivity) - assertTrue(fragmentWasRemoved) - } - - @Test - fun givenUnboundFragment_whenNavigationHandleIsUsedToOpenActivityKey_thenActivityIsOpenedCorrectly() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.supportFragmentManager.commitNow { - val fragment = UnboundFragment() - replace(android.R.id.content, fragment) - setPrimaryNavigationFragment(fragment) - } - } - val unboundFragment = expectFragment() - unboundFragment.getNavigationHandle().forward(GenericActivityKey("opened-from-unbound")) - - val genericActivity = expectActivity() - assertEquals("opened-from-unbound", genericActivity.getNavigationHandle().asTyped().key.id) - } - - @Test - fun givenUnboundFragment_whenNavigationHandleIsUsedToOpenFragmentKey_thenFragmentIsOpenedCorrectly() { - val scenario = ActivityScenario.launch(DefaultActivity::class.java) - scenario.onActivity { - it.supportFragmentManager.commitNow { - val fragment = UnboundFragment() - replace(android.R.id.content, fragment) - setPrimaryNavigationFragment(fragment) - } - } - val unboundFragment = expectFragment() - unboundFragment.getNavigationHandle().forward(GenericFragmentKey("opened-from-unbound")) - - val genericActivity = expectFragment() - assertEquals("opened-from-unbound", genericActivity.getNavigationHandle().asTyped().key.id) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/overrides/ActivityToActivityOverrideTests.kt b/enro/src/androidTest/java/dev/enro/core/overrides/ActivityToActivityOverrideTests.kt deleted file mode 100644 index a58ba5cf9..000000000 --- a/enro/src/androidTest/java/dev/enro/core/overrides/ActivityToActivityOverrideTests.kt +++ /dev/null @@ -1,197 +0,0 @@ -package dev.enro.core.overrides - -import android.content.Intent -import androidx.test.core.app.ActivityScenario -import junit.framework.Assert.assertTrue -import dev.enro.* -import dev.enro.core.* -import dev.enro.core.controller.navigationController -import org.junit.Test - -class ActivityToActivityOverrideTests() { - - @Test - fun givenActivityToActivityOverride_whenInitialActivityOpenedWithDefaultKey_whenActivityIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - ActivityScenario.launch(DefaultActivity::class.java) - .getNavigationHandle() - .forward(GenericActivityKey("override test")) - - expectActivity() - - waitFor { preOpenCalled } - waitFor { openCalled } - waitFor { postOpenCalled } - } - - @Test - fun givenActivityToActivityOverride_whenInitialActivityOpenedWithDefaultKey_whenActivityIsClosed_thenOverrideIsCalled() { - var preCloseCalled = false - var closeOverrideCalled = false - application.navigationController.addOverride ( - createOverride { - closed { - closeOverrideCalled = true - defaultClosed(it) - } - preClosed { - preCloseCalled = true - } - } - ) - - ActivityScenario.launch(DefaultActivity::class.java) - .getNavigationHandle() - .forward(GenericActivityKey("override test")) - - expectActivity() - .getNavigationHandle() - .close() - - expectActivity() - - waitFor { closeOverrideCalled } - waitFor { preCloseCalled } - } - - @Test - fun givenActivityToActivityOverride_whenActivityIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride{ - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - val intent = Intent(application, GenericActivity::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - GenericActivityKey(id = "override test") - ) - ) - - ActivityScenario.launch(intent) - .getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity { it.getNavigationHandle().asTyped().key.id == "override test 2" } - - waitFor { preOpenCalled } - waitFor { openCalled } - waitFor { postOpenCalled } - } - - @Test - fun givenActivityToActivityOverride_whenActivityIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - closed { - closeOverrideCalled = true - defaultClosed(it) - } - preClosed { preCloseCalled = true } - } - ) - - val intent = Intent(application, GenericActivity::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - GenericActivityKey(id = "override test") - ) - ) - - ActivityScenario.launch(intent) - .getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity { it.getNavigationHandle().asTyped().key.id == "override test 2" } - .getNavigationHandle() - .close() - - expectActivity() - - waitFor { closeOverrideCalled } - waitFor { preCloseCalled } - } - - - @Test - fun givenUnboundActivityToActivityOverride_whenActivityIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride{ - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - - ActivityScenario.launch(UnboundActivity::class.java) - expectActivity().getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity() - - waitFor { preOpenCalled } - waitFor { openCalled } - waitFor { postOpenCalled } - } - - @Test - fun givenUnboundActivityToActivityOverride_whenActivityIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - closed { - closeOverrideCalled = true - defaultClosed(it) - } - preClosed { preCloseCalled = true } - } - ) - - ActivityScenario.launch(UnboundActivity::class.java) - expectActivity().getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity { it.getNavigationHandle().asTyped().key.id == "override test 2" } - .getNavigationHandle() - .close() - - expectActivity() - - waitFor { closeOverrideCalled } - waitFor { preCloseCalled } - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/overrides/ActivityToFragmentOverrideTests.kt b/enro/src/androidTest/java/dev/enro/core/overrides/ActivityToFragmentOverrideTests.kt deleted file mode 100644 index e4ea06168..000000000 --- a/enro/src/androidTest/java/dev/enro/core/overrides/ActivityToFragmentOverrideTests.kt +++ /dev/null @@ -1,206 +0,0 @@ -package dev.enro.core.overrides - -import android.content.Intent -import androidx.test.core.app.ActivityScenario -import junit.framework.Assert.assertTrue -import dev.enro.* -import dev.enro.core.* -import dev.enro.core.controller.navigationController -import org.junit.Test - -class ActivityToFragmentOverrideTests() { - - @Test - fun givenActivityToFragmentOverride_andActivityDoesNotSupportFragment_whenInitialActivityOpenedWithDefaultKey_whenFragmentIsLaunched_whenActivityDoes_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - ActivityScenario.launch(DefaultActivity::class.java) - .getNavigationHandle() - .forward(GenericFragmentKey("override test")) - - expectFragment() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenActivityToFragmentOverride_andActivityDoesNotSupportFragment_whenInitialActivityOpenedWithDefaultKey_whenFragmentIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - ActivityScenario.launch(DefaultActivity::class.java) - .getNavigationHandle() - .forward(GenericFragmentKey("override test")) - - expectFragment() - .getNavigationHandle().close() - - expectActivity() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } - - @Test - fun givenActivityToFragmentOverride_andActivityDoesNotSupportFragment_whenFragmentIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - val intent = Intent(application, GenericActivity::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - GenericActivityKey(id = "override test") - ) - ) - - ActivityScenario.launch(intent) - .getNavigationHandle() - .forward(GenericFragmentKey("override test 2")) - - expectFragment() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenActivityToFragmentOverride_andActivityDoesNotSupportFragment_whenFragmentIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - val intent = Intent(application, GenericActivity::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - GenericActivityKey(id = "override test") - ) - ) - - ActivityScenario.launch(intent) - .getNavigationHandle() - .forward(GenericFragmentKey("override test 2")) - - expectFragment() - .getNavigationHandle() - .close() - - expectActivity() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } - - @Test - fun givenActivityToFragmentOverride_whenFragmentIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - val intent = Intent(application, ActivityWithFragments::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - ActivityWithFragmentsKey(id = "override test") - ) - ) - - ActivityScenario.launch(intent) - .getNavigationHandle() - .forward(ActivityChildFragmentKey("override test 2")) - - expectFragment() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenActivityToFragmentOverride_whenFragmentIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - val intent = Intent(application, ActivityWithFragments::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - ActivityWithFragmentsKey(id = "override test") - ) - ) - - ActivityScenario.launch(intent) - .getNavigationHandle() - .forward(ActivityChildFragmentKey("override test 2")) - - expectFragment() - .getNavigationHandle() - .close() - - expectActivity() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/overrides/FragmentToActivityOverrideTests.kt b/enro/src/androidTest/java/dev/enro/core/overrides/FragmentToActivityOverrideTests.kt deleted file mode 100644 index 1c5b141e0..000000000 --- a/enro/src/androidTest/java/dev/enro/core/overrides/FragmentToActivityOverrideTests.kt +++ /dev/null @@ -1,155 +0,0 @@ -package dev.enro.core.overrides - -import android.content.Intent -import androidx.test.core.app.ActivityScenario -import junit.framework.Assert.assertTrue -import dev.enro.* -import dev.enro.core.* -import dev.enro.core.controller.navigationController -import org.junit.Before -import org.junit.Test - -class FragmentToActivityOverrideTests() { - - lateinit var initialScenario: ActivityScenario - - @Before - fun before() { - val intent = Intent(application, ActivityWithFragments::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - ActivityWithFragmentsKey(id = "initial activity") - ) - ) - - initialScenario = ActivityScenario.launch(intent) - } - - @Test - fun givenFragmentToActivityOverride_whenFragmentIsStandalone_whenActivityIsLaunchedFrom_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride{ - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(GenericFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenFragmentToActivityOverride_whenFragmentIsStandalone_whenActivityIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true} - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(GenericFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity() - .getNavigationHandle() - .close() - - expectFragment() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } - - - @Test - fun givenFragmentToActivityOverride_whenFragmentIsNested_whenActivityIsLaunchedFrom_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(ActivityChildFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenFragmentToActivityOverride_whenFragmentIsNested_whenActivityIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(ActivityChildFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(GenericActivityKey("override test 2")) - - expectActivity() - .getNavigationHandle() - .close() - - expectFragment() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } - -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/core/overrides/FragmentToFragmentOverrideTests.kt b/enro/src/androidTest/java/dev/enro/core/overrides/FragmentToFragmentOverrideTests.kt deleted file mode 100644 index 91b21fdcf..000000000 --- a/enro/src/androidTest/java/dev/enro/core/overrides/FragmentToFragmentOverrideTests.kt +++ /dev/null @@ -1,216 +0,0 @@ -package dev.enro.core.overrides - -import android.content.Intent -import androidx.test.core.app.ActivityScenario -import junit.framework.Assert.assertTrue -import dev.enro.* -import dev.enro.core.* -import dev.enro.core.controller.navigationController -import org.junit.Before -import org.junit.Test - -class FragmentToFragmentOverrideTests() { - - lateinit var initialScenario: ActivityScenario - - @Before - fun before() { - val intent = Intent(application, ActivityWithFragments::class.java) - .addOpenInstruction( - NavigationInstruction.Forward( - ActivityWithFragmentsKey(id = "initial activity") - ) - ) - - initialScenario = ActivityScenario.launch(intent) - } - - @Test - fun givenFragmentToFragmentOverride_whenFragmentIsStandalone_whenFragmentIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(GenericFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(ActivityChildFragmentKey("override test 2")) - - expectFragment() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenFragmentToFragmentOverride_whenFragmentIsStandalone_whenFragmentIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(GenericFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(ActivityChildFragmentKey("override test 2")) - - expectFragment() - .getNavigationHandle() - .close() - - expectFragment() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } - - - @Test - fun givenFragmentToFragmentOverride_whenFragmentIsNested_andTargetIsStandalone_whenFragmentIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(ActivityChildFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(GenericFragmentKey("override test 2")) - - expectFragment() - - waitFor { preOpenCalled } - waitFor { openCalled } - waitFor { postOpenCalled } - } - - @Test - fun givenFragmentToFragmentOverride_whenFragmentIsNested_andTargetIsStandalone_whenFragmentIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(ActivityChildFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(GenericFragmentKey("override test 2")) - - expectFragment() - .getNavigationHandle() - .close() - - expectFragment() - - waitFor { closeOverrideCalled } - waitFor { preCloseCalled } - } - - - @Test - fun givenFragmentToFragmentOverride_whenFragmentIsNested_andTargetIsNested_whenFragmentIsLaunched_thenOverrideIsCalled() { - var preOpenCalled = false - var openCalled = false - var postOpenCalled = false - - application.navigationController.addOverride( - createOverride { - preOpened { preOpenCalled = true } - postOpened { postOpenCalled = true } - opened { - openCalled = true - defaultOpened(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(ActivityChildFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(ActivityChildFragmentTwoKey("override test 2")) - - expectFragment() - - assertTrue(preOpenCalled) - assertTrue(openCalled) - assertTrue(postOpenCalled) - } - - @Test - fun givenFragmentToFragmentOverride_whenFragmentIsNested_andTargetIsNested_whenFragmentIsClosed_thenOverrideIsCalled() { - var closeOverrideCalled = false - var preCloseCalled = false - application.navigationController.addOverride( - createOverride { - preClosed { preCloseCalled = true } - closed { - closeOverrideCalled = true - defaultClosed(it) - } - } - ) - - initialScenario.getNavigationHandle() - .forward(ActivityChildFragmentKey("override test")) - - expectFragment() - .getNavigationHandle() - .forward(ActivityChildFragmentTwoKey("override test 2")) - - expectFragment() - .getNavigationHandle() - .close() - - expectFragment() - - assertTrue(closeOverrideCalled) - assertTrue(preCloseCalled) - } - -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/result/ComposableListResultTests.kt b/enro/src/androidTest/java/dev/enro/result/ComposableListResultTests.kt deleted file mode 100644 index 1b36d3126..000000000 --- a/enro/src/androidTest/java/dev/enro/result/ComposableListResultTests.kt +++ /dev/null @@ -1,190 +0,0 @@ -package dev.enro.result - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.* -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.compose.ui.unit.dp -import dev.enro.DefaultActivity -import dev.enro.core.compose.registerForNavigationResult -import dev.enro.getActiveEnroResultChannels -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import java.util.* -import java.util.concurrent.atomic.AtomicInteger - - -class ComposableListResultTests { - @get:Rule - val composeContentRule = createAndroidComposeRule() - - @Test - fun whenListItemWithResultIsRenderedOnItsOwn_thenResultIsRetrievedSuccessfully() { - val id = UUID.randomUUID().toString() - composeContentRule.setContent { - ListItemWithResult(id = id) - } - assertResultIsReceivedFor(id) - } - - @Test - fun whenMultipleListItemWithResultsAreRendered_thenResultIsRetrievedSuccessfullyToTheCorrectItem() { - val ids = List(5) { UUID.randomUUID().toString() } - composeContentRule.setContent { - Column { - ids.forEach { - ListItemWithResult(id = it) - } - } - } - - assertResultIsReceivedFor(ids[0]) - assertResultIsReceivedFor(ids[2]) - assertResultIsReceivedFor(ids[4]) - } - - @Test - fun whenMultipleListItemWithResultsAreRenderedInLazyColumn_thenResultIsRetrievedSuccessfullyToTheCorrectItem() { - val ids = List(500) { UUID.randomUUID().toString() } - val state = LazyListState() - val scrollTarget = mutableStateOf(0) - composeContentRule.setContent { - LazyColumn( - state = state - ) { - items(ids) { - ListItemWithResult(id = it) - } - } - LaunchedEffect(scrollTarget.value) { - state.animateScrollToItem(scrollTarget.value) - } - } - scrollTarget.value = 100 - composeContentRule.waitForIdle() - assertResultIsReceivedFor(ids[100]) - - scrollTarget.value = 460 - composeContentRule.waitForIdle() - assertResultIsReceivedFor(ids[460]) - - scrollTarget.value = 10 - composeContentRule.waitForIdle() - assertResultIsReceivedFor(ids[10]) - - scrollTarget.value = 420 - composeContentRule.waitForIdle() - assertResultIsReceivedFor(ids[420]) - - scrollTarget.value = 0 - composeContentRule.waitForIdle() - assertResultIsReceivedFor(ids[0]) - } - - @Test - fun whenMultipleListItemWithResultsAreRendered_andActivityIsDestroyed_thenResultChannelsAreCleanedUp() { - val ids = List(5) { UUID.randomUUID().toString() } - composeContentRule.setContent { - Column { - ids.forEach { - ListItemWithResult(id = it) - } - } - } - Assert.assertEquals(5, getActiveEnroResultChannels().size) - composeContentRule.activityRule.scenario.close() - Assert.assertEquals(0, getActiveEnroResultChannels().size) - } - - @Test - fun whenHundredsOfListItemWithResultsAreRendered_andScreenIsScrolled_thenNonVisibleResultChannelsAreCleanedUp() { - val ids = List(5000) { UUID.randomUUID().toString() } - val state = LazyListState() - var scrollFinished = false - val activeItems = AtomicInteger(0) - composeContentRule.setContent { - val screenHeight = with(LocalDensity.current) { - LocalConfiguration.current.screenHeightDp.dp.toPx() - } - - LazyColumn( - state = state - ) { - items(ids) { - ListItemWithResult(id = it) - DisposableEffect(true) { - activeItems.incrementAndGet() - onDispose { - activeItems.decrementAndGet() - } - } - } - } - - LaunchedEffect(true) { - while(state.firstVisibleItemIndex < 500) { - state.animateScrollBy(screenHeight * 0.2f, tween(easing = LinearEasing)) - } - scrollFinished = true - } - } - composeContentRule.mainClock.advanceTimeUntil(2 * 60 * 1000) { scrollFinished } - composeContentRule.waitForIdle() - - // The number of items, as recorded by a DisposableEffect, should match the number of active ResultChannels - Assert.assertEquals(activeItems.get(), getActiveEnroResultChannels().size) - } - - private fun assertResultIsReceivedFor(id: String) { - composeContentRule.onNodeWithTag("result@${id}").assertTextEquals("EMPTY") - composeContentRule.onNodeWithTag("button@${id}").performClick() - composeContentRule.onNodeWithTag("result@${id}").assertTextEquals(id.reversed()) - } -} - -@Composable -private fun ListItemWithResult( - id: String, -) { - val title = remember { mutableStateOf("EMPTY") } - val channel = registerForNavigationResult { - title.value = it - } - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .testTag("row@$id") - ) { - Button( - onClick = { - channel.open(ImmediateSyntheticResultKey(id)) - }, - content = { - Text(text = "Get Result") - }, - modifier = Modifier.testTag("button@$id") - ) - Text( - text = title.value, - modifier = Modifier.testTag("result@$id") - ) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/result/ComposableRecyclerViewResultTests.kt b/enro/src/androidTest/java/dev/enro/result/ComposableRecyclerViewResultTests.kt deleted file mode 100644 index 0689a19e0..000000000 --- a/enro/src/androidTest/java/dev/enro/result/ComposableRecyclerViewResultTests.kt +++ /dev/null @@ -1,281 +0,0 @@ -package dev.enro.result - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.compose.ui.unit.dp -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.compose.registerForNavigationResult -import dev.enro.core.navigationHandle -import dev.enro.getActiveEnroResultChannels -import kotlinx.parcelize.Parcelize -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import java.util.* - - -class ComposableRecyclerViewResultTests { - @get:Rule - val composeContentRule = createAndroidComposeRule() - - @Test - fun whenListItemWithResultIsRenderedOnItsOwn_thenResultIsRetrievedSuccessfully() { - val scenario = composeContentRule.activityRule.scenario - scenario.onActivity { - it.setupItems(1) - } - scenario.assertResultIsReceivedFor(0) - } - - @Test - fun whenMultipleListItemWithResultsAreRendered_andActivityIsDestroyed_thenResultChannelsAreCleanedUp() { - val scenario = composeContentRule.activityRule.scenario - scenario.onActivity { - it.setupItems(5) - } - Thread.sleep(1000) - assertEquals(5, getActiveEnroResultChannels().size) - scenario.close() - assertEquals(0, getActiveEnroResultChannels().size) - } - - @Test - fun whenHundredsOfListItemWithResultsAreRendered_andScreenIsScrolled_thenNonVisibleResultChannelsAreCleanedUp() { - val scenario = composeContentRule.activityRule.scenario - scenario.onActivity { - it.setupItems(5000) - } - repeat(200) { - scenario.scrollTo(it * 10) - } - var maximumExpectedItems = 0 - scenario.onActivity { - maximumExpectedItems = it.adapter.attachedViewHolderCount - } - - val activeChannels = getActiveEnroResultChannels() - assertEquals(maximumExpectedItems, activeChannels.size) - } - - @Test - fun whenMultipleListItemWithResultsAreRendered_thenResultIsRetrievedSuccessfullyToTheCorrectItem() { - val scenario = composeContentRule.activityRule.scenario - scenario.onActivity { - it.setupItems(5) - } - - scenario.assertResultIsReceivedFor(0) - scenario.assertResultIsReceivedFor(2) - scenario.assertResultIsReceivedFor(4) - } - - @Test - fun whenMultipleListItemWithResultsAreRenderedInRecyclerView_thenResultIsRetrievedSuccessfullyToTheCorrectItem() { - val scenario = composeContentRule.activityRule.scenario - scenario.onActivity { - it.setupItems(500) - } - scenario.scrollTo(100) - scenario.assertResultIsReceivedFor(100) - - scenario.scrollTo(460) - scenario.assertResultIsReceivedFor(460) - - scenario.scrollTo(10) - scenario.assertResultIsReceivedFor(10) - - scenario.scrollTo(420) - scenario.assertResultIsReceivedFor(420) - - scenario.scrollTo(0) - scenario.assertResultIsReceivedFor(0) - } - - - private val ActivityScenario.items: List - get() { - lateinit var items: List - onActivity { - items = it.items - } - return items - } - - private fun ActivityScenario.assertResultIsReceivedFor(index: Int) { - val id = items[index].id - composeContentRule.onNodeWithTag("result@${id}").assertTextEquals("EMPTY") - composeContentRule.onNodeWithTag("button@${id}").performClick() - composeContentRule.onNodeWithTag("result@${id}").assertTextEquals(id.reversed()) - } - - private fun ActivityScenario.scrollTo(index: Int) { - onView(withId(ComposeRecyclerViewResultActivity.recyclerViewId)) - .perform(RecyclerViewActions.scrollToPosition(index)) - } -} - -@Parcelize -class ComposeRecyclerViewResultActivityKey : NavigationKey - -@NavigationDestination(ComposeRecyclerViewResultActivityKey::class) -class ComposeRecyclerViewResultActivity : AppCompatActivity() { - private val navigation by navigationHandle { - defaultKey(RecyclerViewResultActivityKey()) - } - - val adapter by lazy { - ComposeResultTestAdapter(navigation) - } - - val recyclerView by lazy { - RecyclerView(this).apply { - id = recyclerViewId - adapter = this@ComposeRecyclerViewResultActivity.adapter - layoutManager = LinearLayoutManager(this@ComposeRecyclerViewResultActivity) - itemAnimator = null - } - } - - lateinit var items: List - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(recyclerView) - } - - fun setupItems(size: Int) { - items = List(size) { index -> - ComposeRecyclerViewItem( - id = UUID.randomUUID().toString(), - onResultUpdated = { - result = it - adapter.notifyItemChanged(index) - } - ) - } - adapter.submitList(items) - } - - companion object { - val recyclerViewId = View.generateViewId() - } -} - -data class ComposeRecyclerViewItem( - val id: String, - var result: String = "EMPTY", - val onResultUpdated: ComposeRecyclerViewItem.(String) -> Unit, -) - -class ComposeResultTestAdapter( - val navigationHandle: NavigationHandle -) : ListAdapter( - object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ComposeRecyclerViewItem, newItem: ComposeRecyclerViewItem): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: ComposeRecyclerViewItem, newItem: ComposeRecyclerViewItem): Boolean { - return oldItem == newItem - } - } -) { - var attachedViewHolderCount = 0 - private set - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ComposeResultViewHolder { - return ComposeResultViewHolder( - ComposeView(parent.context) - ) - } - - override fun onViewAttachedToWindow(holder: ComposeResultViewHolder) { - attachedViewHolderCount++ - } - - override fun onViewDetachedFromWindow(holder: ComposeResultViewHolder) { - attachedViewHolderCount-- - } - - override fun onBindViewHolder(holder: ComposeResultViewHolder, position: Int) { - holder.bind(getItem(position)) - } -} - -class ComposeResultViewHolder( - val composeView: ComposeView, -) : RecyclerView.ViewHolder(composeView) { - - fun bind(item: ComposeRecyclerViewItem) { - composeView.setContent { - ListItemWithResult( - id = item.id, - result = item.result, - onResultUpdated = { - item.onResultUpdated(item, it) - } - ) - } - } -} - -@Composable -private fun ListItemWithResult( - id: String, - result: String, - onResultUpdated: (String) -> Unit, -) { - val channel = registerForNavigationResult { - onResultUpdated(it) - } - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .testTag("row@$id") - ) { - Button( - onClick = { - channel.open(ImmediateSyntheticResultKey(id)) - }, - content = { - Text(text = "Get Result") - }, - modifier = Modifier.testTag("button@$id") - ) - Text( - text = result, - modifier = Modifier.testTag("result@$id") - ) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/result/RecyclerViewResultTests.kt b/enro/src/androidTest/java/dev/enro/result/RecyclerViewResultTests.kt deleted file mode 100644 index befbac256..000000000 --- a/enro/src/androidTest/java/dev/enro/result/RecyclerViewResultTests.kt +++ /dev/null @@ -1,253 +0,0 @@ -package dev.enro.result - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.* -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationHandle -import dev.enro.core.NavigationKey -import dev.enro.core.navigationHandle -import dev.enro.core.requireNavigationHandle -import dev.enro.core.result.managedByViewHolderItem -import dev.enro.core.result.registerForNavigationResult -import dev.enro.getActiveEnroResultChannels -import kotlinx.parcelize.Parcelize -import org.hamcrest.Matchers -import org.junit.Assert.assertEquals -import org.junit.Test -import java.util.* - - -class RecyclerViewResultTests { - - @Test - fun whenListItemWithResultIsRenderedOnItsOwn_thenResultIsRetrievedSuccessfully() { - val scenario = ActivityScenario.launch(RecyclerViewResultActivity::class.java) - scenario.onActivity { - it.setupItems(1) - } - scenario.assertResultIsReceivedFor(0) - } - - @Test - fun whenMultipleListItemWithResultsAreRendered_andActivityIsDestroyed_thenResultChannelsAreCleanedUp() { - val scenario = ActivityScenario.launch(RecyclerViewResultActivity::class.java) - scenario.onActivity { - it.setupItems(5) - } - scenario.close() - - val activeChannels = getActiveEnroResultChannels() - assertEquals(0, activeChannels.size) - } - - @Test - fun whenHundredsOfListItemWithResultsAreRendered_andScreenIsScrolled_thenNonVisibleResultChannelsAreCleanedUp() { - val scenario = ActivityScenario.launch(RecyclerViewResultActivity::class.java) - scenario.onActivity { - it.setupItems(5000) - } - repeat(200) { - scenario.scrollTo(it * 10) - } - var maximumExpectedItems = 0 - scenario.onActivity { - maximumExpectedItems = it.adapter.attachedViewHolderCount - } - - val activeChannels = getActiveEnroResultChannels() - assertEquals(maximumExpectedItems, activeChannels.size) - } - - @Test - fun whenMultipleListItemWithResultsAreRendered_thenResultIsRetrievedSuccessfullyToTheCorrectItem() { - val scenario = ActivityScenario.launch(RecyclerViewResultActivity::class.java) - scenario.onActivity { - it.setupItems(5) - } - - scenario.assertResultIsReceivedFor(0) - scenario.assertResultIsReceivedFor(2) - scenario.assertResultIsReceivedFor(4) - } - - @Test - fun whenMultipleListItemWithResultsAreRenderedInRecyclerView_thenResultIsRetrievedSuccessfullyToTheCorrectItem() { - val scenario = ActivityScenario.launch(RecyclerViewResultActivity::class.java) - scenario.onActivity { - it.setupItems(500) - } - scenario.scrollTo(100) - scenario.assertResultIsReceivedFor(100) - - scenario.scrollTo(460) - scenario.assertResultIsReceivedFor(460) - - scenario.scrollTo(10) - scenario.assertResultIsReceivedFor(10) - - scenario.scrollTo(420) - scenario.assertResultIsReceivedFor(420) - - scenario.scrollTo(0) - scenario.assertResultIsReceivedFor(0) - } - - - private val ActivityScenario.items: List - get() { - lateinit var items: List - onActivity { - items = it.items - } - return items - } - - private fun ActivityScenario.assertResultIsReceivedFor(index: Int) { - val id = items[index].id - - // TODO: On very fast emulated devices (i.e. those hosted by an M1 MacBook), - // these tests run too fast and fail because the click event is handled before - // the activity can actually do anything about it. For now, this sleep will - // make sure the test runs on these fast devices, but there should be a nicer - // way to do this in the future. - Thread.sleep(1000) - - onView(withContentDescription(Matchers.equalTo(id))) - .check(matches(withText("$id@EMPTY"))) - - onView(withContentDescription(Matchers.equalTo(id))) - .perform(ViewActions.click()) - - onView(withContentDescription(Matchers.equalTo(id))) - .check(matches(withText("$id@${id.reversed()}"))) - } - - private fun ActivityScenario.scrollTo(index: Int) { - onView(withId(RecyclerViewResultActivity.recyclerViewId)) - .perform(RecyclerViewActions.scrollToPosition(index)) - } -} - -@Parcelize -class RecyclerViewResultActivityKey : NavigationKey - -@NavigationDestination(RecyclerViewResultActivityKey::class) -class RecyclerViewResultActivity : AppCompatActivity() { - private val navigation by navigationHandle { - defaultKey(RecyclerViewResultActivityKey()) - } - - val adapter = ResultTestAdapter() - - val recyclerView by lazy { - RecyclerView(this).apply { - id = recyclerViewId - adapter = this@RecyclerViewResultActivity.adapter - layoutManager = LinearLayoutManager(this@RecyclerViewResultActivity) - itemAnimator = null - } - } - - lateinit var items: List - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(recyclerView) - } - - fun setupItems(size: Int) { - items = List(size) { index -> - RecyclerViewItem( - id = UUID.randomUUID().toString(), - onResultUpdated = { - result = it - adapter.notifyItemChanged(index) - } - ) - } - adapter.submitList(items) - recyclerView.invalidate() - } - - companion object { - val recyclerViewId = View.generateViewId() - } -} - -data class RecyclerViewItem( - val id: String, - var result: String = "EMPTY", - val onResultUpdated: RecyclerViewItem.(String) -> Unit, -) - -class ResultTestAdapter() : ListAdapter( - object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: RecyclerViewItem, newItem: RecyclerViewItem): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: RecyclerViewItem, newItem: RecyclerViewItem): Boolean { - return oldItem == newItem - } - } -) { - var attachedViewHolderCount = 0 - private set - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ResultViewHolder { - return ResultViewHolder( - textView = TextView(parent.context).apply { - setPadding( - 30, 30, 30, 30 - ) - }, - navigationHandle = parent.requireNavigationHandle() - ) - } - - override fun onViewAttachedToWindow(holder: ResultViewHolder) { - attachedViewHolderCount++ - } - - override fun onViewDetachedFromWindow(holder: ResultViewHolder) { - attachedViewHolderCount-- - } - - override fun onBindViewHolder(holder: ResultViewHolder, position: Int) { - holder.bind(getItem(position)) - } -} - -class ResultViewHolder( - val textView: TextView, - val navigationHandle: NavigationHandle -) : RecyclerView.ViewHolder(textView) { - - fun bind(item: RecyclerViewItem) { - val channel = navigationHandle - .registerForNavigationResult(item.id) { - item.onResultUpdated(item, it) - } - .managedByViewHolderItem(this) - - textView.contentDescription = item.id - textView.text = "${item.id}@${item.result}" - textView.setOnClickListener { - channel.open(ImmediateSyntheticResultKey(item.id)) - } - } - -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/result/ResultDestinations.kt b/enro/src/androidTest/java/dev/enro/result/ResultDestinations.kt deleted file mode 100644 index 42f575c55..000000000 --- a/enro/src/androidTest/java/dev/enro/result/ResultDestinations.kt +++ /dev/null @@ -1,323 +0,0 @@ -package dev.enro.result - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.lifecycle.ViewModel -import dev.enro.TestActivity -import dev.enro.TestDialogFragment -import dev.enro.TestFragment -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.close -import dev.enro.core.navigationHandle -import dev.enro.core.result.closeWithResult -import dev.enro.core.result.forwardResult -import dev.enro.core.result.registerForNavigationResult -import dev.enro.core.result.sendResult -import dev.enro.core.synthetic.SyntheticDestination -import dev.enro.viewmodel.enroViewModels -import dev.enro.viewmodel.navigationHandle -import kotlinx.parcelize.Parcelize - -@Parcelize -class ActivityResultKey : NavigationKey.WithResult - -@NavigationDestination(ActivityResultKey::class) -class ResultActivity : TestActivity() - -@Parcelize -class FragmentResultKey : NavigationKey.WithResult - -@NavigationDestination(FragmentResultKey::class) -class ResultFragment : TestFragment() - -@Parcelize -class NestedResultFragmentKey : NavigationKey.WithResult - -@NavigationDestination(NestedResultFragmentKey::class) -class NestedResultFragment : TestFragment() - - -@Parcelize -class ResultReceiverActivityKey : NavigationKey - -@NavigationDestination(ResultReceiverActivityKey::class) -class ResultReceiverActivity : TestActivity() { - - private val navigation by navigationHandle { - defaultKey(ResultReceiverActivityKey()) - - container(primaryFragmentContainer) { it is NestedResultFragmentKey } - } - - var result: String? = null - val resultChannel by registerForNavigationResult { - result = it - findViewById(debugText).text = "Result: $result\nSecondary Result: $secondaryResult" - } - - var secondaryResult: String? = null - val secondaryResultChannel by registerForNavigationResult { - secondaryResult = it - findViewById(debugText).text = "Result: $result\nSecondary Result: $secondaryResult" - } -} - - -@Parcelize -class NestedResultReceiverActivityKey : NavigationKey - -@NavigationDestination(NestedResultReceiverActivityKey::class) -class NestedResultReceiverActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(NestedResultReceiverActivityKey()) - container(primaryFragmentContainer) { it is ResultReceiverFragmentKey || it is NestedResultFragmentKey } - } -} - -@Parcelize -class SideBySideNestedResultReceiverActivityKey : NavigationKey - -@NavigationDestination(SideBySideNestedResultReceiverActivityKey::class) -class SideBySideNestedResultReceiverActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(SideBySideNestedResultReceiverActivityKey()) - container(primaryFragmentContainer) { it is ResultReceiverFragmentKey } - container(secondaryFragmentContainer) { it is NestedResultFragmentKey } - } -} - -@Parcelize -class ResultReceiverFragmentKey : NavigationKey - -@NavigationDestination(ResultReceiverFragmentKey::class) -class ResultReceiverFragment : TestFragment() { - var result: String? = null - val resultChannel by registerForNavigationResult { - result = it - requireView().findViewById(debugText).text = "Result: $result\nSecondary Result: $secondaryResult" - } - - var secondaryResult: String? = null - val secondaryResultChannel by registerForNavigationResult { - secondaryResult = it - requireView().findViewById(debugText).text = "Result: $result\nSecondary Result: $secondaryResult" - } -} - -@Parcelize -class NestedResultReceiverFragmentKey : NavigationKey - -@NavigationDestination(NestedResultReceiverFragmentKey::class) -class NestedResultReceiverFragment : TestFragment() { - - private val navigation by navigationHandle { - container(primaryFragmentContainer) { it is NestedResultFragmentKey } - } - - var result: String? = null - val resultChannel by registerForNavigationResult { - result = it - requireView().findViewById(debugText).text = "Result: $result\nSecondary Result: $secondaryResult" - } - - var secondaryResult: String? = null - val secondaryResultChannel by registerForNavigationResult { - secondaryResult = it - requireView().findViewById(debugText).text = "Result: $result\nSecondary Result: $secondaryResult" - } -} - -@Parcelize -class ImmediateSyntheticResultKey( - val reversedResult: String -) : NavigationKey.WithResult - -@NavigationDestination(ImmediateSyntheticResultKey::class) -class ImmediateSyntheticResultDestination : SyntheticDestination() { - override fun process() { - sendResult(key.reversedResult.reversed()) - } -} - -@Parcelize -class ForwardingSyntheticActivityResultKey : NavigationKey.WithResult - -@NavigationDestination(ForwardingSyntheticActivityResultKey::class) -class ForwardingSyntheticActivityResultDestination : SyntheticDestination() { - override fun process() { - forwardResult(ActivityResultKey()) - } -} - -@Parcelize -class ForwardingSyntheticFragmentResultKey : NavigationKey.WithResult - -@NavigationDestination(ForwardingSyntheticFragmentResultKey::class) -class ForwardingSyntheticFragmentResultDestination : SyntheticDestination() { - override fun process() { - forwardResult(FragmentResultKey()) - } -} - -class ViewModelForwardingResultViewModel : ViewModel() { - val navigation by navigationHandle>() - val forwardingChannel by registerForNavigationResult { - navigation.closeWithResult(it) - } - - init { - forwardingChannel.open(ActivityResultKey()) - } - -} - -@Parcelize -class ViewModelForwardingResultActivityKey : NavigationKey.WithResult - -@NavigationDestination(ViewModelForwardingResultActivityKey::class) -class ViewModelForwardingResultActivity : TestActivity() { - private val viewModel by enroViewModels() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.hashCode() - } -} - -@Parcelize -class ViewModelForwardingResultFragmentKey : NavigationKey.WithResult - -@NavigationDestination(ViewModelForwardingResultFragmentKey::class) -class ViewModelForwardingResultFragment : TestFragment() { - private val viewModel by enroViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel.hashCode() - return super.onCreateView(inflater, container, savedInstanceState) - } -} - -@Parcelize -class ResultFlowKey : NavigationKey - -@NavigationDestination(ResultFlowKey::class) -class ResultFlowActivity : TestActivity() { - private val viewModel by enroViewModels() - private val navigation by navigationHandle { - container(primaryFragmentContainer) - } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.hashCode() - } -} - -class ResultFlowViewModel : ViewModel() { - val navigation by navigationHandle() - val first by registerForNavigationResult { - if(it == "close") { - navigation.close() - } - else { - second.open(FragmentResultKey()) - } - } - - val second by registerForNavigationResult { - if(it == "close") { - navigation.close() - } - else { - third.open(FragmentResultKey()) - } - } - - val third by registerForNavigationResult { - navigation.close() - } - - init { - first.open(FragmentResultKey()) - } -} - - -@Parcelize -class ResultFlowDialogFragmentRootKey : NavigationKey.WithResult - -@NavigationDestination(ResultFlowDialogFragmentRootKey::class) -class ResultFlowFragmentRootActivity : TestActivity() { - private val navigation by navigationHandle { - defaultKey(ResultFlowDialogFragmentRootKey()) - container(primaryFragmentContainer) { it is ResultFlowDialogFragmentKey } - } - var lastResult: String = "" - val nestedResult by registerForNavigationResult { - lastResult = it - } - - override fun onResume() { - super.onResume() - nestedResult - .open(ResultFlowDialogFragmentKey()) - } -} - -@Parcelize -class ResultFlowDialogFragmentKey : NavigationKey.WithResult - -@NavigationDestination(ResultFlowDialogFragmentKey::class) -class ResultFlowDialogFragment : TestDialogFragment() { - val navigation by navigationHandle { - container(primaryFragmentContainer) { it is NestedResultFlowFragmentKey } - } - val nestedResult by registerForNavigationResult { - navigation.closeWithResult("*".repeat(it)) - } - - override fun onResume() { - super.onResume() - nestedResult - .open(NestedResultFlowFragmentKey()) - } -} - -@Parcelize -class NestedResultFlowFragmentKey : NavigationKey.WithResult - -@NavigationDestination(NestedResultFlowFragmentKey::class) -class NestedResultFlowFragment : TestFragment() { - val navigation by navigationHandle { - container(primaryFragmentContainer) { it is NestedNestedResultFlowFragmentKey } - } - - val nestedResult by registerForNavigationResult { - navigation.closeWithResult(it) - } - - override fun onResume() { - super.onResume() - nestedResult - .open(NestedNestedResultFlowFragmentKey()) - } -} - -@Parcelize -class NestedNestedResultFlowFragmentKey : NavigationKey.WithResult - -@NavigationDestination(NestedNestedResultFlowFragmentKey::class) -class NestedNestedResultFlowFragment : TestFragment() { - val navigation by navigationHandle() - - override fun onResume() { - super.onResume() - navigation.closeWithResult(6) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/result/ResultTests.kt b/enro/src/androidTest/java/dev/enro/result/ResultTests.kt deleted file mode 100644 index 7afc5e551..000000000 --- a/enro/src/androidTest/java/dev/enro/result/ResultTests.kt +++ /dev/null @@ -1,606 +0,0 @@ -package dev.enro.result - -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import dev.enro.* -import dev.enro.core.asTyped -import dev.enro.core.forward -import dev.enro.core.getNavigationHandle -import dev.enro.core.result.closeWithResult -import junit.framework.Assert.* -import org.junit.Test -import java.util.* - -class ResultTests { - @Test - fun whenActivityRequestsResult_andResultProviderIsStandaloneFragment_thenResultIsReceived() { - val scenario = ActivityScenario.launch(ResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - scenario.onActivity { - it.resultChannel.open(FragmentResultKey()) - } - - expectContext() - .navigation - .closeWithResult(result) - - val activity = expectActivity() - - assertEquals(result, activity.result) - } - - @Test - fun whenActivityRequestsResult_andResultProviderIsActivity_thenResultIsReceived() { - val scenario = ActivityScenario.launch(ResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - scenario.onActivity { - it.resultChannel.open(ActivityResultKey()) - } - - val resultActivity = expectActivity() - resultActivity.getNavigationHandle() - .asTyped() - .closeWithResult(result) - - val activity = expectActivity() - - assertEquals(result, activity.result) - } - - @Test - fun whenActivityRequestsResult_andResultProviderIsNestedFragment_thenResultIsReceived() { - val scenario = ActivityScenario.launch(ResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - scenario.onActivity { - it.resultChannel.open(NestedResultFragmentKey()) - } - - expectContext() - .navigation - .closeWithResult(result) - - val activity = expectActivity() - - assertEquals(result, activity.result) - } - - - @Test - fun whenActivityRequestsResultThroughMultipleChannels_andResultProviderIsFragment_thenChannelUniquenessIsPreserved() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - val secondaryResult = UUID.randomUUID().toString() - - expectActivity() - .resultChannel - .open(FragmentResultKey()) - - expectContext() - .navigation - .closeWithResult(result) - - expectActivity() - .secondaryResultChannel - .open(FragmentResultKey()) - - expectContext() - .navigation - .closeWithResult(secondaryResult) - - val activity = expectActivity() - - assertEquals(result, activity.result) - assertEquals(secondaryResult, activity.secondaryResult) - } - - @Test - fun whenActivityRequestsResultThroughMultipleChannels_andResultProviderIsActivity_thenChannelUniquenessIsPreserved() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - val secondaryResult = UUID.randomUUID().toString() - - expectActivity() - .resultChannel - .open(ActivityResultKey()) - - expectActivity() - .getNavigationHandle() - .asTyped() - .closeWithResult(result) - - expectActivity() - .secondaryResultChannel - .open(ActivityResultKey()) - - expectActivity() - .getNavigationHandle() - .asTyped() - .closeWithResult(secondaryResult) - - val activity = expectActivity() - - assertEquals(result, activity.result) - assertEquals(secondaryResult, activity.secondaryResult) - } - - @Test - fun whenActivityRequestsResult_andActivityIsReCreated_thenResultIsStillSent() { - val scenario = ActivityScenario.launch(ResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - - val initialActivity = expectActivity() - val initalActivityHash = initialActivity.hashCode() - - scenario.recreate() - .onActivity { - it.resultChannel - .open(ActivityResultKey()) - } - - expectContext() - .navigation - .closeWithResult(result) - - val activity = expectActivity() - - assertEquals(result, activity.result) - assertFalse(initalActivityHash == activity.hashCode()) - } - - @Test - fun whenFragmentRequestsResult_andResultProviderIsStandaloneFragment_thenResultIsReceived() { - ActivityScenario.launch(DefaultActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(FragmentResultKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenFragmentRequestsResult_andResultProviderIsActivity_thenResultIsReceived() { - ActivityScenario.launch(DefaultActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(ActivityResultKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenFragmentRequestsResult_andResultProviderIsNestedFragment_thenResultIsReceived() { - ActivityScenario.launch(DefaultActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(NestedResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(NestedResultFragmentKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenNestedFragmentRequestsResult_andResultProviderIsStandaloneFragment_thenResultIsReceived() { - ActivityScenario.launch(NestedResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(FragmentResultKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenNestedFragmentRequestsResult_andResultProviderIsActivity_thenResultIsReceived() { - ActivityScenario.launch(NestedResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(ActivityResultKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenNestedFragmentRequestsResult_andResultProviderIsNestedFragment_thenResultIsReceived() { - ActivityScenario.launch(NestedResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(NestedResultFragmentKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenNestedFragmentRequestsResult_andResultProviderIsNestedFragmentSideBySideWithFragment_thenResultIsReceived() { - ActivityScenario.launch(SideBySideNestedResultReceiverActivity::class.java) - val result = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(NestedResultFragmentKey()) - - expectContext() - .navigation - .closeWithResult(result) - - assertEquals( - result, - expectContext() - .context - .result - ) - } - - @Test - fun whenActivityRequestResult_andResultProviderIsSyntheticDestination_andSyntheticDestinationSendsImmediateResult_thenResultIsReceived() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .context - .resultChannel - .open( - ImmediateSyntheticResultKey( - reversedResult = expectedResult.reversed() - ) - ) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenFragmentRequestResult_andResultProviderIsSyntheticDestination_andSyntheticDestinationSendsImmediateResult_thenResultIsReceived() { - ActivityScenario.launch(DefaultActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open( - ImmediateSyntheticResultKey( - reversedResult = expectedResult.reversed() - ) - ) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenSyntheticDestinationIsOpened_andSyntheticDestinationForwardsResultFromActivity_andSyntheticDestinationWasNotOpenedForResult_thenForwardedScreenIsStillOpened() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - - expectContext() - .navigation - .forward( - ForwardingSyntheticActivityResultKey() - ) - - expectContext() - } - - @Test - fun whenSyntheticDestinationIsOpened_andSyntheticDestinationForwardsResultFromFragment_andSyntheticDestinationWasNotOpenedForResult_thenForwardedScreenIsStillOpened() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - - expectContext() - .navigation - .forward( - ForwardingSyntheticFragmentResultKey() - ) - - expectContext() - } - - - @Test - fun whenActivityRequestResult_andResultProviderIsSyntheticDestination_andSyntheticDestinationForwardsResultFromActivityKey_thenResultIsReceived() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .context - .resultChannel - .open(ForwardingSyntheticActivityResultKey()) - - expectContext() - .navigation - .closeWithResult(expectedResult) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenFragmentRequestResult_andResultProviderIsSyntheticDestination_andSyntheticDestinationForwardsResultFromActivityKey_thenResultIsReceived() { - ActivityScenario.launch(DefaultActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(ForwardingSyntheticActivityResultKey()) - - expectContext() - .navigation - .closeWithResult(expectedResult) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenActivityRequestResult_andResultProviderIsSyntheticDestination_andSyntheticDestinationForwardsResultFromFragmentKey_thenResultIsReceived() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .context - .resultChannel - .open(ForwardingSyntheticFragmentResultKey()) - - expectContext() - .navigation - .closeWithResult(expectedResult) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenFragmentRequestResult_andResultProviderIsSyntheticDestination_andSyntheticDestinationForwardsResultFromFragmentKey_thenResultIsReceived() { - ActivityScenario.launch(DefaultActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .navigation - .forward(ResultReceiverFragmentKey()) - - expectContext() - .context - .resultChannel - .open(ForwardingSyntheticFragmentResultKey()) - - expectContext() - .navigation - .closeWithResult(expectedResult) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenActivityRequestsResult_andResultOpensActivityThatUsesViewModelToForwardResult_thenResultIsForwarded() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .context - .resultChannel - .open(ViewModelForwardingResultActivityKey()) - - expectContext() - .navigation - .closeWithResult(expectedResult) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenActivityRequestsResult_andResultOpensFragmentThatUsesViewModelToForwardResult_thenResultIsForwarded() { - ActivityScenario.launch(ResultReceiverActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - - expectContext() - .context - .resultChannel - .open(ViewModelForwardingResultFragmentKey()) - - expectContext() - .navigation - .closeWithResult(expectedResult) - - assertEquals( - expectedResult, - expectContext() - .context - .result - ) - } - - @Test - fun whenResultFlowActivityIsLaunched_thenStringRequestIsImmediatelyLaunched() { - ActivityScenario.launch(DefaultActivity::class.java) - expectContext() - .navigation - .forward(ResultFlowKey()) - - expectContext() - expectContext() - } - - @Test - fun whenResultFlowActivityIsLaunched_andFirstStringRequestIsClose_thenResultFlowActivityCloses() { - ActivityScenario.launch(DefaultActivity::class.java) - expectContext() - .navigation - .forward(ResultFlowKey()) - - expectContext() - expectContext() - .navigation - .closeWithResult("close") - - expectContext() - } - - @Test - fun whenResultFlowActivityIsLaunched_andFirstStringRequestProceeds_thenAnotherStringRequestIsLaunched() { - ActivityScenario.launch(DefaultActivity::class.java) - expectContext() - .navigation - .forward(ResultFlowKey()) - - expectContext() - val firstRequest = expectContext() - firstRequest - .navigation - .closeWithResult("next") - - val secondRequest = expectContext() - assertNotSame(firstRequest.navigation.id, secondRequest.navigation.id) - } - - @Test - fun whenResultFlowIsLaunchedInDialogFragment_andCompletesThroughTwoNestedFragments_thenResultIsDelivered() { - ActivityScenario.launch(DefaultActivity::class.java) - expectContext() - .navigation - .forward(ResultFlowDialogFragmentRootKey()) - - // This is not a good solution, but the crash that this test detects happens due to an async - // action causing a bad fragment removal, so we need to give the test time to detect the - // crash before we consider the test successful - Thread.sleep(1000) - - val root = expectContext() - .context - assertEquals("******", root.lastResult) - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/result/ViewModelResultTests.kt b/enro/src/androidTest/java/dev/enro/result/ViewModelResultTests.kt deleted file mode 100644 index a3d71badf..000000000 --- a/enro/src/androidTest/java/dev/enro/result/ViewModelResultTests.kt +++ /dev/null @@ -1,117 +0,0 @@ -package dev.enro.result - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.ViewModel -import androidx.test.core.app.ActivityScenario -import dev.enro.* -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.forward -import dev.enro.core.result.closeWithResult -import dev.enro.core.result.registerForNavigationResult -import dev.enro.viewmodel.enroViewModels -import dev.enro.viewmodel.navigationHandle -import kotlinx.parcelize.Parcelize -import org.junit.Test - -class ViewModelResultTests { - @Test - fun givenOrchestratedResultFlowManagedByViewModels_whenOrchestratedResultFlowExecutes_thenResultsAreReceivedCorrectly() { - ActivityScenario.launch(DefaultActivity::class.java) - .getNavigationHandle() - .forward(OrchestratorKey()) - - val viewModel = expectFragment() - .viewModel - - waitFor { "FirstStep -> SecondStep(SecondStepNested)" == viewModel.currentResult } - } -} - - -@Parcelize -class OrchestratorKey : NavigationKey - -class OrchestratorViewModel : ViewModel() { - var currentResult = "" - - val navigation by navigationHandle() - val resultOne by registerForNavigationResult { - currentResult = it - resultTwo.open(SecondStepKey()) - } - val resultTwo by registerForNavigationResult { - currentResult = "$currentResult -> $it" - } - - init { - resultOne.open(FirstStepKey()) - } -} - -@NavigationDestination(OrchestratorKey::class) -class OrchestratorFragment : TestFragment() { - val viewModel by enroViewModels() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.hashCode() - } -} - -@Parcelize -class FirstStepKey : NavigationKey.WithResult - -class FirstStepViewModel : ViewModel() { - private val navigation by navigationHandle() - init { - navigation.closeWithResult("FirstStep") - } -} - -@NavigationDestination(FirstStepKey::class) -class FirstStepFragment : TestFragment() { - private val viewModel by enroViewModels() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.hashCode() - } -} - -@Parcelize -class SecondStepKey : NavigationKey.WithResult - -class SecondStepViewModel : ViewModel() { - private val navigation by navigationHandle() - private val nested by registerForNavigationResult { - navigation.closeWithResult("SecondStep($it)") - } - init { - nested.open(SecondStepNestedKey()) - } -} - -@NavigationDestination(SecondStepKey::class) -class SecondStepFragment : TestFragment() { - private val viewModel by enroViewModels() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.hashCode() - } -} - - -@Parcelize -class SecondStepNestedKey : NavigationKey.WithResult - -class SecondStepNestedViewModel : ViewModel() { - private val navigation by navigationHandle() - init { - navigation.closeWithResult("SecondStepNested") - } -} - -@NavigationDestination(SecondStepNestedKey::class) -class SecondStepNestedFragment : TestFragment() { - private val viewModel by enroViewModels() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.hashCode() - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/test/ActivityTestExtensionsTest.kt b/enro/src/androidTest/java/dev/enro/test/ActivityTestExtensionsTest.kt deleted file mode 100644 index 11eae2c64..000000000 --- a/enro/src/androidTest/java/dev/enro/test/ActivityTestExtensionsTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package dev.enro.test - -import androidx.test.core.app.ActivityScenario -import dev.enro.GenericActivityKey -import dev.enro.GenericFragmentKey -import dev.enro.core.* -import dev.enro.result.ActivityResultKey -import dev.enro.result.FragmentResultKey -import dev.enro.test.extensions.getTestNavigationHandle -import dev.enro.test.extensions.sendResultForTest -import junit.framework.TestCase -import org.junit.Rule -import org.junit.Test -import java.util.* - -class ActivityTestExtensionsTest { - - @get:Rule - val enroRule = EnroTestRule() - - @Test - fun whenActivityScenarioCreated_thenActivityHasTestNavigationHandle() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - val handle = scenario.getTestNavigationHandle() - - @Suppress("USELESS_IS_CHECK") - TestCase.assertTrue(handle is TestNavigationHandle) - - @Suppress("USELESS_IS_CHECK") - TestCase.assertTrue(handle.key is EnroTestTestActivityKey) - } - - @Test - fun whenActivityScenarioCreated_thenNavigationHandleHasNoInstructions() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - val handle = scenario.getTestNavigationHandle() - - TestCase.assertTrue(handle.instructions.isEmpty()) - } - - @Test - fun whenActivityScenarioCreated_andNavigationHandleRequestsClose_thenNavigationHandleHasNoInstructions() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - scenario.onActivity { - it.getNavigationHandle().close() - } - - val handle = scenario.getTestNavigationHandle() - - TestCase.assertEquals(NavigationInstruction.Close, handle.instructions.first()) - } - - @Test - fun useExtension_whenActivityScenarioCreated_andNavigationHandleRequestsClose_thenNavigationHandleHasNoInstructions() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - scenario.onActivity { - it.getNavigationHandle().close() - } - - scenario.getTestNavigationHandle() - .expectCloseInstruction() - } - - - @Test - fun whenActivityScenarioCreated_andNavigationHandleRequestsForward_thenNavigationHandleCapturesForward() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - val expectedKey = listOf( - GenericFragmentKey(UUID.randomUUID().toString()), GenericActivityKey( - UUID.randomUUID().toString()) - ).random() - - scenario.onActivity { - it.getNavigationHandle().forward(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.instructions.first() - instruction as NavigationInstruction.Open - TestCase.assertEquals(NavigationDirection.FORWARD, instruction.navigationDirection) - TestCase.assertEquals(expectedKey, instruction.navigationKey) - } - - @Test - fun useExtension_whenActivityScenarioCreated_andNavigationHandleRequestsForward_thenNavigationHandleCapturesForward() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - - val expectedKey = GenericFragmentKey(UUID.randomUUID().toString()) - scenario.onActivity { - it.getNavigationHandle().forward(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.expectOpenInstruction() - TestCase.assertEquals(NavigationDirection.FORWARD, instruction.navigationDirection) - TestCase.assertEquals(expectedKey, instruction.navigationKey) - } - - @Test - fun whenActivityOpensResult_thenResultIsReceived() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - val expectedKey = listOf(ActivityResultKey(), FragmentResultKey()).random() - - scenario.onActivity { - it.resultChannel.open(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.instructions.first() - instruction as NavigationInstruction.Open - instruction.sendResultForTest(expectedResult) - - scenario.onActivity { - TestCase.assertEquals(expectedResult, it.result) - } - } - - @Test - fun useExtension_whenActivityOpensResult_thenResultIsReceived() { - val scenario = ActivityScenario.launch(EnroTestTestActivity::class.java) - val expectedResult = UUID.randomUUID().toString() - val expectedKey = listOf(ActivityResultKey(), FragmentResultKey()).random() - - scenario.onActivity { - it.resultChannel.open(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - handle.expectOpenInstruction(expectedKey::class.java).sendResultForTest(expectedResult) - - scenario.onActivity { - TestCase.assertEquals(expectedResult, it.result) - } - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/test/EnroTestTestDestinations.kt b/enro/src/androidTest/java/dev/enro/test/EnroTestTestDestinations.kt deleted file mode 100644 index 70c1c5d1d..000000000 --- a/enro/src/androidTest/java/dev/enro/test/EnroTestTestDestinations.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.enro.test - -import androidx.lifecycle.ViewModel -import dev.enro.TestActivity -import dev.enro.TestFragment -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.navigationHandle -import dev.enro.core.result.registerForNavigationResult -import dev.enro.viewmodel.enroViewModels -import dev.enro.viewmodel.navigationHandle -import kotlinx.parcelize.Parcelize - -interface EnroTestTestKey : NavigationKey { - val name: String -} - -@Parcelize -data class EnroTestTestActivityKey( - override val name: String = "Activity" -) : EnroTestTestKey - -@NavigationDestination(EnroTestTestActivityKey::class) -class EnroTestTestActivity : TestActivity() { - var result: String? = null - - val navigation by navigationHandle { - defaultKey(EnroTestTestActivityKey()) - } - val resultChannel by registerForNavigationResult { - result = it - } - val viewModel by enroViewModels() -} - -@Parcelize -data class EnroTestTestFragmentKey( - override val name: String = "Fragment" -) : EnroTestTestKey - -@NavigationDestination(EnroTestTestFragmentKey::class) -class EnroTestTestFragment : TestFragment() { - var result: String? = null - - val navigation by navigationHandle { - defaultKey(EnroTestTestFragmentKey()) - } - val resultChannel by registerForNavigationResult { - result = it - } - val viewModel by enroViewModels() -} - -class EnroTestViewModel : ViewModel() { - val navigationHandle by navigationHandle() -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/test/FragmentTestExtensionsTest.kt b/enro/src/androidTest/java/dev/enro/test/FragmentTestExtensionsTest.kt deleted file mode 100644 index 32c09b047..000000000 --- a/enro/src/androidTest/java/dev/enro/test/FragmentTestExtensionsTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -package dev.enro.test - -import androidx.fragment.app.testing.launchFragment -import androidx.fragment.app.testing.launchFragmentInContainer -import dev.enro.GenericActivityKey -import dev.enro.GenericFragmentKey -import dev.enro.core.* -import dev.enro.result.ActivityResultKey -import dev.enro.result.FragmentResultKey -import dev.enro.test.extensions.getTestNavigationHandle -import dev.enro.test.extensions.sendResultForTest -import junit.framework.TestCase -import org.junit.Rule -import org.junit.Test -import java.util.* - -class FragmentTestExtensionsTest { - - @get:Rule - val enroRule = EnroTestRule() - - @Test - fun whenFragmentScenarioCreated_thenActivityHasTestNavigationHandle() { - val scenario = launchFragmentInContainer() - val handle = scenario.getTestNavigationHandle() - - @Suppress("USELESS_IS_CHECK") - TestCase.assertTrue(handle is TestNavigationHandle) - - @Suppress("USELESS_IS_CHECK") - TestCase.assertTrue(handle.key is EnroTestTestFragmentKey) - } - - @Test - fun whenFragmentScenarioCreated_thenNavigationHandleHasNoInstructions() { - val scenario = launchFragmentInContainer() - val handle = scenario.getTestNavigationHandle() - - TestCase.assertTrue(handle.instructions.isEmpty()) - } - - @Test - fun whenFragmentScenarioCreated_andNavigationHandleRequestsClose_thenNavigationHandleCapturesClose() { - val scenario = launchFragmentInContainer() - scenario.onFragment { - it.getNavigationHandle().close() - } - - val handle = scenario.getTestNavigationHandle() - - TestCase.assertEquals(NavigationInstruction.Close, handle.instructions.first()) - } - - @Test - fun whenFragmentScenarioCreated_andNavigationHandleRequestsForward_thenNavigationHandleCapturesForward() { - val scenario = launchFragmentInContainer() - val expectedKey = listOf(GenericFragmentKey(UUID.randomUUID().toString()), GenericActivityKey(UUID.randomUUID().toString())).random() - scenario.onFragment { - it.getNavigationHandle().forward(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.instructions.first() - instruction as NavigationInstruction.Open - TestCase.assertEquals(NavigationDirection.FORWARD, instruction.navigationDirection) - TestCase.assertEquals(expectedKey, instruction.navigationKey) - } - - @Test - fun whenFragmentOpensResult_thenResultIsReceived() { - val scenario = launchFragmentInContainer() - val expectedResult = UUID.randomUUID().toString() - val expectedKey = listOf(ActivityResultKey(), FragmentResultKey()).random() - - scenario.onFragment { - it.resultChannel.open(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.instructions.first() - instruction as NavigationInstruction.Open - instruction.sendResultForTest(expectedResult) - - scenario.onFragment { - TestCase.assertEquals(expectedResult, it.result) - } - } - - @Test - fun noContainer_whenFragmentScenarioCreated_thenActivityHasTestNavigationHandle() { - val scenario = launchFragment() - val handle = scenario.getTestNavigationHandle() - - @Suppress("USELESS_IS_CHECK") - TestCase.assertTrue(handle is TestNavigationHandle) - - @Suppress("USELESS_IS_CHECK") - TestCase.assertTrue(handle.key is EnroTestTestFragmentKey) - } - - @Test - fun noContainer_whenFragmentScenarioCreated_thenNavigationHandleHasNoInstructions() { - val scenario = launchFragment() - val handle = scenario.getTestNavigationHandle() - - TestCase.assertTrue(handle.instructions.isEmpty()) - } - - @Test - fun noContainer_whenFragmentScenarioCreated_andNavigationHandleRequestsClose_thenNavigationHandleHasNoInstructions() { - val scenario = launchFragment() - scenario.onFragment { - it.getNavigationHandle().close() - } - - val handle = scenario.getTestNavigationHandle() - - TestCase.assertEquals(NavigationInstruction.Close, handle.instructions.first()) - } - - @Test - fun noContainer_whenFragmentScenarioCreated_andNavigationHandleRequestsForward_thenNavigationHandleCapturesForward() { - val scenario = launchFragment() - val expectedKey = listOf(GenericFragmentKey(UUID.randomUUID().toString()), GenericActivityKey(UUID.randomUUID().toString())).random() - scenario.onFragment { - it.getNavigationHandle().forward(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.instructions.first() - instruction as NavigationInstruction.Open - TestCase.assertEquals(NavigationDirection.FORWARD, instruction.navigationDirection) - TestCase.assertEquals(expectedKey, instruction.navigationKey) - } - - @Test - fun noContainer_whenFragmentOpensResult_thenResultIsReceived() { - val scenario = launchFragmentInContainer() - val expectedResult = UUID.randomUUID().toString() - val expectedKey = listOf(ActivityResultKey(), FragmentResultKey()).random() - - scenario.onFragment { - it.resultChannel.open(expectedKey) - } - - val handle = scenario.getTestNavigationHandle() - - val instruction = handle.instructions.first() - instruction as NavigationInstruction.Open - instruction.sendResultForTest(expectedResult) - - scenario.onFragment { - TestCase.assertEquals(expectedResult, it.result) - } - } -} \ No newline at end of file diff --git a/enro/src/androidTest/java/dev/enro/test/ViewModelTestExtensionsTest.kt b/enro/src/androidTest/java/dev/enro/test/ViewModelTestExtensionsTest.kt deleted file mode 100644 index 558ff0d84..000000000 --- a/enro/src/androidTest/java/dev/enro/test/ViewModelTestExtensionsTest.kt +++ /dev/null @@ -1,4 +0,0 @@ -package dev.enro.test - -class ViewModelTestExtensionsTest { -} \ No newline at end of file diff --git a/enro/src/main/AndroidManifest.xml b/enro/src/main/AndroidManifest.xml deleted file mode 100644 index a4eff0aef..000000000 --- a/enro/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/enro/src/test/java/dev/enro/test/EnroTestJvmTest.kt b/enro/src/test/java/dev/enro/test/EnroTestJvmTest.kt deleted file mode 100644 index 92a85212a..000000000 --- a/enro/src/test/java/dev/enro/test/EnroTestJvmTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -package dev.enro.test - -import androidx.lifecycle.ViewModelProvider -import dev.enro.core.requestClose -import dev.enro.test.extensions.putNavigationHandleForViewModel -import dev.enro.test.extensions.sendResultForTest -import org.junit.Assert -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import java.util.* - -class EnroTestJvmTest { - - @Rule - @JvmField - val enroTestRule = EnroTestRule() - - val factory = ViewModelProvider.NewInstanceFactory() - - @Test - fun whenViewModelIsCreatedWithoutNavigationHandleTestInstallation_theViewModelCreationFails() { - val exception = runCatching { - factory.create(TestTestViewModel::class.java) - } - assertNotNull(exception.exceptionOrNull()) - } - - @Test - fun whenPutNavigationHandleForTesting_andViewModelIsCreated_theViewModelIsCreatedSuccessfully() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - } - - @Test - fun whenNavigationRequestsClose_thenOnCloseFromConfigurationIsCalled() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - navigationHandle.requestClose() - - navigationHandle.assertRequestedClose() - navigationHandle.assertClosed() - assertTrue(viewModel.wasCloseRequested) - } - - @Test - fun whenPutNavigationHandleForTesting_andViewModelRequestsResult_thenResultIsVerified() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - - viewModel.openStringOne() - val instruction = navigationHandle.expectOpenInstruction() - navigationHandle.assertOpened() - instruction.sendResultForTest("wow") - - assertEquals("wow", viewModel.stringOneResult) - } - - @Test - fun whenPutNavigationHandleForTesting_andViewModelOpensAnotherKey_thenAssertionWorks() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - - val id = UUID.randomUUID().toString() - viewModel.forwardToTestWithData(id) - val key = navigationHandle.assertOpened() - - assertEquals(id, key.id) - runCatching { - navigationHandle.assertNoneOpened() - }.onSuccess { Assert.fail() } - } - - @Test - fun whenFullViewModelFlowIsCompleted_thenAllFlowDataIsAssignedCorrectly() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - - viewModel.openStringOne() - - navigationHandle.expectOpenInstruction() - .sendResultForTest("first") - - navigationHandle.expectOpenInstruction() - .sendResultForTest("second") - - navigationHandle.expectOpenInstruction() - .sendResultForTest(1) - - navigationHandle.expectOpenInstruction() - .sendResultForTest(2) - - assertEquals("first", viewModel.stringOneResult) - assertEquals("second", viewModel.stringTwoResult) - assertEquals(1, viewModel.intOneResult) - assertEquals(2, viewModel.intTwoResult) - - runCatching { - navigationHandle.assertNoneOpened() - }.onSuccess { Assert.fail() } - navigationHandle.assertAnyOpened() - navigationHandle.assertAnyOpened() - - navigationHandle.expectCloseInstruction() - navigationHandle.assertClosed() - runCatching { - navigationHandle.assertNotClosed() - }.onSuccess { Assert.fail() } - } - - @Test - fun givenViewModelWithResult_whenViewModelSendsResult_thenResultIsVerified() { - val navigationHandle = putNavigationHandleForViewModel(TestResultStringKey()) - val viewModel = factory.create(TestResultStringViewModel::class.java) - assertNotNull(viewModel) - - val expectedResult = UUID.randomUUID().toString() - viewModel.sendResult(expectedResult) - - runCatching { - navigationHandle.assertNoResultDelivered() - }.onSuccess { fail() } - navigationHandle.assertResultDelivered(expectedResult) - navigationHandle.assertResultDelivered { it == expectedResult } - val result = navigationHandle.assertResultDelivered() - assertEquals(expectedResult, result) - } - - @Test - fun givenViewModelWithResult_whenViewModelDoesNotSendResult_thenExpectResultFails() { - val navigationHandle = putNavigationHandleForViewModel(TestResultStringKey()) - val viewModel = factory.create(TestResultStringViewModel::class.java) - assertNotNull(viewModel) - - val expectedResult = UUID.randomUUID().toString() - runCatching { - navigationHandle.assertResultDelivered(expectedResult) - }.onSuccess { fail() } - runCatching { - navigationHandle.assertResultDelivered() - }.onSuccess { fail() } - navigationHandle.assertNoResultDelivered() - } -} diff --git a/enro/src/test/java/dev/enro/test/EnroTestTest.kt b/enro/src/test/java/dev/enro/test/EnroTestTest.kt deleted file mode 100644 index 68e6f0afe..000000000 --- a/enro/src/test/java/dev/enro/test/EnroTestTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -package dev.enro.test - -import androidx.lifecycle.ViewModelProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import dev.enro.core.requestClose -import dev.enro.test.extensions.putNavigationHandleForViewModel -import dev.enro.test.extensions.sendResultForTest -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import java.util.* - -@RunWith(AndroidJUnit4::class) -class EnroTestTest { - - @Rule - @JvmField - val enroTestRule = EnroTestRule() - - val factory = ViewModelProvider.NewInstanceFactory() - - @Test - fun whenViewModelIsCreatedWithoutNavigationHandleTestInstallation_theViewModelCreationFails() { - val exception = runCatching { - factory.create(TestTestViewModel::class.java) - } - assertNotNull(exception.exceptionOrNull()) - } - - @Test - fun whenPutNavigationHandleForTesting_andViewModelIsCreated_theViewModelIsCreatedSuccessfully() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - } - - @Test - fun whenNavigationRequestsClose_thenOnCloseFromConfigurationIsCalled() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - navigationHandle.requestClose() - - navigationHandle.assertRequestedClose() - navigationHandle.assertClosed() - assertTrue(viewModel.wasCloseRequested) - } - - @Test - fun whenPutNavigationHandleForTesting_andViewModelRequestsResult_thenResultIsVerified() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - - viewModel.openStringOne() - val instruction = navigationHandle.expectOpenInstruction() - navigationHandle.assertOpened() - instruction.sendResultForTest("wow") - - assertEquals("wow", viewModel.stringOneResult) - } - - @Test - fun whenPutNavigationHandleForTesting_andViewModelOpensAnotherKey_thenAssertionWorks() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - - val id = UUID.randomUUID().toString() - viewModel.forwardToTestWithData(id) - val key = navigationHandle.assertOpened() - - assertEquals(id, key.id) - runCatching { - navigationHandle.assertNoneOpened() - }.onSuccess { fail() } - } - - @Test - fun whenFullViewModelFlowIsCompleted_thenAllFlowDataIsAssignedCorrectly() { - val navigationHandle = putNavigationHandleForViewModel(TestTestNavigationKey()) - val viewModel = factory.create(TestTestViewModel::class.java) - assertNotNull(viewModel) - - viewModel.openStringOne() - - navigationHandle.expectOpenInstruction() - .sendResultForTest("first") - - navigationHandle.expectOpenInstruction() - .sendResultForTest("second") - - navigationHandle.expectOpenInstruction() - .sendResultForTest(1) - - navigationHandle.expectOpenInstruction() - .sendResultForTest(2) - - assertEquals("first", viewModel.stringOneResult) - assertEquals("second", viewModel.stringTwoResult) - assertEquals(1, viewModel.intOneResult) - assertEquals(2, viewModel.intTwoResult) - - runCatching { - navigationHandle.assertNoneOpened() - }.onSuccess { fail() } - navigationHandle.assertAnyOpened() - navigationHandle.assertAnyOpened() - - navigationHandle.expectCloseInstruction() - navigationHandle.assertClosed() - runCatching { - navigationHandle.assertNotClosed() - }.onSuccess { fail() } - } - - @Test - fun givenViewModelWithResult_whenViewModelSendsResult_thenResultIsVerified() { - val navigationHandle = putNavigationHandleForViewModel(TestResultStringKey()) - val viewModel = factory.create(TestResultStringViewModel::class.java) - assertNotNull(viewModel) - - val expectedResult = UUID.randomUUID().toString() - viewModel.sendResult(expectedResult) - - runCatching { - navigationHandle.assertNoResultDelivered() - }.onSuccess { fail() } - navigationHandle.assertResultDelivered(expectedResult) - navigationHandle.assertResultDelivered { it == expectedResult } - val result = navigationHandle.assertResultDelivered() - assertEquals(expectedResult, result) - } - - @Test - fun givenViewModelWithResult_whenViewModelDoesNotSendResult_thenExpectResultFails() { - val navigationHandle = putNavigationHandleForViewModel(TestResultStringKey()) - val viewModel = factory.create(TestResultStringViewModel::class.java) - assertNotNull(viewModel) - - val expectedResult = UUID.randomUUID().toString() - runCatching { - navigationHandle.assertResultDelivered(expectedResult) - }.onSuccess { fail() } - runCatching { - navigationHandle.assertResultDelivered() - }.onSuccess { fail() } - navigationHandle.assertNoResultDelivered() - } -} \ No newline at end of file diff --git a/enro/src/test/java/dev/enro/test/TestData.kt b/enro/src/test/java/dev/enro/test/TestData.kt deleted file mode 100644 index 3236e7dca..000000000 --- a/enro/src/test/java/dev/enro/test/TestData.kt +++ /dev/null @@ -1,92 +0,0 @@ -package dev.enro.test - -import androidx.lifecycle.ViewModel -import dev.enro.core.NavigationKey -import dev.enro.core.close -import dev.enro.core.forward -import dev.enro.core.result.closeWithResult -import dev.enro.core.result.registerForNavigationResult -import dev.enro.viewmodel.navigationHandle -import kotlinx.parcelize.Parcelize - -@Parcelize -data class TestTestKeyWithData( - val id: String -) : NavigationKey - -@Parcelize -class TestResultStringKey : NavigationKey.WithResult - -class TestResultStringViewModel : ViewModel() { - private val navigation by navigationHandle() - - fun sendResult(result: String) { - navigation.closeWithResult(result) - } -} - -@Parcelize -class TestResultIntKey : NavigationKey.WithResult - -class TestResultIntViewModel : ViewModel() { - private val navigation by navigationHandle() -} - -@Parcelize -class TestTestNavigationKey : NavigationKey - -class TestTestViewModel : ViewModel() { - private val navigation by navigationHandle { - onCloseRequested { - wasCloseRequested = true - close() - } - } - - var wasCloseRequested: Boolean = false - - var stringOneResult: String? = null - var stringTwoResult: String? = null - var intOneResult: Int? = null - var intTwoResult: Int? = null - - private val stringOne by registerForNavigationResult { - stringOneResult = it - openStringTwo() - } - - private val stringTwo by registerForNavigationResult { - stringTwoResult = it - openIntOne() - } - - private val intOne by registerForNavigationResult { - intOneResult = it - openIntTwo() - } - - private val intTwo by registerForNavigationResult { - intTwoResult = it - navigation.close() - } - - fun openStringOne() { - stringOne.open(TestResultStringKey()) - } - - fun openStringTwo() { - stringTwo.open(TestResultStringKey()) - } - - fun openIntOne() { - intOne.open(TestResultIntKey()) - } - - fun openIntTwo() { - intTwo.open(TestResultIntKey()) - } - - fun forwardToTestWithData(id: String) { - navigation.forward(TestTestKeyWithData(id)) - } -} \ No newline at end of file diff --git a/example/.gitignore b/example/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/example/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/example/build.gradle b/example/build.gradle deleted file mode 100644 index 0cbc4be40..000000000 --- a/example/build.gradle +++ /dev/null @@ -1,62 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlin-kapt' -apply plugin: 'dagger.hilt.android.plugin' -useCompose() - -android { - compileSdkVersion 32 - - defaultConfig { - applicationId "dev.enro.example" - minSdkVersion 21 - targetSdkVersion 32 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - viewBinding = true - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } -} - -dependencies { - implementation project(":enro") - kapt project(":enro-processor") - - lintChecks(project(":enro-lint")) - - implementation deps.compose.material - - implementation deps.hilt.android - kapt deps.hilt.compiler - kapt deps.hilt.androidCompiler - - implementation deps.kotlin.stdLib - implementation deps.androidx.core - implementation deps.androidx.appcompat - implementation deps.androidx.lifecycle - implementation deps.androidx.constraintlayout - implementation deps.androidx.fragment - implementation deps.androidx.activity - - implementation deps.material -} \ No newline at end of file diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml deleted file mode 100644 index 70b787fb1..000000000 --- a/example/src/main/AndroidManifest.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/ComposeSimpleExample.kt b/example/src/main/java/dev/enro/example/ComposeSimpleExample.kt deleted file mode 100644 index a8c2bae95..000000000 --- a/example/src/main/java/dev/enro/example/ComposeSimpleExample.kt +++ /dev/null @@ -1,231 +0,0 @@ -package dev.enro.example - -import android.util.Log -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.* -import dev.enro.core.compose.* -import dev.enro.core.compose.dialog.* -import kotlinx.parcelize.Parcelize -import java.util.* -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SingletonThing @Inject constructor() { - val id = UUID.randomUUID().toString() -} - -class ThingThing @Inject constructor() { - val id = UUID.randomUUID().toString() -} - -@Parcelize -data class ComposeSimpleExampleKey( - val name: String, - val launchedFrom: String, - val backstack: List = emptyList() -) : NavigationKey - -@HiltViewModel -class ComposeSimpleExampleViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, - private val singletonThing: SingletonThing, - private val thingThing: ThingThing -) : ViewModel() { - - init { - val isRestored = savedStateHandle.contains("savedId") - val savedId = savedStateHandle.get("savedId") ?: UUID.randomUUID().toString() - savedStateHandle.set("savedId", savedId) - Log.e("CSEVM", "Opened $savedId/${singletonThing.id}/${thingThing.id} (was restored $isRestored)") - } - -} - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(ComposeSimpleExampleKey::class) -fun ComposeSimpleExample() { - - val navigation = navigationHandle() - val scrollState = rememberScrollState() - val viewModel = viewModel() - - EnroExampleTheme { - Surface { - val topContentHeight = remember { mutableStateOf(0)} - val bottomContentHeight = remember { mutableStateOf(0)} - val availableHeight = remember { mutableStateOf(0)} - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(start = 16.dp, end = 16.dp, bottom = 8.dp, top = 8.dp) - .onGloballyPositioned { availableHeight.value = it.size.height }, - ) { - Column( - modifier = Modifier.onGloballyPositioned { topContentHeight.value = it.size.height } - ) { - Text( - text = "Example Composable", - style = MaterialTheme.typography.h4, - modifier = Modifier.padding(top = 8.dp) - ) - Text( - text = stringResource(R.string.example_content), - modifier = Modifier.padding(top = 16.dp) - ) - Text( - text = "Current Destination:", - modifier = Modifier.padding(top = 24.dp), - style = MaterialTheme.typography.h6 - ) - Text( - text = navigation.key.name, - modifier = Modifier.padding(top = 4.dp) - ) - - Text( - text = "Launched From:", - modifier = Modifier.padding(top = 24.dp), - style = MaterialTheme.typography.h6 - ) - Text( - text = navigation.key.launchedFrom, - modifier = Modifier.padding(top = 4.dp) - ) - - Text( - text = "Current Stack:", - modifier = Modifier.padding(top = 24.dp), - style = MaterialTheme.typography.h6 - ) - Text( - text = (navigation.key.backstack + navigation.key.name).joinToString(" -> "), - modifier = Modifier.padding(top = 4.dp) - ) - } - - val density = LocalDensity.current - Spacer(modifier = Modifier.height( - if(scrollState.maxValue == 0) (availableHeight.value - topContentHeight.value - bottomContentHeight.value).div(density.density).dp - 1.dp else 0.dp - )) - - Column( - verticalArrangement = Arrangement.Bottom, - modifier = Modifier - .onGloballyPositioned { bottomContentHeight.value = it.size.height } - .padding(top = 16.dp) - ) { - OutlinedButton( - modifier = Modifier.padding(top = 6.dp, bottom = 6.dp), - onClick = { - val next = ComposeSimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack + navigation.key.name - ) - navigation.forward(next) - }) { - Text("Forward") - } - - OutlinedButton( - modifier = Modifier.padding(top = 6.dp, bottom = 6.dp), - onClick = { - val next = SimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack + navigation.key.name - ) - navigation.forward(next) - }) { - Text("Forward (Fragment)") - } - - OutlinedButton( - modifier = Modifier.padding(top = 6.dp, bottom = 6.dp), - onClick = { - val next = ComposeSimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack - ) - navigation.replace(next) - }) { - Text("Replace") - } - - OutlinedButton( - modifier = Modifier.padding(top = 6.dp, bottom = 6.dp), - onClick = { - val next = ComposeSimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = emptyList() - ) - navigation.replaceRoot(next) - - }) { - Text("Replace Root") - } - - OutlinedButton( - modifier = Modifier.padding(top = 6.dp, bottom = 6.dp), - onClick = { - val next = ComposeSimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack + navigation.key.name - ) - navigation.forward(ExampleComposableBottomSheetKey(NavigationInstruction.Forward(next))) - - }) { - Text("Bottom Sheet") - } - } - } - } - } -} - -@Parcelize -class ExampleComposableBottomSheetKey(val innerKey: NavigationInstruction.Open) : NavigationKey - -@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class) -@Composable -@ExperimentalComposableDestination -@NavigationDestination(ExampleComposableBottomSheetKey::class) -fun BottomSheetDestination.ExampleDialogComposable() { - val navigationHandle = navigationHandle() - EnroContainer( - controller = rememberEnroContainerController( - initialState = listOf(navigationHandle.key.innerKey), - accept = { false }, - emptyBehavior = EmptyBehavior.CloseParent - ) - ) -} - -private fun ComposeSimpleExampleKey.getNextDestinationName(): String { - if (name.length != 1) return "A" - return (name[0] + 1).toString() -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/EnroExampleTheme.kt b/example/src/main/java/dev/enro/example/EnroExampleTheme.kt deleted file mode 100644 index ce1590296..000000000 --- a/example/src/main/java/dev/enro/example/EnroExampleTheme.kt +++ /dev/null @@ -1,115 +0,0 @@ -package dev.enro.example - -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Typography -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift -import androidx.compose.ui.unit.sp - -private val cutiveMono = FontFamily( - Font( - resId = R.font.cutive_mono, - weight = FontWeight.Normal - ) -) - -@Composable -fun EnroExampleTheme(content: @Composable () -> Unit) { - MaterialTheme( - colors = Colors( - primary = Color(0xFF607d8b), - primaryVariant = Color(0xFF34515e), - onPrimary = Color.White, - - secondary = Color(0xFFa5f5), - secondaryVariant = Color(0xFFa5f5), - onSecondary = Color.White, - - surface = Color.White, - onSurface = Color(0xFF707070), - - background = Color.White, - onBackground = Color(0xFF707070), - - error = Color.Red, - onError = Color.White, - - isLight = true - ), - typography = Typography( - h1 = TextStyle( - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - fontFamily = cutiveMono - ), - h2 = TextStyle( - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - fontFamily = cutiveMono - ), - h3 = TextStyle( - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - fontFamily = cutiveMono - ), - h4 = TextStyle( - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - fontFamily = cutiveMono - ), - h5 = TextStyle( - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - fontFamily = cutiveMono - ), - h6 = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - fontFamily = cutiveMono - ), - subtitle1 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - letterSpacing = 0.15.sp - ), - subtitle2 = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - letterSpacing = 0.1.sp - ), - body1 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ), - body2 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 14.sp - ), - button = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - letterSpacing = 1.25.sp, - baselineShift = BaselineShift(.15f), - fontFeatureSettings = "smcp,c2sc" - ), - caption = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - letterSpacing = 0.4.sp - ), - overline = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 10.sp, - letterSpacing = 1.5.sp - ) - ) - ) { - content() - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/ExampleApplication.kt b/example/src/main/java/dev/enro/example/ExampleApplication.kt deleted file mode 100644 index 6a713ce23..000000000 --- a/example/src/main/java/dev/enro/example/ExampleApplication.kt +++ /dev/null @@ -1,21 +0,0 @@ -package dev.enro.example - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp -import dev.enro.annotations.NavigationComponent -import dev.enro.core.DefaultAnimations -import dev.enro.core.controller.NavigationApplication -import dev.enro.core.controller.navigationController -import dev.enro.core.plugins.EnroLogger - -@HiltAndroidApp -@NavigationComponent -class ExampleApplication : Application(), NavigationApplication { - override val navigationController = navigationController { - plugin(EnroLogger()) - - override { - animation { DefaultAnimations.none } - } - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/ExampleDialogFragment.kt b/example/src/main/java/dev/enro/example/ExampleDialogFragment.kt deleted file mode 100644 index e94305e5e..000000000 --- a/example/src/main/java/dev/enro/example/ExampleDialogFragment.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.enro.example - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import dev.enro.annotations.NavigationDestination -import dev.enro.core.* -import dev.enro.example.databinding.FragmentExampleDialogBinding -import kotlinx.parcelize.Parcelize - -@Parcelize -class ExampleDialogKey(val number: Int = 1) : NavigationKey - -@NavigationDestination(ExampleDialogKey::class) -class ExampleDialogFragment : DialogFragment() { - - private val navigation by navigationHandle() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_example_dialog, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - FragmentExampleDialogBinding.bind(view).apply { - exampleDialogNumber.text = navigation.key.number.toString() - - exampleDialogForward.setOnClickListener { - navigation.forward(ExampleDialogKey(navigation.key.number + 1)) - } - - exampleDialogReplace.setOnClickListener { - navigation.replace(ResultExampleKey()) - } - - exampleDialogClose.setOnClickListener { - navigation.close() - } - } - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/Features.kt b/example/src/main/java/dev/enro/example/Features.kt deleted file mode 100644 index 2f63fe10f..000000000 --- a/example/src/main/java/dev/enro/example/Features.kt +++ /dev/null @@ -1,236 +0,0 @@ -package dev.enro.example - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.forward -import dev.enro.core.navigationHandle -import dev.enro.example.databinding.FragmentFeaturesBinding -import kotlinx.parcelize.Parcelize - - -@Parcelize -class Features : NavigationKey - -@NavigationDestination(Features::class) -class FeaturesFragment : Fragment() { - - private val navigation by navigationHandle() - private val adapter = FeatureAdapter { - navigation.forward(it.key) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_features, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - FragmentFeaturesBinding.bind(view).apply { - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - recyclerView.adapter = adapter - } - adapter.submitList(features) - } -} - - -data class FeatureDescription( - val name: String, - val iconResource: Int = 0, - val key: NavigationKey = SimpleMessage( - "Missing", - "This destination hasn't been implemented yet!" - ) -) - -val features = listOf( - FeatureDescription( - name = "Auto-generated navigation", - iconResource = R.drawable.ic_round_autorenew_24, - key = SimpleMessage( - title = "Auto-generated navigation", - message = """ - Enro uses annotation processing to automatically generate all the boilerplate code that it needs to run. - - All you need to do to bind a NavigationKey to a Fragment, Activity, or SyntheticDestination is to annotation that class with '@NavigationDestination' and pass the type of your NavigationKey as an argument. Easy! - """.trimIndent() - ) - ), - FeatureDescription( - name = "Multi-module support", - iconResource = R.drawable.ic_round_account_tree_24, - key = SimpleMessage( - title = "Multi-module support", - message = """ - Enro was built with multi-module support as a key consideration. - - To support navigation between Fragments and Activities that don't know about each other, simply define your NavigationKeys in a shared module. Enro's annotation processor takes care of the rest! - - For an example of this, look in the 'modularised-example' module in the Enro repository. - """.trimIndent() - ) - ), - FeatureDescription( - name = "ViewModel integration", - iconResource = R.drawable.ic_round_extension_24 - ), - FeatureDescription( - name = "Jetpack Compose", - iconResource = R.drawable.ic_compose, - key = SimpleMessage( - title = "Jetpack Compose", - message = """ - Enro supports Jetpack Compose navigation as a primary concern. - - Click 'Launch' to show an example of how this works. - - To see how this example is built, look at ComposeSimpleExample.kt in the examples. - """.trimIndent(), - positiveActionInstruction = NavigationInstruction.Forward(ComposeSimpleExampleKey( - name = "Start", - launchedFrom = "Features" - )) - ) - ), - FeatureDescription( - name = "Receive results from destinations", - iconResource = R.drawable.ic_round_undo_24, - key = SimpleMessage( - title = "Receive results from destinations", - message = """ - Enro supports destinations returning results (similar to startActivityForResult). This API is modelled after the AndroidX Activity 1.2.0 ActivityResultContract API, so should be reasonably familiar. - - To see how this works, look at ResultExample.kt in the examples. - - Click the 'Launch' button to try this out. - """.trimIndent(), - positiveActionInstruction = NavigationInstruction.Forward(ResultExampleKey()) - ) - ), - FeatureDescription( - name = "Deeplinking", - iconResource = R.drawable.ic_round_link_24, - key = SimpleMessage( - title = "Deeplinking", - message = """ - When you execute a navigation instruction, you can provide more than one navigation key as "child keys", or using one of the 'forward'/'replace'/'replaceRoot' extensions, provide a variable number of navigation keys. - - Doing this will cause those keys to be opened in order, as an easy way to perform deeplinking. - - Click the 'Launch' button to open a deeplink with the following stack: - "Deeplink 1 -> Deeplink 2 -> Deeplink 3" - """.trimIndent(), - positiveActionInstruction = NavigationInstruction.Forward( - navigationKey = SimpleExampleKey("Deeplink 1", "Features", listOf("Features")), - children = listOf( - SimpleExampleKey("Deeplink 2", "Deeplink 1", listOf("Features", "Deeplink 1")), - SimpleExampleKey( - "Deeplink 3", - "Deeplink 2", - listOf("Features", "Deeplink 1", "Deeplink 2") - ) - ) - ) - ) - ), - FeatureDescription( - name = "Customisable navigation behaviour", - iconResource = R.drawable.ic_round_tune_24 - ), - FeatureDescription( - name = "Synthetic destinations", - iconResource = R.drawable.ic_round_flip_24, - key = SimpleMessage( - title = "Synthetic Destinations", - message = """ - Most navigation destinations are Activities or Fragments. A synthetic destination is a navigation destination that isn't an Activity or Fragment. - - This dialog is being displayed through a synthetic destination. To see how this works, look at SimpleMessage.kt in the examples. - """.trimIndent() - ) - ), - FeatureDescription( - name = "Multistack navigation", - iconResource = R.drawable.ic_round_amp_stories_24, - key = SimpleMessage( - title = "Multistack navigation", - message = """ - The Activity that you're in at the moment is using a MultistackController to keep multiple backstacks active - one for each of the tabs in the BottomNavigationView. - - Each tab maintains it's own backstack, and when you press the back button, you'll go backwards only on the current tab. If you're at the 'base' level of a tab and you press the back button, you'll go back to the 'Home' tab. - - To see how this works, look at Main.kt in the examples. - """.trimIndent() - ) - ), - FeatureDescription( - name = "Master/Detail navigation", - iconResource = R.drawable.ic_round_vertical_split_24, - key = SimpleMessage( - title = "Master/Detail navigation", - message = """ - Enro supports Master/Detail navigation through a component called the MasterDetailController. - - Click 'Launch' to show an example of how this works. - - To see how this example is built, look at MasterDetail.kt in the examples. - """.trimIndent() - ) - ) -) - -class FeatureAdapter( - val onFeatureSelected: (FeatureDescription) -> Unit -) : ListAdapter(FeatureDescriptionDiff) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.viewholder_feature_description, parent, false) - ) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - private val featureTitle = view.findViewById(R.id.featureTitle) - private val featureIcon = view.findViewById(R.id.featureIcon) - - fun bind(feature: FeatureDescription) { - featureTitle.text = feature.name - featureIcon.setImageResource(feature.iconResource) - itemView.setOnClickListener { - onFeatureSelected(feature) - } - } - } -} - -object FeatureDescriptionDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: FeatureDescription, - newItem: FeatureDescription - ): Boolean = oldItem.name == newItem.name - - override fun areContentsTheSame( - oldItem: FeatureDescription, - newItem: FeatureDescription - ): Boolean = oldItem == newItem -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/Home.kt b/example/src/main/java/dev/enro/example/Home.kt deleted file mode 100644 index bac10a6cb..000000000 --- a/example/src/main/java/dev/enro/example/Home.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.enro.example - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.forward -import dev.enro.core.getNavigationHandle -import dev.enro.example.databinding.FragmentHomeBinding -import kotlinx.parcelize.Parcelize - - -@Parcelize -class Home : NavigationKey - -@NavigationDestination(Home::class) -class HomeFragment : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_home, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - FragmentHomeBinding.bind(view).apply { - launchExample.setOnClickListener { - getNavigationHandle() - .forward(SimpleExampleKey("Start", "Home", listOf("Home"))) - } - } - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/ListDetailCompose.kt b/example/src/main/java/dev/enro/example/ListDetailCompose.kt deleted file mode 100644 index 802782b48..000000000 --- a/example/src/main/java/dev/enro/example/ListDetailCompose.kt +++ /dev/null @@ -1,117 +0,0 @@ -package dev.enro.example - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.* -import androidx.compose.ui.unit.dp -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.compose.EmptyBehavior -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.navigationHandle -import dev.enro.core.compose.rememberEnroContainerController -import dev.enro.core.forward -import dev.enro.core.replace -import kotlinx.parcelize.Parcelize -import java.util.* - -@Parcelize -class ListDetailComposeKey : NavigationKey - -@Parcelize -class ListComposeKey : NavigationKey - -@Parcelize -class DetailComposeKey( - val id: String -) : NavigationKey - - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(ListDetailComposeKey::class) -fun MasterDetailComposeScreen() { - val listContainerController = rememberEnroContainerController( - initialState = listOf(NavigationInstruction.Forward(ListComposeKey())), - emptyBehavior = EmptyBehavior.CloseParent, - accept = { it is ListComposeKey } - ) - val detailContainerController = rememberEnroContainerController( - emptyBehavior = EmptyBehavior.AllowEmpty, - accept = { it is DetailComposeKey } - ) - - val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - if (isLandscape) { - Row { - EnroContainer( - controller = listContainerController, - modifier = Modifier.weight(1f, true), - ) - EnroContainer( - controller = detailContainerController, - modifier = Modifier.weight(1f, true) - ) - } - } else { - Box { - EnroContainer(controller = listContainerController) - EnroContainer(controller = detailContainerController) - } - } -} - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(ListComposeKey::class) -fun ListComposeScreen() { - val items = rememberSaveable { - List(100) { UUID.randomUUID().toString() } - } - val navigation = navigationHandle() - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - items.forEach { - Text( - text = it, - modifier = Modifier - .clickable { - navigation.replace(DetailComposeKey(it)) - } - .padding(16.dp) - ) - } - } -} - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(DetailComposeKey::class) -fun DetailComposeScreen() { - val navigation = navigationHandle() - - Box( - modifier = Modifier - .background(Color.White) - .fillMaxSize() - ) { - Text( - text = navigation.key.id, modifier = Modifier - .padding(16.dp) - .fillMaxSize() - ) - } -} - diff --git a/example/src/main/java/dev/enro/example/Main.kt b/example/src/main/java/dev/enro/example/Main.kt deleted file mode 100644 index 14a9dd222..000000000 --- a/example/src/main/java/dev/enro/example/Main.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.enro.example - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.Observer -import dagger.hilt.android.AndroidEntryPoint -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.navigationHandle -import dev.enro.example.databinding.ActivityMainBinding -import dev.enro.multistack.multistackController -import kotlinx.parcelize.Parcelize - -@Parcelize -class MainKey : NavigationKey - -@AndroidEntryPoint -@NavigationDestination(MainKey::class) -class MainActivity : AppCompatActivity() { - - private val navigation by navigationHandle { - container(R.id.homeContainer) { - it is Home || it is SimpleExampleKey || it is ComposeSimpleExampleKey - } - } - - private val mutlistack by multistackController { - container(R.id.homeContainer, Home()) - container(R.id.featuresContainer, Features()) - container(R.id.profileContainer, Profile()) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.apply { - bottomNavigation.setOnNavigationItemSelectedListener { - when (it.itemId) { - R.id.home -> mutlistack.openStack(R.id.homeContainer) - R.id.features -> mutlistack.openStack(R.id.featuresContainer) - R.id.profile -> mutlistack.openStack(R.id.profileContainer) - else -> return@setOnNavigationItemSelectedListener false - } - return@setOnNavigationItemSelectedListener true - } - - mutlistack.activeContainer.observe(this@MainActivity, Observer { selectedContainer -> - bottomNavigation.selectedItemId = when (selectedContainer) { - R.id.homeContainer -> R.id.home - R.id.featuresContainer -> R.id.features - R.id.profileContainer -> R.id.profile - else -> 0 - } - }) - } - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/MultistackCompose.kt b/example/src/main/java/dev/enro/example/MultistackCompose.kt deleted file mode 100644 index 8604d5fde..000000000 --- a/example/src/main/java/dev/enro/example/MultistackCompose.kt +++ /dev/null @@ -1,127 +0,0 @@ -package dev.enro.example - -import android.annotation.SuppressLint -import androidx.compose.animation.* -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.BottomAppBar -import androidx.compose.material.IconButton -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.zIndex -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.DefaultAnimations -import dev.enro.core.NavigationInstruction -import dev.enro.core.NavigationKey -import dev.enro.core.compose.* -import kotlinx.parcelize.Parcelize - -@Parcelize -class MultistackComposeKey : NavigationKey - -@OptIn(ExperimentalAnimationApi::class) -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MultistackComposeKey::class) -fun MultistackComposeScreen() { - - val composableManager = localComposableManager - val redController = rememberEnroContainerController( - initialState = listOf(NavigationInstruction.Forward(ComposeSimpleExampleKey("Red", "Mutlistack"))), - emptyBehavior = EmptyBehavior.CloseParent - ) - - val greenController = rememberEnroContainerController( - initialState = listOf(NavigationInstruction.Forward(ComposeSimpleExampleKey("Green", "Mutlistack"))), - emptyBehavior = EmptyBehavior.Action { - composableManager.setActiveContainer(redController) - true - } - ) - - val blueController = rememberEnroContainerController( - initialState = listOf(NavigationInstruction.Forward(ComposeSimpleExampleKey("Blue", "Mutlistack"))), - emptyBehavior = EmptyBehavior.Action { - composableManager.setActiveContainer(redController) - true - } - ) - - Column { - Crossfade( - targetState = composableManager.activeContainer, - modifier = Modifier.weight(1f, true), - animationSpec = tween(225) - ) { - if(it == null) return@Crossfade - val isActive = composableManager.activeContainer == it - EnroContainer( - controller = it, - modifier = Modifier - .weight(1f) - .animateVisibilityWithScale( - visible = isActive, - enterScale = 0.9f, - exitScale = 1.1f, - ) - .zIndex(if (isActive) 1f else 0f) - ) - } - BottomAppBar( - backgroundColor = Color.White - ) { - TextButton(onClick = { - composableManager.setActiveContainer(redController) - }) { - Text(text = "Red") - } - TextButton(onClick = { - composableManager.setActiveContainer(greenController) - }) { - Text(text = "Green") - } - TextButton(onClick = { - composableManager.setActiveContainer(blueController) - }) { - Text(text = "Blue") - } - } - } -} - -@SuppressLint("UnnecessaryComposedModifier") -fun Modifier.animateVisibilityWithScale( - visible: Boolean, - enterScale: Float, - exitScale: Float -): Modifier = composed { - val isFirstRender = remember { mutableStateOf(true) } - val anim = animateFloatAsState( - targetValue = when { - isFirstRender.value -> enterScale - visible -> 1.0f - else -> exitScale - }, - animationSpec = tween(225) - ) - SideEffect { - isFirstRender.value = false - } - - return@composed scale(anim.value) -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/Profile.kt b/example/src/main/java/dev/enro/example/Profile.kt deleted file mode 100644 index 3323dcf07..000000000 --- a/example/src/main/java/dev/enro/example/Profile.kt +++ /dev/null @@ -1,175 +0,0 @@ -package dev.enro.example - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.compose.EnroContainer -import dev.enro.core.compose.navigationHandle -import dev.enro.core.compose.registerForNavigationResult -import dev.enro.core.compose.rememberEnroContainerController -import dev.enro.core.forward -import dev.enro.core.result.closeWithResult -import dev.enro.core.result.registerForNavigationResult -import dev.enro.viewmodel.navigationHandle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.parcelize.Parcelize - -@Parcelize -class Profile : NavigationKey - - -@Composable -fun ProgileFragment() { - EnroExampleTheme { - Text(text = "Open Nested!") - Column { - val navigation = navigationHandle() - Text(text = "Open Nested!") - Button(onClick = { navigation.forward(InitialKey()) }) { - Text(text = "Open Initial") - } - EnroContainer(modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), controller = rememberEnroContainerController { - it is InitialKey - }) - } - } -} - -@NavigationDestination(Profile::class) -class ProfileFragment : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply{ - setContent { - EnroExampleTheme { - Text(text = "Open Nested!") - Column { - val navigation = navigationHandle() - Text(text = "Open Nested!") - Button(onClick = { navigation.forward(InitialKey()) }) { - Text(text = "Open Initial") - } - EnroContainer(modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), controller = rememberEnroContainerController { - it is InitialKey - }) - } - } - } - } - } -} - -@Parcelize -class InitialKey : NavigationKey - -class InitialScreenViewModel : ViewModel() { - val navigation by navigationHandle() - val state = MutableStateFlow("None!") - - private val resultChannel by registerForNavigationResult { - state.value = it - } - - fun goNestedOne() { - resultChannel.open(NestedKey()) - } - - fun goNestedTwo() { - resultChannel.open(NestedKey2()) - } -} - -@Composable -@ExperimentalComposableDestination -@NavigationDestination(InitialKey::class) -fun InitialScreen() { - val viewModel = viewModel() - val state = viewModel.state.collectAsState() - Column { - Text(text = "Last result: ${state.value}") - Button(onClick = { viewModel.goNestedOne() }) { - Text(text = "Open Nested!") - } - Button(onClick = { viewModel.goNestedTwo() }) { - Text(text = "Open Nested 2!") - } - EnroContainer(modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .border(1.dp, Color.Green), controller = rememberEnroContainerController() { it is NestedKey }) - EnroContainer(modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .border(1.dp, Color.Red), controller = rememberEnroContainerController() { it is NestedKey2 }) - } -} - -@Parcelize -class NestedKey : NavigationKey.WithResult - -@Composable -@NavigationDestination(NestedKey::class) -@ExperimentalComposableDestination -fun NestedScreen() { - val navigation = navigationHandle() - val state = rememberSaveable { mutableStateOf("None") } - val channel = registerForNavigationResult { - state.value = it - } - Column { - Text("NESTED ONE! ${state.value}") - Button(onClick = { navigation.closeWithResult("One") }) { - Text(text = "CloseResult") - } - Button(onClick = { channel.open(NestedKey2()) }) { - Text(text = "Open Nested2!") - } - } -} - -@Parcelize -class NestedKey2 : NavigationKey.WithResult - -@Composable -@NavigationDestination(NestedKey2::class) -@ExperimentalComposableDestination -fun NestedScreen2() { - val navigation = navigationHandle() - Column { - Text("NESTED TWO!") - Button(onClick = { navigation.closeWithResult("!") }) { - Text(text = "CloseResult") - } - } -} - diff --git a/example/src/main/java/dev/enro/example/ResultExample.kt b/example/src/main/java/dev/enro/example/ResultExample.kt deleted file mode 100644 index 8fafe7852..000000000 --- a/example/src/main/java/dev/enro/example/ResultExample.kt +++ /dev/null @@ -1,157 +0,0 @@ -package dev.enro.example - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.OutlinedButton -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import dev.enro.annotations.ExperimentalComposableDestination -import dev.enro.annotations.NavigationDestination -import dev.enro.core.NavigationKey -import dev.enro.core.compose.dialog.BottomSheetDestination -import dev.enro.core.compose.navigationHandle -import dev.enro.core.navigationHandle -import dev.enro.core.result.closeWithResult -import dev.enro.core.result.registerForNavigationResult -import dev.enro.example.databinding.FragmentRequestStringBinding -import dev.enro.example.databinding.FragmentResultExampleBinding -import dev.enro.viewmodel.enroViewModels -import dev.enro.viewmodel.navigationHandle -import kotlinx.parcelize.Parcelize - -@Parcelize -class ResultExampleKey : NavigationKey - -@SuppressLint("SetTextI18n") -@NavigationDestination(ResultExampleKey::class) -class RequestExampleFragment : Fragment() { - - private val viewModel by enroViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_result_example, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - FragmentResultExampleBinding.bind(view).apply { - viewModel.results.observe(viewLifecycleOwner, Observer { - results.text = it.joinToString("\n") - if (it.isEmpty()) { - results.text = "(None)" - } - }) - - requestStringButton.setOnClickListener { - viewModel.onRequestString() - } - requestStringBottomSheetButton.setOnClickListener { - viewModel.onRequestStringFromBottomSheet() - } - } - } -} - -class RequestExampleViewModel() : ViewModel() { - - private val navigation by navigationHandle() - - private val mutableResults = MutableLiveData>().apply { emptyList() } - val results = mutableResults as LiveData> - - private val requestString by registerForNavigationResult { - mutableResults.value = mutableResults.value.orEmpty() + it - } - - fun onRequestString() { - requestString.open(RequestStringKey()) - } - - fun onRequestStringFromBottomSheet() { - requestString.open(RequestStringBottomSheetKey()) - } -} - -@Parcelize -class RequestStringKey : NavigationKey.WithResult - -@NavigationDestination(RequestStringKey::class) -class RequestStringFragment : Fragment() { - - private val navigation by navigationHandle() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_request_string, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - FragmentRequestStringBinding.bind(view).apply { - sendResultButton.setOnClickListener { - navigation.closeWithResult(input.text.toString()) - } - } - } -} - -@Parcelize -class RequestStringBottomSheetKey : NavigationKey.WithResult - -@OptIn(ExperimentalMaterialApi::class) -@Composable -@NavigationDestination(RequestStringBottomSheetKey::class) -@ExperimentalComposableDestination -fun BottomSheetDestination.RequestStringBottomSheet() { - val navigation = navigationHandle() - val result = remember { - mutableStateOf("") - } - - EnroExampleTheme { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding( - top = 32.dp, - bottom = 32.dp - ) - ) { - Text(text = "Request String Bottom Sheet") - OutlinedTextField(value = result.value, onValueChange = { - result.value = it - }) - OutlinedButton(onClick = { - navigation.closeWithResult(result.value) - }) { - Text(text = "Send Result") - } - } - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/SimpleExample.kt b/example/src/main/java/dev/enro/example/SimpleExample.kt deleted file mode 100644 index f55e88b8e..000000000 --- a/example/src/main/java/dev/enro/example/SimpleExample.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.enro.example - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import dev.enro.annotations.NavigationDestination -import dev.enro.core.* -import dev.enro.example.databinding.FragmentSimpleExampleBinding -import kotlinx.parcelize.Parcelize - -@Parcelize -data class SimpleExampleKey( - val name: String, - val launchedFrom: String, - val backstack: List = emptyList() -) : NavigationKey - -@NavigationDestination(SimpleExampleKey::class) -class SimpleExampleFragment() : Fragment() { - - private val navigation by navigationHandle() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_simple_example, container, false) - } - - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - FragmentSimpleExampleBinding.bind(view).apply { - currentDestination.text = navigation.key.name - launchedFrom.text = navigation.key.launchedFrom - currentStack.text = (navigation.key.backstack + navigation.key.name).joinToString(" -> ") - - forwardButton.setOnClickListener { - val next = SimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack + navigation.key.name - ) - navigation.forward(next) - } - - forwardComposeButton.setOnClickListener { - val next = ComposeSimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack + navigation.key.name - ) - navigation.forward(next) - } - - replaceButton.setOnClickListener { - val next = SimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = navigation.key.backstack - ) - navigation.replace(next) - } - - replaceRootButton.setOnClickListener { - val next = SimpleExampleKey( - name = navigation.key.getNextDestinationName(), - launchedFrom = navigation.key.name, - backstack = emptyList() - ) - navigation.replaceRoot(next) - } - } - - } -} - -private fun SimpleExampleKey.getNextDestinationName(): String { - if(name.length != 1) return "A" - return (name[0] + 1).toString() -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/SimpleMessage.kt b/example/src/main/java/dev/enro/example/SimpleMessage.kt deleted file mode 100644 index 330330b77..000000000 --- a/example/src/main/java/dev/enro/example/SimpleMessage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.enro.example - -import android.app.AlertDialog -import dev.enro.annotations.NavigationDestination -import dev.enro.core.* -import dev.enro.core.synthetic.SyntheticDestination -import kotlinx.parcelize.Parcelize - -@Parcelize -data class SimpleMessage( - val title: String, - val message: String, - val positiveActionInstruction: NavigationInstruction.Open? = null -) : NavigationKey - -@NavigationDestination(SimpleMessage::class) -class SimpleMessageDestination : SyntheticDestination() { - override fun process() { - val activity = navigationContext.activity - AlertDialog.Builder(activity).apply { - setTitle(key.title) - setMessage(key.message) - setNegativeButton("Close") { _, _ -> } - - if(key.positiveActionInstruction != null) { - setPositiveButton("Launch") {_, _ -> - navigationContext - .getNavigationHandle() - .executeInstruction(key.positiveActionInstruction!!) - } - } - - show() - } - } -} \ No newline at end of file diff --git a/example/src/main/java/dev/enro/example/SplashScreen.kt b/example/src/main/java/dev/enro/example/SplashScreen.kt deleted file mode 100644 index 2d40b274d..000000000 --- a/example/src/main/java/dev/enro/example/SplashScreen.kt +++ /dev/null @@ -1,31 +0,0 @@ -package dev.enro.example - -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import kotlinx.parcelize.Parcelize -import dev.enro.annotations.NavigationDestination -import dev.enro.core.* - -@Parcelize -class SplashScreenKey : NavigationKey - -@NavigationDestination(SplashScreenKey::class) -class SplashScreenActivity : AppCompatActivity() { - - private val navigation by navigationHandle { - defaultKey(SplashScreenKey()) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(View(this).apply { - setBackgroundResource(R.color.colorPrimary) - }) - } - - override fun onResume() { - super.onResume() - navigation.replaceRoot(MainKey()) - } -} \ No newline at end of file diff --git a/example/src/main/res/drawable/ic_compose.xml b/example/src/main/res/drawable/ic_compose.xml deleted file mode 100644 index 11f145671..000000000 --- a/example/src/main/res/drawable/ic_compose.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - diff --git a/example/src/main/res/drawable/ic_round_account_tree_24.xml b/example/src/main/res/drawable/ic_round_account_tree_24.xml deleted file mode 100644 index fb02e0f24..000000000 --- a/example/src/main/res/drawable/ic_round_account_tree_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_amp_stories_24.xml b/example/src/main/res/drawable/ic_round_amp_stories_24.xml deleted file mode 100644 index 0c42dcb28..000000000 --- a/example/src/main/res/drawable/ic_round_amp_stories_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/example/src/main/res/drawable/ic_round_autorenew_24.xml b/example/src/main/res/drawable/ic_round_autorenew_24.xml deleted file mode 100644 index 33dad0e17..000000000 --- a/example/src/main/res/drawable/ic_round_autorenew_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_dashboard_24.xml b/example/src/main/res/drawable/ic_round_dashboard_24.xml deleted file mode 100644 index 628af9afb..000000000 --- a/example/src/main/res/drawable/ic_round_dashboard_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_dynamic_feed_24.xml b/example/src/main/res/drawable/ic_round_dynamic_feed_24.xml deleted file mode 100644 index 21925d604..000000000 --- a/example/src/main/res/drawable/ic_round_dynamic_feed_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/example/src/main/res/drawable/ic_round_extension_24.xml b/example/src/main/res/drawable/ic_round_extension_24.xml deleted file mode 100644 index 91e53c3f2..000000000 --- a/example/src/main/res/drawable/ic_round_extension_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_flip_24.xml b/example/src/main/res/drawable/ic_round_flip_24.xml deleted file mode 100644 index 56f4ae927..000000000 --- a/example/src/main/res/drawable/ic_round_flip_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_grade_24.xml b/example/src/main/res/drawable/ic_round_grade_24.xml deleted file mode 100644 index 405ee0139..000000000 --- a/example/src/main/res/drawable/ic_round_grade_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_home_24.xml b/example/src/main/res/drawable/ic_round_home_24.xml deleted file mode 100644 index 2a8afa83e..000000000 --- a/example/src/main/res/drawable/ic_round_home_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_link_24.xml b/example/src/main/res/drawable/ic_round_link_24.xml deleted file mode 100644 index a7c819ed4..000000000 --- a/example/src/main/res/drawable/ic_round_link_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_person_24.xml b/example/src/main/res/drawable/ic_round_person_24.xml deleted file mode 100644 index 64194a275..000000000 --- a/example/src/main/res/drawable/ic_round_person_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_search_24.xml b/example/src/main/res/drawable/ic_round_search_24.xml deleted file mode 100644 index c1818d507..000000000 --- a/example/src/main/res/drawable/ic_round_search_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_tune_24.xml b/example/src/main/res/drawable/ic_round_tune_24.xml deleted file mode 100644 index eec23d1c3..000000000 --- a/example/src/main/res/drawable/ic_round_tune_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_undo_24.xml b/example/src/main/res/drawable/ic_round_undo_24.xml deleted file mode 100644 index 6cb467b4c..000000000 --- a/example/src/main/res/drawable/ic_round_undo_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/drawable/ic_round_vertical_split_24.xml b/example/src/main/res/drawable/ic_round_vertical_split_24.xml deleted file mode 100644 index 1041d937c..000000000 --- a/example/src/main/res/drawable/ic_round_vertical_split_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml deleted file mode 100644 index 97d1989a6..000000000 --- a/example/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/example/src/main/res/layout/fragment_example_dialog.xml b/example/src/main/res/layout/fragment_example_dialog.xml deleted file mode 100644 index 56da6a20b..000000000 --- a/example/src/main/res/layout/fragment_example_dialog.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - -