diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34477c7e2..7911c2dbe 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,5 +3,5 @@ updates: - package-ecosystem: gradle directory: "/" schedule: - interval: daily - open-pull-requests-limit: 10 + interval: monthly + open-pull-requests-limit: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ef34925c..060267e03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,46 @@ name: CI on: push: + paths-ignore: + - '**.md' branches: - main pull_request: branches: - main + workflow_dispatch: + jobs: - run-ui-tests: - name: Run Tests - runs-on: macOS-latest + run-continuous-integration: + name: Run Continuous Integration + runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v3 - - 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: Changes + uses: dorny/paths-filter@v2 + id: changes + with: + filters: | + isCodeChange: + - '**/*.kt' + - '**/*.kts' + - '**/*.toml' - - name: Run Enro UI Tests (Hilt) - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :enro:hilt-test:connectedCheck + - name: Set up JDK 21 + if: steps.changes.outputs.isCodeChange == 'true' + uses: actions/setup-java@v3.9.0 + with: + distribution: 'zulu' + java-version: 21 - - name: Run Enro Unit Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :enro:testDebugUnitTest + - name: Setup gradle + if: steps.changes.outputs.isCodeChange == 'true' + uses: gradle/gradle-build-action@v2 - - name: Run Modularised Example Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - script: ./gradlew :modularised-example:app:testDebugUnitTest + - name: Run + if: steps.changes.outputs.isCodeChange == 'true' + env: + EW_API_TOKEN: ${{ secrets.EW_API_TOKEN }} + run: ./gradlew continuousIntegration --continue \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e30f8305..4f381c3c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,37 +16,16 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v2.5.0 + - name: Set up JDK 21 + uses: actions/setup-java@v3.9.0 with: distribution: 'zulu' - java-version: 11 + java-version: 21 - - 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: Setup gradle + uses: gradle/gradle-build-action@v2 - name: Install gpg secret key run: cat <(echo -e "${{ secrets.PUBLISH_SIGNING_KEY_LITERAL }}") | gpg --batch --import @@ -69,7 +48,7 @@ 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 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..f3abccb4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,124 @@ +# Changelog + +## 3.0.0 (Unreleased) +* ⚠️ Updated to Kotlin 2.1.10 ⚠️ +* ⚠️ Updated project to support Kotlin Multiplatform ⚠️ + * Only the Android target is currently supported, but the project is now set up to support other targets in the future +* ⚠️ Removed NavigationExecutor and executor override functionality ⚠️ + * This functionality was not widely used, and was complicating the codebase, and has been removed. Most of the functionality provided through using executor overrides can be achieved through other means, such as NavigationInstructionInterceptors or SyntheticDestinations. If you were using a custom executor, please raise an issue on the Enro GitHub repository to discuss your use case, and we can work together to find a solution. +* ⚠️ Updated NavigationController's configuration to default to `strictMode = true` ⚠️ +* Fixed a possible memory leak bug with `ComposableDestinationOwner` + +## 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..406e42d35 100644 --- a/README.md +++ b/README.md @@ -1,353 +1,167 @@ [![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) +> **Note** +> +> Please see the [CHANGELOG](./CHANGELOG.md) to understand the latest changes in Enro # Enro 🗺️ -A simple navigation library for Android +### [enro.dev](https://enro.dev) -*"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 +Enro is a powerful navigation library based on a simple idea; screens within an application should behave like functions. -- Remove navigation logic from Fragment or Activity implementations +### Gradle quick-start +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: -- (Experimental) @Composable functions as navigation destinations, with full interoperability with Fragments and Activities - -## 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 +```kotlin dependencies { - implementation "dev.enro:enro:1.17.1" - kapt "dev.enro:enro-processor:1.17.1" + implementation("dev.enro:enro:3.0.0-alpha05") + ksp("dev.enro:enro-processor:3.0.0-alpha05") + testImplementation("dev.enro:enro-test:3.0.0-alpha05") } ``` -
-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. -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. -

-
+# Introduction +This introduction is designed to give a brief overview of how Enro works. It doesn't contain all the information you might need to know to get Enro installed in an application, or provide specific details about each of the topics covered. For this information please refer to the other documentation, such as: +* [Installing Enro](https://enro.dev/docs/installing-enro.html) +* [Navigation Keys](https://enro.dev/docs/navigation-keys.html) +* [FAQ](https://enro.dev/docs/frequently-asked-questions.html) -#### 1. Define your NavigationKeys +## NavigationKeys +Building a screen using Enro begins with defining a `NavigationKey`. A `NavigationKey` can be thought of like the function signature or interface for a screen. Just like a function signature, a `NavigationKey` represents a contract. By invoking the contract, and providing the requested parameters, an action will occur and you may (or may not) receive a result. + +Here's an example of two `NavigationKey`s that you might find in an Enro application: ```kotlin -@Parcelize -data class MyListKey(val listType: String): NavigationKey @Parcelize -data class MyDetailKey(val itemId: String, val isReadOnly): NavigationKey +data class ShowUserProfile( + val userId: UserId +) : NavigationKey.SupportsPush @Parcelize -data class MyComposeKey(val name: String): NavigationKey +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.SupportsPresent.WithResult + ``` -#### 2. Define your NavigationDestinations +If you think of the `NavigationKey`s as function signatures, they could look something like this: ```kotlin -@NavigationDestination(MyListKey::class) -class ListFragment : Fragment() -@NavigationDestination(MyDetailKey::class) -class DetailActivity : AppCompatActivity() +fun showUserProfile(userId: UserId): Unit +fun selectDate(minDate: LocalDate? = null, maxDate: LocalDate? = null): LocalDate -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { } ``` -#### 3. Annotate your Application as a NavigationComponent, and implement the NavigationApplication interface -```kotlin -@NavigationComponent -class MyApplication : Application(), NavigationApplication { - override val navigationController = navigationController() -} -``` +## NavigationHandles +Once you've defined the `NavigationKey` for a screen, you'll want to use it. In any Activity, Fragment or Composable, you will be able to get access to a `NavigationHandle`, which allows you to perform navigation. The syntax is slightly different for each type of screen. -#### 4. Navigate! +### In a Fragment or Activity: ```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(...)) - } - ) +class ExampleFragment : Fragment() { + val selectDate by registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ + } + + fun onSelectDateButtonPressed() = selectDate.present( + SelectDate(maxDate = LocalDate.now()) + ) + + fun onProfileButtonPressed() { + getNavigationHandle().push( + ShowUserProfile(userId = /* ... */) + ) + } } ``` -## Applications using Enro -

- - - -

- -## 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. - -#### 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) -``` - -#### 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 ->` - -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 ->` - -#### 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: +### In a Composable: ```kotlin -@Parcelize -class RequestDataKey(...) : NavigationKey.WithResult() -@NavigationDestination(RequestDataKey::class) -class MyResultActivity : AppCompatActivity() { - val navigation by navigationHandle() - - fun onSendResultButtonClicked() { - navigation.closeWithResult(false) - } +@Composable +fun ExampleComposable() { + val navigation = navigationHandle() + val selectDate = registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ + } + + Button(onClick = { + selectDate.present( + SelectDate(maxDate = LocalDate.now()) + ) + }) { /* ... */ } + + Button(onClick = { + navigation.push( + ShowUserProfile(userId = /* ... */) + ) + }) { /* ... */ } } -@NavigationDestination(...) -class MyActivity : AppCompatActivity() { - val requestData by registerForNavigationResult { - // do something! - } - - fun onRequestDataButtonClicked() { - requestData.open(RequestDataKey(/*arguments*/)) - } -} ``` -#### 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 +## NavigationDestinations +You might have noticed that we've defined our `ExampleFragment` and `ExampleComposable` in the example above before we've even begun to think about how we're going to implement the `ShowUserProfile` and `SelectDate` destinations. That's because implementing a `NavigationDestination` in Enro is the least interesting part of the process. All you need to do to make this application complete is to build an Activity, Fragment or Composable, and mark it as the `NavigationDestination` for a particular `NavigationKey`. -#### 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. +The recommended approach to mark an Activity, Fragment or Composable as a `NavigationDestination` is to use the Enro annotation processor and the `@NavigationDestination` annotation. -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: +### In a Fragment or Activity: ```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() - } -) -``` - -#### 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 } - } +@NavigationDestination(ShowUserProfile::class) +class ProfileFragment : Fragment { + // providing a type to `by navigationHandle()` gives you access to the NavigationKey + // used to open this destination, and you can use this to read the + // arguments for the destination + val navigation by navigationHandle() } -``` - -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) - ) - } -} ``` -## 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. - -Here is an example of a Composable function being used as a NavigationDestination: +### In a Composable: ```kotlin + @Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyComposableScreen() { - val navigation = navigationHandle() - - Button( - content = { Text("Hello, ${navigation.key}") }, - onClick = { - navigation.forward(MyListKey(...)) - } - ) +@NavigationDestination(SelectDate::class) +fun SelectDateComposable() { + // providing a type to `navigationHandle()` gives you access to the NavigationKey + // used to open this destination, and you can use this to read the + // arguments for the destination + val navigation = navigationHandle() + // ... + Button(onClick = { + navigation.closeWithResult( /* pass a local date here to return that as a result */ ) + }) { /* ... */ } } -``` -#### 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. - -Here is an example of creating a Composable that supports nested Composable navigation in Enro: +``` +### Without annotation processing: +If you'd prefer to avoid annotation processing, you can use a DSL to define these bindings when creating your application (see [here]() for more information): ```kotlin -@Composable -@ExperimentalComposableDestination -@NavigationDestination(MyComposeKey::class) -fun MyNestedComposableScreen() { - val navigation = navigationHandle() - val containerController = rememberEnroContainerController( - accept = { it is NestedComposeKey } - ) - - Column { - EnroContainer( - controller = containerController - ) - Button( - content = { Text("Open Nested") }, - onClick = { - navigation.forward(NestedComposeKey()) - } - ) - } + +// this needs to be registered with your application +val exampleNavigationComponent = createNavigationComponent { + fragmentDestination() + composableDestination { SelectDateComposable() } } -@Composable -@ExperimentalComposableDestination -@NavigationDestination(NestedComposeKey::class) -fun NestedComposableScreen() = Text("Nested Screen!") ``` -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. - -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. - -#### 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 { ... } -} +*"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)* -@Composable -@OptIn(ExperimentalMaterialApi::class) -@ExperimentalComposableDestination -@NavigationDestination(BottomSheetComposableKey::class) -fun BottomSheetDestination.BottomSheetComposableScreen() { - configureBottomSheet { ... } -} -``` \ No newline at end of file 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..dbda1eb5d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,110 @@ +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) + } +} + +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("testDebugUnitTest")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("desktopTest")?.let { continuousIntegration.dependsOn(it) } + tasks.findByName("testDebugWithEmulatorWtf")?.let { continuousIntegration.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") + } + + versionPropertiesFile.writeText("versionName=$versionName\nversionCode=$versionCode") + } +} + +tasks.register("publishEnroLocal") { + group = "publishing" + description = "Publishes Enro libraries to Maven Local" + + doLast { + exec { + workingDir = rootProject.projectDir + commandLine( + "./gradlew", + ":enro-processor:publishMavenPublicationToMavenLocal", + ":enro-annotations:publishAndroidReleasePublicationToMavenLocal", + ":enro-annotations:publishDesktopPublicationToMavenLocal", + + "publishKotlinMultiplatformPublicationToMavenLocal", + "publishAndroidReleasePublicationToMavenLocal", + "publishDesktopPublicationToMavenLocal", +// "publishFrontendJsPublicationToMavenLocal", + "publishIosArm64PublicationToMavenLocal", + "publishIosSimulatorArm64PublicationToMavenLocal", + "publishIosX64PublicationToMavenLocal", + + "--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..0cc817227 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,58 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +repositories { + mavenLocal() + google() + mavenCentral() +} + +plugins { + `kotlin-dsl` +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} +tasks.withType() { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +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) +} + + +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..fb46f74ad --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigureCompose.kt @@ -0,0 +1,98 @@ +import com.android.build.api.dsl.AndroidResources +import com.android.build.api.dsl.BuildFeatures +import com.android.build.api.dsl.BuildType +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.DefaultConfig +import com.android.build.api.dsl.Installation +import com.android.build.api.dsl.ProductFlavor +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) + } + 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) + } + } + } + + @Suppress("UNCHECKED_CAST") + val androidExtension = + project.extensions.getByType(CommonExtension::class) as CommonExtension + + androidExtension.apply { + buildFeatures { + compose = true + } + project.dependencies { + "debugImplementation"(compose.uiTooling) + } + } +} \ 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..944f10b1c --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatformLibrary.kt @@ -0,0 +1,35 @@ +import com.android.build.api.dsl.LibraryExtension +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.the + +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, +) { + val libs = project.the() + project.plugins.apply("com.android.library") + project.configureKotlinMultiplatform(js = js) + + val androidExtension = project.extensions.getByType(LibraryExtension::class.java) + androidExtension.apply { + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + testOptions { + targetSdk = libs.versions.android.targetSdk.get().toInt() + } + } +} \ 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..3fb8fbb2f --- /dev/null +++ b/buildSrc/src/main/kotlin/ConfigurePublishing.kt @@ -0,0 +1,94 @@ + +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") + } + + 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..07ac41c97 --- /dev/null +++ b/buildSrc/src/main/kotlin/Project.configureEmulatorWtf.kt @@ -0,0 +1,54 @@ +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.konan.properties.hasProperty +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) + + devices.set( + listOf( + mapOf( + "model" to "Pixel2", "version" to 35 + ), + mapOf( + "model" to "Pixel2", "version" to 34 + ), + mapOf( + "model" to "Pixel2", "version" to 33 + ), + mapOf( + "model" to "Pixel2", "version" to 30 + ), + mapOf( + "model" to "Pixel2", "version" to 27 + ), + mapOf( + "model" to "Pixel2", "version" to 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..d563bfe6f --- /dev/null +++ b/buildSrc/src/main/kotlin/Project.configureKotlinMultiplatform.kt @@ -0,0 +1,194 @@ + +import com.android.build.api.dsl.AndroidResources +import com.android.build.api.dsl.BuildFeatures +import com.android.build.api.dsl.BuildType +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.DefaultConfig +import com.android.build.api.dsl.Installation +import com.android.build.api.dsl.ProductFlavor +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.get +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.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 (android) { + androidTarget { + 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) + } + } + } + + 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( + iosX64(), + 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) { + @Suppress("UNCHECKED_CAST") + val androidExtension = + project.extensions.getByType(CommonExtension::class) as CommonExtension + + androidExtension.apply { + namespace = project.projectName.packageName + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + } + } +} \ 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/architecture.md b/docs/architecture.md index 90b7381c7..2175c0802 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,25 +42,46 @@ A NavigationContext represents a reference to a Fragment, Activity or Composable 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. + +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. + +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. +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. + +### NavigationBinding + +A NavigationBinding is an 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.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 NavigationBinding 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. +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. + +The NavigationController is a Singleton object which is bound to the Application's lifecycle. The +NavigationController stores all the NavigationBindings and NavigationExecutors for the application. diff --git a/docs/ghpages-old/assets/beyond-budget-icon.png b/docs/ghpages-old/assets/beyond-budget-icon.png new file mode 100644 index 000000000..bdd8532eb Binary files /dev/null and b/docs/ghpages-old/assets/beyond-budget-icon.png differ diff --git a/example/src/main/res/font/cutive_mono.ttf b/docs/ghpages-old/assets/cutive_mono.ttf similarity index 100% rename from example/src/main/res/font/cutive_mono.ttf rename to docs/ghpages-old/assets/cutive_mono.ttf diff --git a/docs/ghpages-old/assets/git-icon.png b/docs/ghpages-old/assets/git-icon.png new file mode 100644 index 000000000..1b822efb4 Binary files /dev/null and b/docs/ghpages-old/assets/git-icon.png differ diff --git a/docs/ghpages-old/assets/splitwise-icon.png b/docs/ghpages-old/assets/splitwise-icon.png new file mode 100644 index 000000000..0a7d7cde3 Binary files /dev/null and b/docs/ghpages-old/assets/splitwise-icon.png differ diff --git a/enro-masterdetail/consumer-rules.pro b/docs/ghpages-old/docs/configuring-enro.md similarity index 100% rename from enro-masterdetail/consumer-rules.pro rename to docs/ghpages-old/docs/configuring-enro.md diff --git a/docs/ghpages-old/docs/frequently-asked-questions.md b/docs/ghpages-old/docs/frequently-asked-questions.md new file mode 100644 index 000000000..333bfff16 --- /dev/null +++ b/docs/ghpages-old/docs/frequently-asked-questions.md @@ -0,0 +1,490 @@ +--- +title: Frequently Asked Questions +nav_order: 99 +--- +# Frequently Asked Questions + + +
+ + What's a NavigationKey? + + +A NavigationKey is a contract for a screen. It defines the inputs/parameters/arguments for a screen, and potentially the type of results returned from the screen (if any). + +When you perform navigation, you ask for a particular NavigationKey to be opened, and the screen/destination that is connected to that NavigationKey will be opened. + +From within a screen/destination, you have access to the NavigationKey that was used when opening it, and you can use this to read the inputs/parameters/arguments that were used. + +See [NavigationKeys](./navigation-keys.md) for more information. + +
+ +
+ + How do I connect a NavigationKey to a screen/destination? + + +Using KAPT or KSP, annotate the screen/destination with `@NavigationDestination`, and pass in the class reference to the NavigationKey. + +```kotlin + +// Composables: +@Parcelize +class ExampleComposableKey : NavigationKey.SupportsPush + +@Composable +@NavigationDestination(ExampleComposableKey::class) +fun ExampleComposableScreen() {} + +// Fragments: +@Parcelize +class ExampleFragmentKey : NavigationKey.SupportsPresent + +@NavigationDestination(ExampleFragmentKey::class) +class ExampleFragment : Fragment() {} + +// Activities: +@Parcelize +class ExampleActivityKey : NavigationKey.SupportsPresent + +@NavigationDestination(ExampleActivityKey::class) +class ExampleActivity : AppCompatActivity() {} // Or FragmentActivity, or ComponentActivity + +``` + +
+ +
+ + How do I open a screen/destination? + + +Once you've defined a NavigationKey for your screen/destination: +1. On a different screen, get a reference to a NavigationHandle +2. Use the `.push` or `.present` function on the NavigationHandle (depending on whether your NavigationKey is SupportsPush or SupportsPresent) +3. Pass in an instance of your NavigationKey + +```kotlin + +val navigation: NavigationHandle = TODO() // up to you! +navigation.push( ExampleNavigationKey() ) + +``` + +
+ +
+ + How do I close a screen/destination? + + +Get the NavigationHandle for the screen and use `close` or `requestClose`. + +`close` will always cause the screen to be closed. + +`requestClose` is the same as pressing the Android back button, and is a "softer" way of asking a screen to close. It is possible to configure the behaviour for `requestClose` to perform some side effect (e.g. a confirmation). + +```kotlin + +val navigation: NavigationHandle = TODO() // up to you! +navigation.close() + +``` + +
+ +
+ + How do I open a screen if I want a result from that screen/destination? + + +Create a NavigationResultChannel, by using `registerForNavigationResult()`, and then use the NavigationResultChannel to push or present the NavigationKey you want to get a result from. If you do not use the NavigationResultChannel to push or present, the result will not get delivered. If you have multiple NavigationResultChannels, the result will be delivered to the NavigationResultChannel that was used to push or present. + +```kotlin + +class ExampleResultKey : NavigationKey.SupportsPresent.WithResult + +@Composable +fun ExampleComposable() { + val exampleResult = registerForNavigationResult { result: Boolean -> + // handle result + } + LaunchedEffect(Unit) { + exampleResult.present(ExampleResultKey()) + } +} + +class ExampleViewModel : ViewModel() { + val exampleResult by registerForNavigationResult { result: Boolean -> + // handle result + } + fun startResultFlow() { + exampleResult.present(ExampleResultKey()) + } +} + +class ExampleFragment : Fragment() { + val exampleResult by registerForNavigationResult { result: Boolean -> + // handle result + } + fun startResultFlow() { + exampleResult.present(ExampleResultKey()) + } +} + +``` + +
+ +
+ + How do I send a result from a screen/destination? + + +Make sure that the NavigationKey for that screen/destination extends `...WithResult` (e.g. `NavigationKey.SupportsPresent.WithResult`). + +Get a `TypedNavigationHandle` for the screen, with the correct NavigationKey type. + +Call `closeWithResult` and pass in an object that matches `T` from the NavigationKey's `...WithResult`. + +```kotlin + +class ExampleResultKey : NavigationKey.SupportsPush.WithResult + +val navigation: TypedNavigationHandle = TODO() // up to you! +navigation.closeWithResult(ExampleResultType(/*...*/)) + +``` + +
+ +
+ + How do I get a NavigationHandle? + + +In a Composable, use `= navigationHandle()` +```kotlin + +@Composable +fun ExampleComposable() { + val navigation = navigationHandle() +} + +``` + +In a ViewModel, use `by navigationHandle()`, but make sure you've set up your ViewModel factory correctly, see [ViewModels](./viewmodels.md). +```kotlin + +class ExampleViewModel() : ViewModel() { + val navigation by navigationHandle() +} + +``` + +In an Activity or Fragment, use `by navigationHandle()` +```kotlin + +class ExampleActivity : Activity { + val navigation by navigationHandle() +} + +``` +
+ +
+ + What's the difference between Push and Present? + + +When you "push" a screen/destination, you're saying that the screen should be the top element of it's container, and it should be the only thing rendered within the container. + +When you "present" a screen/destination, you're saying that the screen should appear above the most recently pushed screen. Generally, these destinations are Dialogs, BottomSheets, or similar. Activities are also always considered to be presented, because they cannot be contained within a container. + +For example, if you have a container with a backstack that looks like this:
+`push(A), push(B), push(C)`, that container will show "C", and no other screens will be visible. + +If you pushed "D", and the backstack became:
+`push(A), push(B), push(C), push(D)`, then "C" would animate out, and "D" would become visible. "C" would become inactive. + +But if you presented "D" instead, and the backstack was:
+`push(A), push(B), push(C), present(D)`, then "C" would not animate out, and both "C" and "D" would be visible (assuming that D did not cover the entire screen). "C" remains active in the background. + +If "D" then pushed to "E", and the backstack was:
+`push(A), push(B), push(C), present(D), push(E)`, then both "C" and "D" would animate out, and "E" would be visible. Once "E" was closed, both "C" and "D" would become visible again. + +
+ +
+ + How do I create a BottomSheet or a Dialog screen in Compose? + + +Create a Composable NavigationDestination, and then call either `DialogDestination` or `BottomSheetDestination` as the root of the Composable. These destinations should generally be presented, as they should appear above the previous screen. + +```kotlin + +/** + * This is an example of creating a DialogDestination in Compose, using the standard + * Dialog Composable. + */ +@Parcelize +object ExampleDialog : NavigationKey.SupportsPresent + +@Composable +@NavigationDestination(ExampleBottomSheet::class) +fun ExampleDialogScreen() = DialogDestination { + val navigation = navigationHandle() + Dialog( + onDismissRequest = { navigation.requestClose() } + ) { + // Render screen contents + } +} + +/** + * This is an example of creating a BottomSheetDestination in Compose. The BottomSheetDestination + * lambda receives a "ModalBottomSheetState" object, which should be passed to a ModalBottomSheetLayout. + * Arguments such as "skipHalfExpanded" can be passed in to the BottomSheetDestination function. + */ +@Parcelize +object ExampleBottomSheet : NavigationKey.SupportsPresent + +@Composable +@NavigationDestination(ExampleBottomSheet::class) +fun ExampleBottomSheetScreen() = BottomSheetDestination { sheetState -> + BottomSheetDestination { sheetState -> + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + // Render screen contents + }, + content = {} + ) + } +} + +/** + * This is an example of creating a NavigationDestination which can be pushed OR presented. If this + * destination is pushed, it will be rendered in a Box as a regular screen, but if it is presented, + * it will be rendered inside of a ModalBottomSheetLayout, using BottomSheetDestination. + */ +@Parcelize +object ExampleBottomSheetOrNot : NavigationKey.SupportsPresent, NavigationKey.SupportsPush + +@Composable +@NavigationDestination(ExampleBottomSheetOrNot::class) +fun ExampleBottomSheetOrNotScreen() { + val navigation = navigationHandle() + val isPresented = navigation.instruction.navigationDirection == NavigationDirection.Present + + if(isPresented) { + BottomSheetDestination { sheetState -> + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + // Render screen contents + }, + content = {} + ) + } + } else { + Box(modifier = Modifier.fillMaxSize()) { + // Render screen contents + } + } +} + +``` + +
+ +
+ + What's a SyntheticDestination? + + +A "SyntheticDestination" is a destination that's not a Composable/Fragment/Activity, it's a way to create a NavigationKey that can be used to perform a UI/Context-aware side-effect as if it was a navigation action. + +For example, you might use a SyntheticDestination to open an Intent, make a runtime permission request, set a container's backstack, use a feature flag to open one of two different NavigationKeys, or as a placeholder for a screen that hasn't been implemented yet. + +A SyntheticDestination receives the NavigationKey, NavigationInstruction, and NavigationContext reference of the destination that was used to open the SyntheticDestination, and can use these to perform any kind of logic. + +```kotlin + +/** + * This is an example of launching an implicit Intent to view a URL using a SyntheticDestination + */ +@Parcelize +object OpenEnroDocumentationDestination : NavigationKey.SupportsPresent + +@NavigationDestination(OpenEnroDocumentationDestination::class) +val openEnroDocumentationDestination = syntheticDestination { + val activity = navigationContext.activity + val url = "https://www.enro.dev" + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + activity.startActivity(intent) +} + +/** + * ShowDocumentDestination is an example of using a SyntheticDestination to pick between two + * different "real" destinations, based on a feature flag. We can get the NavigationHandle from + * the NavigationContext, and then use this to push to other NavigationKeys based on a feature flag, + * passing through some of the arguments from ShowDocumentDestination to the other NavigationKeys + */ +@Parcelize +class ShowDocumentDestination( + val documentId: String +) : NavigationKey.SupportsPush + +@NavigationDestination(ShowDocumentDestination::class.java) +val showDocumentDestination = syntheticDestination { + val navigation = navigationContext.getNavigationHandle() + val featureFlags = getFeatureFlagsFromSomewhere() + if (featureFlags.isNewDocumentsEnabled) { + navigation.push( + NewShowDocumentDestination( + documentId = key.documentId + ) + ) + } else { + navigation.push( + LegacyShowDocumentDestination( + documentId = key.documentId + ) + ) + } +} + +/** + * DatePickerDestination is an example of using a SyntheticDestination as a placeholder while + * a destination hasn't been implemented yet (likely during development time). We'll show a + * Toast to announce that the DatePickerDestination hasn't been implemented, and then we'll + * also send a result of LocalDate.now() (because DatePickerDestination is a result destination) + */ +@Parcelize +object DatePickerDestination : NavigationKey.SupportsPresent.WithResult + +@NavigationDestination(DatePickerDestination::class) +val datePickerDestination = syntheticDestination { + Toast.makeText( + navigationContext.activity, + "DatePickerDestination is not yet implemented", + Toast.LENGTH_LONG + ).show() + + sendResult(LocalDate.now()) +} + +/** + * RequestCameraPermission is an example of using `activityResultDestination`, + * which is a special case SyntheticDestination builder that allows interoperability + * with ActivityResultContracts + */ +@Parcelize +class RequestCameraPermission : NavigationKey.SupportsPresent.WithResult { + enum class Result { + GRANTED, + DENIED, + DENIED_PERMANENTLY, + } +} + +@NavigationDestination(RequestCameraPermission::class) +val requestCameraPermission = activityResultDestination(RequestCameraPermission::class) { + ActivityResultContracts.RequestPermission() + .withInput(Manifest.permission.CAMERA) + .withMappedResult { granted -> + when { + granted -> RequestCameraPermission.Result.GRANTED + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> RequestCameraPermission.Result.DENIED + else -> RequestCameraPermission.Result.DENIED_PERMANENTLY + } + } +} + +``` + +
+ +
+ + How do I configure animations? + + +In the configuration for your application's `navigationController`, you can provide an `animations { }` block, which allows you to configure animations for a variety of situations. This can also be configured within a `navigationModule`, which can be installed on the `navigationController`, or can be configured on an individual `navigationContainer`. + +```kotlin + +val specificNavigationModule = createNavigationModule { + animations { + // Configure the default animations for destinations that are pushed + direction( + direction = NavigationDirection.Push, + entering = yourAnimationHere, + exiting = yourAnimationHere, + returnEntering = yourAnimationHere, + returnExiting = yourAnimationHere, + ) + + // Configure an animations for when any destination opens the "ExampleComposableKey" + transitionTo( + entering = yourAnimationHere, // the entering animation for ExampleComposableKey + exiting = yourAnimationHere, // the exiting animation for the destination that opened ExampleComposableKey + returnEntering = yourAnimationHere, // the entering animation for the destination that opened ExampleComposableKey, when ExampleComposableKey is closed + returnExiting = yourAnimationHere, // the exiting animation for ExampleComposableKey, when ExampleComposableKey is closed + ) + + // Configure an animations for when FooKey opens BarKey + transitionBetween( + entering = yourAnimationHere, // the entering animation for BarKey + exiting = yourAnimationHere, // the exiting animation for FooKey + returnEntering = yourAnimationHere, // the entering animation for FooKey when BarKey is closed + returnExiting = yourAnimationHere, // the exiting animation for BarKey when BarKey is closed + ) + + // Advanced APIs for adding animations in more complex situations + addOpeningTransition(/* ... */) + addClosingTransition(/* ... */) + } +} + +class ExampleApplication : Application(), NavigationApplication { + override val navigationController = createNavigationController { + module(specificNavigationModule) // install the module defined outside of the application + animations { + // this block has the same functionality as the + // animations block in specificNavigationModule above + } + } +} + +@Composable +fun ExampleScreen() { + val container = rememberNavigationContainer( + animations = { + // this block has the same functionality as the + // animations block in specificNavigationModule above + } + ) + // ... +} + +``` + +
+ +
+ + How do I do analytics when a user views a screen? + + +Enro allows you to create `EnroPlugin` classes, and register these with the `navigationController`. These plugins can be used to perform side-effects when a screen is opened or closed, and can be used to perform analytics, logging, or any other side-effect. The `EnroLogger` plugin that is defined within the Enro library is an example of this. The key functions to be interested in are: +* `onOpened(navigationHandle: NavigationHandle)` which is called the first time a screen is opened. This should be invoked once per screen. +* `onActive(navigationHandle: NavigationHandle)` which is called whenever a screen becomes "active", which essentially means whenever that screen would receive the system back button press. This can be invoked multiple times for a screen. +* `onClosed(navigationHandle: NavigationHandle)` which is called whenever a screen is closed. This should be invoked once per screen. + +
diff --git a/docs/ghpages-old/docs/installing-enro-ios.md b/docs/ghpages-old/docs/installing-enro-ios.md new file mode 100644 index 000000000..870b6842d --- /dev/null +++ b/docs/ghpages-old/docs/installing-enro-ios.md @@ -0,0 +1,77 @@ +# Installing Enro for iOS + +1. Define a "NavigationComponent" in Kotlin code. This can be in the common source set or in the iOS source set, this should be an object which is annotated with @NavigationComponent and extends NavigationComponentConfiguration. Here is an example: +```kotlin + @NavigationComponent + object MyNavigationComponent : NavigationComponentConfiguration( + // module is an optional parameter, which can be used for configuring the navigation module, + // and doing things like installing plugins, interceptors, manually adding bindings, etc. + module = createNavigationModule { /* ... */ } + ) +``` + +2. In the XCode project, make sure you have configured a UIApplicationDelegate for your application (which should be annotated with `@main`, or referenced from the `@main` SwiftUI View with as `@UIApplicationDelegateAdaptor`). In your func UIApplicationDelegate's `application(_ application:, launchOptions:)` function, you will need to install Enro using the NavigationComponent you declared in step 1. Here is an example: +```swift +@main +class EnroExampleAppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + MyNavigationComponent.shared.installNavigationController( + application: application, + root: nil, + strictMode: false, + useLegacyContainerPresentBehavior: false, + backConfiguration: Enro.shared.backConfiguration.Default, + block: { scope in + } + ) + return true + } +} +``` + +3. From here, you will need to launch an `EnroUIViewController`, like you would launch any other UIViewController. You can do this from the `AppDelegate` or from a SwiftUI View. Here is an example of launching an EnroUIViewController from an `@main` SwiftUI View: +```swift +@main +struct EnroTestApplicationApp: App { + @UIApplicationDelegateAdaptor(EnroExampleAppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + EnroRootView() + } + } +} + +struct EnroRootView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + return Enro.shared.createEnroViewController( + present: NavigationInstruction.companion.Present(navigationKey: YourRootNavigationKey()) + ) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + } +} +``` + +Alternatively, you can skip some of this additional configuration if you're writing an application that +only uses Enro, you can provide a non-nil argument to the `root` parameter of the `installNavigationController` function. This will automatically create a root EnroUIViewController for you, and you won't need to create one yourself. Here is an example: +```swift +@main +class EnroExampleAppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + MyNavigationComponent.shared.installNavigationController( + application: application, + root: NavigationInstruction.companion.Present(navigationKey: YourRootNavigationKey()), + // ... + ) + return true + } +} +``` diff --git a/docs/ghpages-old/docs/installing-enro.md b/docs/ghpages-old/docs/installing-enro.md new file mode 100644 index 000000000..20f80c849 --- /dev/null +++ b/docs/ghpages-old/docs/installing-enro.md @@ -0,0 +1,295 @@ +# Installing Enro + +## Add the Gradle Dependencies +The first step in installing Enro is to add the Gradle dependencies to your project. These should be added to your application module, as well as any modules that will use Enro. + +[![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) +```kotlin + +dependencies { + implementation("dev.enro:enro:") + ksp("dev.enro:enro-processor:") // optional but highly recommended + // kapt("dev.enro:enro-processor:") - if you're using KAPT not KSP + testImplementation("dev.enro:enro-test:") // optional test extensions +} + +``` + +## Add Enro to your Application class +Enro needs to be added to the Application class used by your Application. This is also where you can apply custom configuration to Enro, if your application requires this. + +
+ + Show step-by-step guide + + +**0. An application class without Enro installed** +```kotlin + +class ExampleApplication : Application { + // ... +} + +``` + +**1. Add the `NavigationApplication` interface** +```kotlin + +class ExampleApplication : Application, NavigationApplication { +``` +{:.code-important .code-start} +```kotlin + // ... +} + +``` +{:.code-not-important .code-end} + +**2. Override the `navigationController` property** +```kotlin + +class ExampleApplication : Application, NavigationApplication { +``` +{:.code-not-important .code-start} +```kotlin + override val navigationController = createNavigationController { } +``` +{:.code-important} +```kotlin + + // ... +} + +``` +{:.code-not-important .code-end} + +In the example above we're passing an empty block to the `createNavigationController` function, but this block is used to provide configuration to Enro. In a simple application that uses annotation processing, you may not need to provide any configuration, but it's useful to be aware that this is what the block is used for. Please see [Configuring Enro](./configuring-enro.md) for more information. + +**3. Add the `@NavigationComponent` annotation to your Application (if using kapt/annotation processing)** +```kotlin + +@NavigationComponent +``` +{:.code-important .code-start} +```kotlin +class ExampleApplication : Application, NavigationApplication { + override val navigationController = createNavigationController { } + + // ... +} + +``` +{:.code-not-important .code-end} + +If you are using annotation processing (which is optional, but recommended), you are required to annotate your Application class with `@NavigationComponent` so that the annotation processor has a hook to generate and provide configuration. + +If you are not using annotation processing, you won't need to add this annotation. Instead, you'll need to provide your Application's configuration within the `createNavigationController` block. Please see [Configuring Enro](./configuring-enro.md) for more information. +
+ +## Add Enro to an Activity +Once you've added Enro to your Application, it's likely that you'll want to add a Navigation Container to an Activity. This isn't necessary, as navigation using Enro will work even without a Navigation Container, but it is recommended. The exact configuration of the Navigation Container will depend on your needs, and the examples below will deal with a reasonably simple case, so if you need more information on how to configure a Navigation Container, please see the [Navigation Container documentation](./navigation-containers.md). + +**What is a Navigation Container?** + +A Navigation Container is a ViewGroup or Composable that maintains a backstack and displays the active Navigation Destination for that backstack. If you're familiar with Fragments, think of it as the `FrameLayout` that holds the Fragments. If you're more familiar with Compose, think of it as a `Box` that holds some child content (the active destination). For more information, please see the [Navigation Container documentation](./navigation-containers.md). + +### Adding a Navigation Container for Fragments and Composables +If your application has Navigation Destinations that are a mix of Fragments and Composables, your top level Navigation Container should be a View based Navigation Container, as this will accept both Fragment and Composable destinations. + +
+ + Show step-by-step guide + + +**0. An Activity without a Navigation Container** +```kotlin + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} + +``` + +**1. Add a FrameLayout to your Activity's layout file** +```xml + + +``` +{:.code-not-important .code-start} +```xml + +``` +{:.code-important} +```xml + + + +``` +{:.code-not-important .code-end} + +**2. Add a Navigation Container property to your Activity** +```kotlin + +class MainActivity : AppCompatActivity() { + +``` +{:.code-not-important .code-start} +```kotlin + private val exampleContainer by navigationContainer( + containerId = R.id.exampleNavigationContainer, + ) +``` +{:.code-important} +```kotlin + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} + +``` +{:.code-not-important .code-end} + +**3. Configure the Navigation Container** + +The Navigation Container that we've defined above will start off with nothing in it, and it will allow any Navigation Destination to be pushed into it. Below is an example of a configured Navigation Container that will initially show the Navigation Destination for a particular Navigation Key, and will `finish` the Activity if the Navigation Container is ever about to become empty. This isn't always the behavior that you will want for a Navigation Container, but it is a reasonably common way to set up an Activity's root Navigation Container. For more information, please see the [Navigation Container documentation](./navigation-containers.md). + +```kotlin + +class MainActivity : AppCompatActivity() { + + private val exampleContainer by navigationContainer( + containerId = R.id.exampleNavigationContainer, +``` +{:.code-not-important .code-start} +```kotlin + root = { ExampleRootNavigationKey(/* ... */) }, + emptyBehavior = EmptyBehavior.CloseParent, +``` +{:.code-important} +```kotlin + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} + +``` +{:.code-not-important .code-end} +
+ + +### Adding a Navigation Container for Composables only +If your application only has Composable destinations, you can choose to use a View based Navigation Container (as these support Composable destinations too), but you may want to consider directly using a Composable NavigationContainer. + +
+ + Show step-by-step guide + + +**0. A Composable Activity without a Navigation Container** +```kotlin + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // ... + } + } +} + +``` + +**1. Add a Navigation Container variable** +```kotlin + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { +``` +{:.code-not-important .code-start} +```kotlin + val container = rememberNavigationContainer() +``` +{:.code-important} +```kotlin + // ... + } + } +} + +``` +{:.code-not-important .code-end} + +**2. Render the Navigation Container** +```kotlin + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val container = rememberNavigationContainer() + +``` +{:.code-not-important .code-start} +```kotlin + Box(modifier = Modifier.fillMaxSize()) { + container.Render() + } +``` +{:.code-important} +```kotlin + // ... + } + } +} + +``` +{:.code-not-important .code-end} + +**3. Configure the Navigation Container** + +The Navigation Container that we've defined above will start off with nothing in it, and it will allow any Navigation Destination to be pushed into it. Below is an example of a configured Navigation Container that will initially show the Navigation Destination for a particular Navigation Key, and will `finish` the Activity if the Navigation Container is ever about to become empty. This isn't always the behavior that you will want for a Navigation Container, but it is a reasonably common way to set up an Activity's root Navigation Container. For more information, please see the [Navigation Container documentation](./navigation-containers.md). + +```kotlin + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val container = rememberNavigationContainer( +``` +{:.code-not-important .code-start} +```kotlin + root = ExampleRootNavigationKey(/* ... */), + emptyBehavior = EmptyBehavior.CloseParent, +``` +{:.code-important} +```kotlin + ) + + Box(modifier = Modifier.fillMaxSize()) { + container.Render() + } + // ... + } + } +} + +``` +{:.code-not-important .code-end} + +
\ No newline at end of file diff --git a/docs/ghpages-old/docs/introduction.md b/docs/ghpages-old/docs/introduction.md new file mode 100644 index 000000000..01e38e5b4 --- /dev/null +++ b/docs/ghpages-old/docs/introduction.md @@ -0,0 +1,137 @@ +--- +title: Introduction +parent: Overview +nav_order: 1 +--- +# Introduction +This introduction is designed to give a brief overview of how Enro works. It doesn't contain all the information you might need to know to get Enro installed in an application, or provide specific details about each of the topics covered. For this information please refer to the other documentation, such as: +* [Installing Enro](./installing-enro.md) +* [Navigation Keys](./navigation-keys.md) +* [Navigation Destinations](./navigation-destinations.md) +* [Navigation Handles](./navigation-handles.md) +* [Navigation Containers](./navigation-containers.md) +* [Testing](./testing.md) + +## NavigationKeys +Building a screen using Enro begins with defining a `NavigationKey`. A `NavigationKey` can be thought of like the function signature or interface for a screen. Just like a function signature, a `NavigationKey` represents a contract. By invoking the contract, and providing the requested parameters, an action will occur and you may (or may not) receive a result. + +Here's an example of two `NavigationKey`s that you might find in an Enro application: +```kotlin + +@Parcelize +data class ShowUserProfile( + val userId: UserId +) : NavigationKey.SupportsPush + +@Parcelize +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.SupportsPresent.WithResult + +``` + +If you think of the `NavigationKey`s as function signatures, they could look something like this: +```kotlin + +fun showUserProfile(userId: UserId): Unit +fun selectDate(minDate: LocalDate? = null, maxDate: LocalDate? = null): LocalDate + +``` + +## NavigationHandles +Once you've defined the `NavigationKey` for a screen, you'll want to use it. In any Activity, Fragment or Composable, you will be able to get access to a `NavigationHandle`, which allows you to perform navigation. The syntax is slightly different for each type of screen. + +### In a Fragment or Activity: +```kotlin + +class ExampleFragment : Fragment() { + val selectDate by registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ + } + + fun onSelectDateButtonPressed() = selectDate.present( + SelectDate(maxDate = LocalDate.now()) + ) + + fun onProfileButtonPressed() { + getNavigationHandle().push( + ShowUserProfile(userId = /* ... */) + ) + } +} + +``` + +### In a Composable: +```kotlin + +@Composable +fun ExampleComposable() { + val navigation = navigationHandle() + val selectDate = registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ + } + + Button(onClick = { + selectDate.present( + SelectDate(maxDate = LocalDate.now()) + ) + }) { /* ... */ } + + Button(onClick = { + navigation.push( + ShowUserProfile(userId = /* ... */) + ) + }) { /* ... */ } +} + +``` + +## NavigationDestinations +You might have noticed that we've defined our `ExampleFragment` and `ExampleComposable` in the example above before we've even begun to think about how we're going to implement the `ShowUserProfile` and `SelectDate` destinations. That's because implementing a `NavigationDestination` in Enro is the least interesting part of the process. All you need to do to make this application complete is to build an Activity, Fragment or Composable, and mark it as the `NavigationDestination` for a particular `NavigationKey`. + +The recommended approach to mark an Activity, Fragment or Composable as a `NavigationDestination` is to use the Enro annotation processor and the `@NavigationDestination` annotation. + +### In a Fragment or Activity: +```kotlin + +@NavigationDestination(ShowUserProfile::class) +class ProfileFragment : Fragment { + // providing a type to `by navigationHandle()` gives you access to the NavigationKey + // used to open this destination, and you can use this to read the + // arguments for the destination + val navigation by navigationHandle() +} + +``` + +### In a Composable: +```kotlin + +@Composable +@NavigationDestination(SelectDate::class) +fun SelectDateComposable() { + // providing a type to `navigationHandle()` gives you access to the NavigationKey + // used to open this destination, and you can use this to read the + // arguments for the destination + val navigation = navigationHandle() + // ... + Button(onClick = { + navigation.closeWithResult( /* pass a local date here to return that as a result */ ) + }) { /* ... */ } +} + +``` + +### Without annotation processing: +If you'd prefer to avoid annotation processing, you can use a DSL to define these bindings when creating your application (see [here]() for more information): +```kotlin + +// this needs to be registered with your application +val exampleNavigationComponent = createNavigationComponent { + fragmentDestination() + composableDestination { SelectDateComposable() } +} + +``` \ No newline at end of file diff --git a/docs/ghpages-old/docs/multi-module-projects.md b/docs/ghpages-old/docs/multi-module-projects.md new file mode 100644 index 000000000..763c59c81 --- /dev/null +++ b/docs/ghpages-old/docs/multi-module-projects.md @@ -0,0 +1,12 @@ +# Multi-module projects +Enro was designed to support large multi-module projects just as well as it supports small projects. There are a few things to keep in mind when using Enro in a multi-module project, but it is generally very simple to set up. + +The most important part of supporting multi-module projects is the fact that NavigationKeys can be defined seperately to the destinations/screens that they are bound to. This means that you can define a NavigationKey in one module, and bind it to a destination in another module. + +Exactly how this is done depends on the module structure of the project in question. Here are some examples of different ways that this might work in a multi-module project: +1. A single module contains all the NavigationKeys. Modules that depend on this module can then bind these NavigationKeys to destinations, or use these NavigationKeys for navigation. +2. Each "feature" module defines an "api" or "public" module, which contains all of the NavigationKeys for that feature. Other modules can then depend on this "api" module use these NavigationKeys for navigation. In this situation, it would be expected that there is an "internal", "private" or "implementation" which provides the destinations for these NavigationKeys. + +Essentially, to make Enro work across a multi-module project, all you need to do is make sure that the NavigationKeys are defined in a location that is visible to the modules that need to use them, either for binding the destinations or for performing navigation to those NavigationKeys. + +For more information on defining NavigationKeys, please see the [NavigationKeys documentation](./navigation-keys.md). \ No newline at end of file diff --git a/docs/ghpages-old/docs/navigation-containers.md b/docs/ghpages-old/docs/navigation-containers.md new file mode 100644 index 000000000..92d33a885 --- /dev/null +++ b/docs/ghpages-old/docs/navigation-containers.md @@ -0,0 +1 @@ +# Navigation Containers \ No newline at end of file diff --git a/docs/ghpages-old/docs/navigation-destinations.md b/docs/ghpages-old/docs/navigation-destinations.md new file mode 100644 index 000000000..92d33a885 --- /dev/null +++ b/docs/ghpages-old/docs/navigation-destinations.md @@ -0,0 +1 @@ +# Navigation Containers \ No newline at end of file diff --git a/docs/ghpages-old/docs/navigation-handles.md b/docs/ghpages-old/docs/navigation-handles.md new file mode 100644 index 000000000..4e4fa0c2f --- /dev/null +++ b/docs/ghpages-old/docs/navigation-handles.md @@ -0,0 +1 @@ +# Navigation Handles \ No newline at end of file diff --git a/docs/ghpages-old/docs/navigation-keys.md b/docs/ghpages-old/docs/navigation-keys.md new file mode 100644 index 000000000..fdc335f4d --- /dev/null +++ b/docs/ghpages-old/docs/navigation-keys.md @@ -0,0 +1,103 @@ +# NavigationKeys +NavigationKeys are the foundation of Enro. They are the contract that defines how navigation works in your application. + +A NavigationKey is a simple data class that represents a screen in your application. It can be thought of like the function signature or interface for a screen. Just like a function signature, a NavigationKey represents a contract. + +## Defining NavigationKeys +To define a NavigationKey, create a data class that implements the NavigationKey interface. There are several different interfaces underneath NavigationKey which provide different options. The interfaces that your NavigationKeys implement will depend on the type of navigation that the NavigationKey should support. + +Note on Parcelable: +The NavigationKey interface extends Parcelable. All NavigationKeys must be parcelable so that they can be passed between screens, and saved/restored during configuration changes and application process death. Using the `kotlin-parcelize` plugin and the `@Parcelize` annotation to generate a Parcelable implementation for your NavigationKeys is the easiest way to support this. + +### SupportsPush +NavigationKeys that extend `NavigationKey.SupportsPush` can be used to push a new screen onto a NavigationContainer's backstack. This is the most common type of navigation, and is used for screens that should fill the entire space within a container. When a screen is pushed, the previous screen is hidden, and the new screen is shown. Only the top-most pushed screen will be visible within a container. + +```kotlin + +@Parcelize +data class ShowUserProfile( + val userId: UserId +) : NavigationKey.SupportsPush + +``` + +### SupportsPresent +NavigationKeys that extend `NavigationKey.SupportsPresent` can be used to present a new screen on top of the current screen. This is useful for screens that should be shown in a dialog, or screens that should only take up part of the space within a container. When a screen is presented, the top-most pushed screen will be visible underneath it. + +```kotlin +data class ShowUpdateRequired( + val updateUrl: String +) : NavigationKey.SupportsPresent +``` + +### WithResult +NavigationKeys that implement `NavigationKey.SupportsPush.WithResult`, or `NavigationKey.SupportsPresent.WithResult` can be used to declare screens that return a result. Both SupportsPush and SupportsPresent screens can return results. + +```kotlin + +@Parcelize +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.SupportsPresent.WithResult + +``` + +### Mixing and matching +A NavigationKey can support multiple types of navigation. For example, a screen that can be pushed, presented, and returns a result could look like this: + +```kotlin + +@Parcelize +data class MixAndMatchScreen( + val parameter: String, +) : NavigationKey.SupportsPush.WithResult, NavigationKey.SupportsPresent.WithResult + +``` + +## Using NavigationKeys to Navigate +To perform navigation using a NavigationKey, you'll need to get a NavigationHandle. A NavigationHandle is a simple interface that allows you to perform navigation. The syntax for getting a NavigationHandle is slightly different depending on the type of screen you're in. For more information on NavigationHandles, please see the [NavigationHandles documentation](./navigation-handles.md). + +To push or present, all you need is a NavigationHandle: +```kotlin + +val navigationHandle = // get a navigation handle from somewhere + +// Push a screen onto the backstack +navigationHandle.push( + ShowUserProfile(userId = "1234") +) + +// Present a screen on top of the current screen +navigationHandle.present( + ShowUpdateRequired(updateUrl = "https://example.com/update") +) + +``` + +To receive a result from a destination, you'll need to set up a NavigationResultChannel. Just with a NavgationHandle, the syntax for creating a NavigationResultChannel is slightly different depending on the type of screen you're in. For more information on NavigationResultChannels, please see the [Navigation results documentation](./navigation-results.md). + +When you have created a NavigationResultChannel, it is very similar to using a NavigationHandle to perform navigation. You can use the `push` or `present` functions to either push or present the NavigationKey. The only difference is that you will receive a result from the destination, which will be sent to the `onResult` lambda that you provide when creating the NavigationResultChannel. + +```kotlin + +val resultChannel by registerForNavigationResult { selectedDate: LocalDate -> + /* do something! */ +} + +resultChannel.present( + SelectDate(maxDate = LocalDate.now() +) + +``` + +## Binding NavigationKeys to a destination +Once you've defined a NavigationKey, it is important to bind it to a destination. A destination is a screen that will be shown when the NavigationKey is used. Activities, Fragments and Composables can all be used as NavigationDestinations. In general, to bind a NavigationKey to a destination, you'll need to either annotate the destination with `@NavigationDestination` and providing a class reference to the NavigationKey, or manually bind the NavigationKey to the destination when creating the NavigationController for your application. For more information on NavigationDestinations, please see the [Navigation destinations documentation](./navigation-destinations.md). + +## Naming NavigationKeys +Enro does not make a strong recommendation on how NavigationKeys should be named at this stage. There are however some conventions that have been identified in different projects. The most important thing is to be consistent within your own project, and use a pattern that feels natural to you. Here are some common patterns that have been identified in different projects: + +1. Name NavigationKeys like actions `ShowUserProfile`, `SelectDate`, `ShowUpdateRequired`. This makes it clear that the NavigationKey represents an action, and that invoking the action will result in a screen being shown. +2. Name NavigationKeys using a "screen" suffix `UserProfileScreen`, `DateSelectionScreen`, `UpdateRequiredScreen`. This makes it clear that the NavigationKey represents a screen, and that the screen can be shown by invoking the NavigationKey. +3. Name NavigationKeys using a "key" suffix `UserProfileKey`, `DateSelectionKey`, `UpdateRequiredKey`. This makes it clear that the NavigationKey represents a NavigationKey. +4. Name NavigationKeys using a "destination" suffix `UserProfileDestination`, `DateSelectionDestination`, `UpdateRequiredDestination`. This makes it clear that the NavigationKey represents a NavigationDestination binding. diff --git a/docs/ghpages-old/docs/navigation-results.md b/docs/ghpages-old/docs/navigation-results.md new file mode 100644 index 000000000..353ae1119 --- /dev/null +++ b/docs/ghpages-old/docs/navigation-results.md @@ -0,0 +1,128 @@ +# Navigation Results +The ability for a NavigationKey to define a result type is an important feature of Enro. It allows you to define a rich contract for a screen, where the contract represents not just an input type, but also an output type. This allows screens within an application to be more independent of one another and helps screens be as re-usable as possible. + +Making use of this feature is very simple, and is done by defining a NavigationKey that implements `NavigationKey.SupportsPush.WithResult` or `NavigationKey.SupportsPresent.WithResult`. For more information on defining NavigationKeys, please see the [NavigationKeys documentation](./navigation-keys.md). + +## Defining a `NavigationKey.WithResult` +Any NavigationKey can define a result type by implementing the `...WithResult` interface, where `T` is the type of the result. For example, a NavigationKey that returns a `LocalDate` might look like this: + +```kotlin + +@Parcelize +data class SelectDate( + val minDate: LocalDate? = null, + val maxDate: LocalDate? = null, +) : NavigationKey.SupportsPresent.WithResult + +``` + +For more information on defining NavigationKeys, please see the [NavigationKeys documentation](./navigation-keys.md). + +## Receiving results +Receiving a result from a NavigationKey works in a similar way to Android's `registerForActivityResult` functionality. To receive a result, you'll need to create a `NavigationResultChannel` using the `registerForNavigationResult` function. This function takes a lambda that will be invoked when a result is received. The lambda will be invoked with the result value. + +### Defining a `NavigationResultChannel` +A `NavigationResultChannel` can be defined in any Activity, Fragment, Composable or Enro-supported ViewModel. The syntax for defining a `NavigationResultChannel` is slightly different depending on the type of screen you're in, but always uses a function called `registerForNavigationResult`. + +`registerForNavigationResult` takes the following arguments: +1. (Required) A generic type argument, which represents the type of the result that will be received. +2. (Required) An `onResult` lambda, which will be invoked when a result is received, which receives a single argument of the generic type provided to `registerForNavigationResult`. +3. (Optional) An `onClosed` lambda, which will be invoked when if a screen opened using the result channel is closed without a result being sent. + +When you have created a result channel using `registerForNavigationResult`, the result channel can be used to `push` or `present` any NavigationKey that implements `NavigationKey.SupportsPush.WithResult` or `NavigationKey.SupportsPresent.WithResult`. The result channel will then receive the result from the destination, and invoke the `onResult` lambda that was provided when creating the result channel. The `T` type of the `...WithResult` NavigationKey must match the `T` used when creating the result channel. + +If there are multiple result channels in the same screen, registered for the same `T`, they can all be used to receive results, and the result channel that is used to `push` or `present` will be the one that receives the result (this works safely across configuration change and process death). + +#### Activities/Fragments/ViewModels +In an Activity, Fragment or ViewModel, the `registerForNavigationResult` function should be used as follows: + +#### Composables +In a Composable, the `registerForNavigationResult` function should be used as follows: + +```kotlin +@Composable +fun ExampleComposable() { + val exampleResultChannel = registerForNavigationResult { result: ExampleResult -> + // handle result + } + + Button( + onClick = { exampleResultChannel.present(ExampleResultNavigationKey()) } + ) { + Text("Launch Example Result") + } +} +``` + +```kotlin +class MyActivityFragmentOrViewModel : ... { + private val exampleResultChannel by registerForNavigationResult { result: ExampleResult -> + // handle result + } + + fun launchExampleResult() { + exampleResultChannel.present(ExampleResultNavigationKey()) + } +} + +``` + +## Sending results +From a screen that is bound to a `...WithResult` NavigationKey, you can send a result by calling the `closeWithResult` function on the NavigationHandle. This function takes a single argument, which is the result value. The type of the result value must match the type of the `...WithResult` NavigationKey. The `closeWithResult` function is only available for `TypedNavigationHandle`, where `K` is a NavigationKey `...WithResult`, which have been created through the `navigationHandle()` function. For more information on NavigationHandles, please see the [NavigationHandles documentation](./navigation-handles.md). + +Essentially, when you want to send a result, make sure you use the typed version of the `navigationHandle` functions, and pass in the type of the NavigationKey `...WithResult` that you want to send a result for, and then the `closeWithResult` will be available. + +It is also possible to delegate a result to another screen, rather than return the result yourself. For more information on delegating results, please see the [Delegating results](#delegating-results) section. + +#### Composables +In a Composable, the `closeWithResult` function should be used as follows: + +```kotlin +@Parcelize +class ExampleResultKey: NavigationKey.SupportsPush.WithResult + +@Composable +fun ExampleComposable() { + val navigationHandle = navigationHandle() + // private val navigationHandle = navigationHandle() <- this won't work, as it returns an untyped NavigationHandle + + Button( + onClick = { navigationHandle.closeWithResult(ExampleResult(...)) } + ) { + Text("Close with result") + } +} +``` + +#### Activities/Fragments/ViewModels +In an Activity, Fragment or ViewModel, the `closeWithResult` function should be used as follows: + +```kotlin +@Parcelize +class ExampleResultKey: NavigationKey.SupportsPush.WithResult + +class ExampleFragmentActivityOrViewModel : ... { + private val navigationHandle by navigationHandle() + // private val navigationHandle by navigationHandle() <- this won't work, as it returns an untyped NavigationHandle + + fun sendResult() { + navigationHandle.closeWithResult(ExampleResult(...)) + } +} +``` + +## Advanced +### Delegating results +Enro provides support for delegating results to another screen. This functionality can be used to build small, multi-step flows that are made up of multiple screens. For example, a flow that requires the user to select a date, and then select a time, could be built using two screens, where the first screen delegates to the second screen. These kinds of flows are often referred to as "embedded navigation flows", as the logic for the flow is embedded within each screen within the flow. When the screen which is delegated to returns a result, the screen that delegated to it will be closed at the same time, and the result will be delivered to the original screen that requested the result. + +For example: +1. Starting on "Screen A" where the backstack is `... -> A` +2. Screen A requests a result from Screen B, the backstack becomes `... -> A -> B` +3. Screen B delegates its result to Screen C, the backstack becomes `... -> A -> B -> C` +4. There are several possible outcomes: + a. Screen C returns a result, which would cause the result to be delivered to Screen A, and the backstack would become `... -> A` + b. Screen C closes without a result, which would cause no result to be delivered, but Screen C to close, and the backstack would become `... A -> B` + c. Screen C continues the by delegating to Screen D, the backstack would become `... -> A -> B -> C -> D`, and the result behaviour would be the same when Screen D is closed; the result would be delivered to Screen A, and the backstack would become `... -> A` + + +To delegate a result, you'll need to use the `deliverResultFromPush` or `deliverResultFromPresent` functions on the `NavigationHandle`. This function takes a single argument, which is the `NavigationKey` that you want to delegate to. The `NavigationKey` that you delegate to must be a `...WithResult` NavigationKey, where `T` is the same type as the `...WithResult` NavigationKey that you are delegating from. diff --git a/docs/ghpages-old/docs/testing.md b/docs/ghpages-old/docs/testing.md new file mode 100644 index 000000000..94cfd7d77 --- /dev/null +++ b/docs/ghpages-old/docs/testing.md @@ -0,0 +1 @@ +# Testing \ No newline at end of file diff --git a/docs/ghpages-old/docs/viewmodels.md b/docs/ghpages-old/docs/viewmodels.md new file mode 100644 index 000000000..1900d7c7b --- /dev/null +++ b/docs/ghpages-old/docs/viewmodels.md @@ -0,0 +1,215 @@ +# ViewModels + +Enro allows ViewModels to access the `NavigationHandle` for the screen that they are being used by, which allows navigation logic to be managed from within a ViewModel. This is functionality is optional, and some people prefer to leave the navigation logic within the View layer of their applications. + +## Getting a NavigationHandle inside a ViewModel +To get a NavigationHandle from inside of a ViewModel, use the `by navigationHandle()` property delegate. This will return a `TypedNavigationHandle`. For more information on what a NavigationHandle is, see [Navigation Handles](./navigation-handles.md). + +```kotlin + +@Parcelize +class ExampleNavigationKey( + val exampleArgument: String +) : NavigationKey.SupportsPush + +class ExampleViewModel : ViewModel() { + +``` +{:.code-not-important} +```kotlin + private val navigation by navigationHandle() +``` +{:.code-important} +```kotlin + +} + +``` +{:.code-not-important} + +With a NavigationHandle inside of your ViewModel, you are able to perform all the regular functions that are available on a NavigationHandle. + +```kotlin + +@Parcelize +class ExampleNavigationKey( + val exampleArgument: String +) : NavigationKey.SupportsPush + +class ExampleViewModel : ViewModel() { + + private val navigation by navigationHandle() + +``` +{:.code-not-important} +```kotlin + fun goToNextScreen() { + val argument = navigation.key.exampleArgument + val nextScreenKey = NextScreenKey(nextScreenArgument = argument.hashCode()) + navigation.push(nextScreenKey) + } +``` +{:.code-important} +```kotlin + +} + +``` +{:.code-not-important} + +## Handle Results +ViewModels are also able to create result channels, and manage results. To create a NavigationResultChannel, use the `by registerForNavigationResult()` property delegate. This will return a NavigationResultChannel that will handle results of type `T`. For more information of NavigationResultChannels, see [Navigation Results](./navigation-results.md). + +```kotlin + +@Parcelize +class ExampleViewModel : ViewModel() { + + private val navigation by navigationHandle() +``` +{:.code-not-important} +```kotlin + private val stringResultChannel by registerForNavigationResult { result: String -> + // ... + } +``` +{:.code-important} +```kotlin + + fun onRequestString() { + stringResultChannel.present(RequestStringKey()) + } +} + +``` +{:.code-not-important} + +## Creating navigation aware ViewModels +To ensure that a NavigationHandle is available for `by navigationHandle()`, the `ViewModelProvider.Factory` that is used to create the ViewModel must be made aware of the local NavigationHandle. Exactly how this works depends on whether the navigation destination is an Activity/Fragment/Composable. + +### Composables +By default, all Composable navigation destinations (i.e. Composables annotated with NavigationDestination) already have a ViewModel Factory that is aware of the NavigationHandle. This means that ViewModels created using the standard Composable `viewModel()` function should be able to use `by navigationHandle()`, as long as a custom ViewModelProvider.Factory is not passed as a parameter to the `viewModel()` function. + +If you are passing a custom ViewModelProvider.Factory to this function, you will need to bind the NavigationHandle to the factory using `withNavigationHandle`. + +```kotlin + +@Composable +@NavigationDestination(ExampleNavigationKey::class) +fun ViewModelExample() { + // No factory provided: + val firstViewModel = viewModel() + + // Custom factory, using `withNavigationHandle()`: + val secondViewModel = viewModel( + factory = CustomViewModelFactory().withNavigationHandle() + ) +} + +``` + +If you use the same custom ViewModelProvider.Factory for all screens in your application, you may want to globally set the ViewModel Factory for all Composable navigation destinations, and ensure that this globally set factory also provides a NavigationHandle. + +To do this, you can configure the `composeEnvironment` in the `createNavigationController` block of your Application class. The `composeEnvironment` configuration allows you to provide a common rendering environment for all Composable destinations, and is useful to globally set a theme, or provide screen-specific CompositionLocals. + +Here is an example of a `composeEnvironment` block which will wrap the LocalViewModelStoreOwner with a special ViewModelStoreOwner that implements HasDefaultViewModelProviderFactory, and provides a custom ViewModelProvider.Factory. This will allow any screen to use the Composable `viewModel()` function without needing to specify a custom ViewModelProvider.Factory, and which will allow these ViewModels to access a NavigationHandle. + +```kotlin + +@NavigationComponent +class ExampleApplication : Application(), NavigationApplication { + override val navigationController = createNavigationController { + composeEnvironment { content -> + ProvideCustomViewModelFactory(content) + } + } +} + +@Composable +fun ProvideCustomViewModelFactory(content: @Composable () -> Unit) { + val navigation = navigationHandle() + val viewModelStoreOwner = LocalViewModelStoreOwner.current + val wrappedViewModelStoreOwner = remember(navigation, viewModelStoreOwner) { + WrappedViewModelStore( + wrapped = requireNotNull(viewModelStoreOwner), + factory = CustomViewModelFactory().withNavigationHandle(navigation) + ) + } + CompositionLocalProvider( + LocalViewModelStoreOwner provides wrappedViewModelStoreOwner + ) { + content() + } +} + +class WrappedViewModelStore( + val wrapped: ViewModelStoreOwner, + val factory: ViewModelProvider.Factory +) : ViewModelStoreOwner, HasDefaultViewModelProviderFactory { + override val viewModelStore: ViewModelStore + get() = wrapped.viewModelStore + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = factory + + override val defaultViewModelCreationExtras: CreationExtras + get() = when (wrapped) { + is HasDefaultViewModelProviderFactory -> wrapped.defaultViewModelCreationExtras + else -> super.defaultViewModelCreationExtras + } +} + +``` + +Enro provides a `ProvideViewModelFactory` utility method which can be used to achieve the same as the code above. + +```kotlin + +@NavigationComponent +class ExampleApplication : Application(), NavigationApplication { + override val navigationController = createNavigationController { + composeEnvironment { content -> + ProvideViewModelFactory( + factory = CustomViewModelFactory(), + content = content + ) + } + } +} + +``` + +#### Hilt +If you are using Hilt, and the Activity hosting your Composable destinations is an AndroidEntryPoint, you should not need to provide a custom factory at all, and the Composable `viewModel()` function should use the Hilt ViewModelProvider.Factory. + +### Activities and Fragments +From an Activity or Fragment, you have two options for making sure that a ViewModel has a NavigationHandle available: +1. Use `by enroViewModels()` instead of `by viewModels()` + +```kotlin + +class ExampleActivity : AppCompatActivity() { + private val firstViewModel by enroViewModels() + + private val secondViewModel by enroViewModels( + factoryProducer = { CustomViewModelFactory() } + ) +} + +``` + +2. Use `withNavigationHandle()` to bind a NavigationHandle to an existing ViewModelProvider.Factory + +```kotlin + +class ExampleActivity : AppCompatActivity() { + + private val navigation by navigationHandle() + private val firstViewModel by viewModels( + factoryProducer = { + CustomViewModelFactory().withNavigationHandle(navigation) + } + ) +} + +``` diff --git a/docs/ghpages-old/index.md b/docs/ghpages-old/index.md new file mode 100644 index 000000000..d6649307f --- /dev/null +++ b/docs/ghpages-old/index.md @@ -0,0 +1,39 @@ +--- +title: Overview +has_children: true +nav_order: 1 +--- + +This documentation website is currently a work-in-progress +{: .highlight } + +# Enro + +Enro is a powerful navigation library based on a simple idea; screens within an application should behave like functions. + +[Introduction](./docs/introduction.md) + +## Gradle quick-start +```kotlin + +dependencies { + implementation("dev.enro:enro:2.2.0") + ksp("dev.enro:enro-processor:2.2.0") // both kapt and ksp are supported + testImplementation("dev.enro:enro-test:2.2.0") +} + +``` + +### 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/ghpages/assets/fonts/cutive_mono.ttf b/docs/ghpages/assets/fonts/cutive_mono.ttf new file mode 100644 index 000000000..e5bcf8842 Binary files /dev/null and b/docs/ghpages/assets/fonts/cutive_mono.ttf differ 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/getting-started/basic-concepts.md b/docs/ghpages/docs/getting-started/basic-concepts.md new file mode 100644 index 000000000..2448d3729 --- /dev/null +++ b/docs/ghpages/docs/getting-started/basic-concepts.md @@ -0,0 +1,92 @@ +# Basic Concepts + +This guide introduces the fundamental concepts of Enro's navigation system. + +## Navigation Philosophy + +At its core, Enro is built around a few key principles: + +1. **Screen Contracts**: Each screen has a clearly defined contract (NavigationKey) that specifies its inputs and outputs. +2. **Decoupled Navigation**: Screens don't need to know about the implementation of other screens they navigate to. +3. **Type Safety**: Navigation is fully type-safe, with compile-time checking of navigation parameters. +4. **Platform Agnostic**: The same navigation concepts work across different platforms and UI frameworks. + +## Core Components + +### NavigationKey + +A NavigationKey represents the contract for a screen. It defines: +- The inputs required to display the screen +- The output type (if any) that the screen can produce +- Any additional metadata needed for navigation + +```kotlin +// Simple key +data class ProfileKey( + val userId: String +) : NavigationKey.SupportsPush + +// Key with result +data class SelectDateKey( + val initialDate: LocalDate? = null +) : NavigationKey.SupportsPresent.WithResult +``` + +### NavigationDestination + +A NavigationDestination is a screen implementation that can be navigated to. It is bound to a specific NavigationKey type and can be implemented as: +- Android: Activity, Fragment, or Composable +- iOS: UIViewController or SwiftUI View +- Desktop: Window or Composable + +```kotlin +@NavigationDestination(ProfileKey::class) +@Composable +fun ProfileScreen() { + val navigation = navigationHandle() + // Screen implementation... +} +``` + +### NavigationContainer + +A NavigationContainer is a location within the UI that can host screens. It: +- Maintains a backstack of screens +- Manages screen lifecycles +- Handles navigation animations +- Can be nested within other containers + +```kotlin +val container = rememberNavigationContainer( + root = HomeKey(), + emptyBehavior = EmptyBehavior.CloseParent +) +``` + +### NavigationHandle + +A NavigationHandle provides the API for controlling navigation within a screen. It: +- Provides access to the screen's NavigationKey +- Allows execution of navigation instructions +- Manages navigation state +- Handles navigation results + +```kotlin +val navigation = navigationHandle() +navigation.push(EditProfileKey(navigation.key.userId)) +``` + +## Navigation Flow + +1. **Navigation Request**: A screen uses its NavigationHandle to request navigation to another screen +2. **Key Creation**: The NavigationKey for the target screen is created with the required parameters +3. **Destination Resolution**: Enro finds the appropriate NavigationDestination for the key +4. **Screen Creation**: The destination is created and displayed +5. **Result Handling**: If the screen produces a result, it's returned to the calling screen + +## Next Steps + +- Learn about [Navigation Keys](../core-concepts/navigation-keys.md) +- Understand [Navigation Destinations](../core-concepts/navigation-destinations.md) +- Explore [Navigation Containers](../core-concepts/navigation-containers.md) +- See how to use [Navigation Handles](../core-concepts/navigation-handles.md) \ No newline at end of file diff --git a/docs/ghpages/docs/getting-started/installation.md b/docs/ghpages/docs/getting-started/installation.md new file mode 100644 index 000000000..c262c9e1c --- /dev/null +++ b/docs/ghpages/docs/getting-started/installation.md @@ -0,0 +1,125 @@ +--- +title: Installation +parent: Getting Started +nav_order: 1 +--- + +# Installation + +Enro is available through Maven Central. Add the following dependencies to your project: + +## Gradle (Kotlin DSL) + +```kotlin +dependencies { + // Core library + implementation("dev.enro:enro:2.8.3") + + // Annotation processor (choose one) + ksp("dev.enro:enro-processor:2.8.3") + // If using KAPT (deprecated, prefer KSP) + // kapt("dev.enro:enro-processor:2.8.3") + + // Optional test utilities + testImplementation("dev.enro:enro-test:2.8.3") +} +``` + +## Platform Setup + +### Common +In your application module, define a "NavigationComponent". This is a class that extends `NavigationComponentConfiguration` and is annotated with `@NavigationComponent`. + +When Enro runs code generation, it uses this `NavigationComponent` to generate code that allows Enro to be installed into your application. In a multi-platform project, you can declare the `NavigationComponent` in the common source set, or declare one in each platform source set. Android applications may also annotate their Application class with `@NavigationComponent` instead of using a `NavigationComponentConfiguration` class. + +```kotlin +@NavigationComponent +class ExampleNavigationComponent : NavigationComponentConfiguration() +``` + +### Android +To install Enro into an Android application: +1. Implement `NavigationApplication` on your Application class +2. Override the `navigationController` property, and call `installNavigationController` on your NavigationComponent. +3. Optionally, you can pass a lambda to the `installNavigationController` function to set additional configuration + +```kotlin +class ExampleApplication : Application(), NavigationApplication { + override val navigationController = ExampleNavigationComponent.installNavigationController(this) { + // optional additional configuration + } +} +``` + +If you are building an Android-only application, you can annotate your Application class with `@NavigationComponent` instead of declaring a separate NavigationComponent class: +1. Annotate your Application class with `@NavigationComponent` +2. Implement `NavigationApplication` on your Application class +3. Override the `navigationController` property, and call `installNavigationController`. +4. Optionally, you can pass a lambda to the `installNavigationController` function to set additional configuration + +```kotlin +@NavigationComponent +class ExampleApplication : Application(), NavigationApplication { + override val navigationController = installNavigationController(this) { + // optional additional configuration + } +} +``` + +### iOS + +Initialize Enro in your iOS application's AppDelegate: + +```swift +@main +class ExampleAppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + EnroComponent.shared.installNavigationController( + application: application, + root: // Provide a Root NavigationInstruction here + strictMode: false, + useLegacyContainerPresentBehavior: false, + backConfiguration: Enro.shared.backConfiguration.Default, + block: { scope in + // Add any additional configuration here + } + ) + return true + } +} +``` + +### Desktop + +Initialize Enro in your desktop application's main function: + +```kotlin +fun main() = application { + val controller = EnroComponent.rememberNavigationController( + root = // Provide a Root NavigationInstruction here + ) { + // Add any additional configuration here + } + controller.windowManager.Render() +} +``` + +### Web + +Initialize Enro in your web application's entry point: + +```kotlin +fun main() { + val controller = EnroComponent.installNavigationController( + document = document, + root = // Provide a Root NavigationInstruction here + ) { + // Add any additional configuration here + } + + EnroViewport( + controller = controller, + ) +} +``` diff --git a/docs/ghpages/docs/platform/web.md b/docs/ghpages/docs/platform/web.md new file mode 100644 index 000000000..10de3a303 --- /dev/null +++ b/docs/ghpages/docs/platform/web.md @@ -0,0 +1,6 @@ +--- +title: Web Platform Guide +parent: Platform-Specific Guides +nav_order: 4 +--- + diff --git a/docs/ghpages/index.md b/docs/ghpages/index.md new file mode 100644 index 000000000..4e730f013 --- /dev/null +++ b/docs/ghpages/index.md @@ -0,0 +1,56 @@ +--- +title: Overview +has_children: true +nav_order: 1 +--- + +# Enro + +Enro is a powerful navigation library for Android/iOS/Desktop/Web applications built with Jetpack Compose/Kotlin multiplatform. + +Enro is based on one simple idea; screens within an application should behave like functions. + +## Getting Started + +- [Installation](docs/getting-started/installation.md) +- [Basic Concepts](docs/getting-started/basic-concepts.md) + +## 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) + + +### 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)* \ No newline at end of file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bd31488dd..ced0ae27c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -148,7 +148,7 @@ fun MyEnroComposable() { #### 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` +### `MissingNavigationBinding` 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` 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..7aaa8159a --- /dev/null +++ b/enro-annotations/src/commonMain/kotlin/dev/enro/annotations/NavigationPath.kt @@ -0,0 +1,9 @@ +package dev.enro.annotations + + +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.CONSTRUCTOR) +@ExperimentalEnroApi +public annotation class NavigationPath( + val pattern: String, +) \ No newline at end of file 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/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..3f6d38089 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/NavigationBackstack.kt @@ -0,0 +1,27 @@ +package dev.enro + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +@Immutable +public class NavigationBackstack( + 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..84d621112 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/NavigationKey.kt @@ -0,0 +1,275 @@ +package dev.enro + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import dev.enro.annotations.AdvancedEnroApi +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 + + /** + * 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 { + this::class.qualifiedName ?: error("MetadataKeys must have a valid qualifiedName") + } + } + + /** + * 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/path/NavigationPathBinding.fromNavigationKey.kt b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.fromNavigationKey.kt new file mode 100644 index 000000000..8a3068bc9 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.fromNavigationKey.kt @@ -0,0 +1,592 @@ +package dev.enro.path + +import dev.enro.NavigationKey +import kotlin.reflect.KProperty1 +import kotlin.reflect.typeOf + +@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..beafc672d --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/NavigationPathBinding.kt @@ -0,0 +1,44 @@ +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 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 +} 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..1c55123c5 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/PathData.kt @@ -0,0 +1,25 @@ +package dev.enro.path + +public class PathData( + internal val data: MutableMap = mutableMapOf(), +) { + public fun optional(key: String): String? { + return data[key] + } + + public fun require(key: String): String { + return requireNotNull(data[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) + } + } +} \ No newline at end of file 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..96b6e33d1 --- /dev/null +++ b/enro-common/src/commonMain/kotlin/dev/enro/path/PathPattern.kt @@ -0,0 +1,190 @@ +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 + } + + 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/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/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/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/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..7f6148d26 --- /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.overlay() + } +} \ 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..24b9dd87c --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationInstruction.kt @@ -0,0 +1,55 @@ +@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 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..d59987ad3 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/NavigationKey.kt @@ -0,0 +1,52 @@ +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") + 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..9ea9e2087 --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/compose/registerForNavigationResult.kt @@ -0,0 +1,44 @@ +package dev.enro.core.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.enro.NavigationKey +import dev.enro.core.NavigationDirection +import dev.enro.result.NavigationResultChannel +import dev.enro.result.NavigationResultScope +import dev.enro.result.open +import dev.enro.withMetadata + +@Composable +public inline fun registerForNavigationResult( + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onResult: NavigationResultScope>.(R) -> Unit, +): NavigationResultChannelCompat { + val channel = dev.enro.result.registerForNavigationResult( + onClosed = onClosed, + onCompleted = onResult, + ) + return remember(channel) { + NavigationResultChannelCompat(channel) + } +} + +public class NavigationResultChannelCompat( + private val channel: NavigationResultChannel +) { + public fun push( + key: dev.enro.core.NavigationKey.SupportsPush.WithResult, + ) { + channel.open( + key.withMetadata(NavigationDirection.MetadataKey, NavigationDirection.Push) + ) + } + + public fun present( + key: dev.enro.core.NavigationKey.SupportsPresent.WithResult, + ) { + channel.open( + key.withMetadata(NavigationDirection.MetadataKey, NavigationDirection.Present) + ) + } +} 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..113408d38 --- /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/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..84f33a04b --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/parentContainer.kt @@ -0,0 +1,13 @@ +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() { + val parentContainer = runCatching { LocalNavigationContainer.current } + .getOrNull() + return parentContainer + } \ No newline at end of file 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..8e095eceb --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result.kt @@ -0,0 +1,47 @@ +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.core.compose.NavigationResultChannelCompat +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 -> + NavigationResultChannelCompat( + 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..67befd84a --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/NavigationResultChannel.kt @@ -0,0 +1,34 @@ +package dev.enro.core.result + +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationKey +import dev.enro.result.open +import dev.enro.withMetadata + +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..68dd3adeb --- /dev/null +++ b/enro-compat/src/androidMain/kotlin/dev/enro/core/result/SyntheticDestinationScope.sendResult.kt @@ -0,0 +1,32 @@ +package dev.enro.core.result + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.core.NavigationDirection +import dev.enro.core.getNavigationHandle +import dev.enro.ui.destinations.SyntheticDestinationScope + +public fun SyntheticDestinationScope>.sendResult( + result: T +) { + AdvancedResultExtensions.setResultForInstruction( + navigationController = context.controller, + instruction = instance, + result = result + ) +} + +public fun SyntheticDestinationScope>.forwardResult( + navigationKey: NavigationKey.WithResult, + direction: NavigationDirection = when(navigationKey) { + is dev.enro.core.NavigationKey.SupportsPresent -> NavigationDirection.Present + else -> NavigationDirection.Push + } +) { + val instruction = AdvancedResultExtensions.getInstructionToForwardResult( + originalInstruction = instance, + direction = direction, + navigationKey = navigationKey + ) + context.getNavigationHandle().execute(NavigationOperation.Open(instruction)) +} \ No newline at end of file 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/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/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..ad9b861da --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationBindingGenerator.kt @@ -0,0 +1,258 @@ +package dev.enro.processor.generator + +import com.google.devtools.ksp.KspExperimental +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.KSDeclaration +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.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") + .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() + + if (navigationPaths.isEmpty()) return this + navigationPaths.forEach { (path, constructor) -> + val pattern = path.pattern + 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 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..83aca96ce --- /dev/null +++ b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationComponentGenerator.kt @@ -0,0 +1,248 @@ +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( + "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( + 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, + 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/build.gradle.kts b/enro-runtime/build.gradle.kts new file mode 100644 index 000000000..a19ed4d00 --- /dev/null +++ b/enro-runtime/build.gradle.kts @@ -0,0 +1,46 @@ +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("dev.enro:enro-annotations:${project.enroVersionName}") + api("dev.enro:enro-common:${project.enroVersionName}") + implementation(libs.compose.runtime) + implementation(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")) + } + 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/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..034597538 --- /dev/null +++ b/enro-runtime/src/androidMain/kotlin/dev/enro/result/registerForNavigationResult.fragment.kt @@ -0,0 +1,57 @@ +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 ?: TODO() + 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..325966a55 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/EnroController.kt @@ -0,0 +1,100 @@ +package dev.enro + +import androidx.compose.runtime.Stable +import androidx.savedstate.serialization.SavedStateConfiguration +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 { + // TODO NEED TO CONFIGURE THIS + internal val isDebug = 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() + + 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 -> + val controller = requireInstance() + 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") + } + + 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..f22de18ea --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.kt @@ -0,0 +1,132 @@ +package dev.enro + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import dev.enro.annotations.AdvancedEnroApi +import kotlin.jvm.JvmName + +public abstract class NavigationHandle internal constructor() : LifecycleOwner { + public abstract val savedStateHandle: SavedStateHandle + + public abstract val instance: NavigationKey.Instance + public val key: T get() = instance.key + + @Deprecated("Use instance") + public val instruction: NavigationKey.Instance get() = instance + + public abstract fun execute( + operation: NavigationOperation, + ) +} + +public fun NavigationHandle<*>.requestClose() { + NavigationHandleConfiguration.onCloseRequested(this) +} + +public fun NavigationHandle<*>.close() { + execute(NavigationOperation.Close(instance)) +} + +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") +} + +public fun NavigationHandle>.complete(result: R) { + execute(NavigationOperation.Complete(instance, result)) +} + +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") +} + +public fun NavigationHandle>.completeFrom(key: NavigationKey.WithResult) { + execute(NavigationOperation.CompleteFrom(instance, key.asInstance())) +} + +public fun NavigationHandle>.completeFrom(key: NavigationKey.WithMetadata>) { + execute(NavigationOperation.CompleteFrom(instance, key.asInstance())) +} + +public fun NavigationHandle<*>.open(key: NavigationKey) { + execute(NavigationOperation.Open(key.asInstance())) +} + +public fun NavigationHandle<*>.open(key: NavigationKey.WithMetadata<*>) { + execute(NavigationOperation.Open(key.asInstance())) +} + +public fun NavigationHandle<*>.closeAndReplaceWith(key: NavigationKey) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.Open(key.asInstance()), + ) + ) +} + +public fun NavigationHandle<*>.closeAndReplaceWith(key: NavigationKey.WithMetadata<*>) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.Open(key.asInstance()), + ) + ) +} + +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") +} + +public fun NavigationHandle>.closeAndCompleteFrom( + key: NavigationKey.WithResult, +) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.CompleteFrom(instance, key.asInstance()) + ) + ) +} + +public fun NavigationHandle>.closeAndCompleteFrom( + key: NavigationKey.WithMetadata>, +) { + execute( + NavigationOperation.AggregateOperation( + NavigationOperation.Close(instance), + NavigationOperation.CompleteFrom(instance, key.asInstance()) + ) + ) +} \ No newline at end of file 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..e7b07041a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.ui.kt @@ -0,0 +1,47 @@ +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 + +@JvmName("untypedNavigationHandle") +@Composable +public fun navigationHandle(): NavigationHandle { + return navigationHandle() +} + +@Composable +public inline fun navigationHandle(): NavigationHandle { + return navigationHandle(T::class) +} + +@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 +} + +@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..545bb22da --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandle.viewmodel.kt @@ -0,0 +1,62 @@ +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 + +public inline fun ViewModel.navigationHandle( + noinline config: (NavigationHandleConfiguration.() -> Unit)? = null, +): ReadOnlyProperty> { + return navigationHandle( + K::class, + config, + ) +} + +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 } +} + +public inline fun ViewModel.navigationHandle(): ReadOnlyProperty> { + return navigationHandle( + config = null, + ) +} +public fun ViewModel.navigationHandle( + keyType: KClass, +): ReadOnlyProperty> { + return navigationHandle( + keyType = keyType, + config = null, + ) +} + +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..4cdeec71b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationHandleConfiguration.kt @@ -0,0 +1,67 @@ +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 +import dev.enro.platform.EnroLog + +private typealias OnCloseCallback = NavigationHandle.() -> Unit + +public class NavigationHandleConfiguration( + private val navigation: NavigationHandle, +) { + private val closeables: MutableList = mutableListOf() + + // TODO: Add documentation here + 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> + if (callbacks.isEmpty()) { + navigation.execute(NavigationOperation.Close(navigation.instance)) + } else { + val callback = callbacks.last() + if (callbacks.size > 1) { + EnroLog.warn("Multiple onCloseRequested callbacks have been registered for NavigationHandle with key ${navigation.key}, the last registered callback will be used.") + } + callback.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..0c8421157 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/NavigationOperation.kt @@ -0,0 +1,157 @@ +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) + return AggregateOperation( + transition.targetBackstack.map { Open(it) } + transition.closed.map { Close(it) }, + ) + } + } +} 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..06a5e8930 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/context/NavigationContext.getNavigationHandle.kt @@ -0,0 +1,16 @@ +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 + +public inline fun AnyNavigationContext.getNavigationHandle(): NavigationHandle { + return (this as ViewModelStoreOwner).getNavigationHandle() +} + +@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..dba3a6068 --- /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..f200148e9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/internalCreateEnroController.kt @@ -0,0 +1,16 @@ +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( + builder: NavigationModule.BuilderScope.() -> Unit = {}, +) : EnroController { + val module = createNavigationModule(builder) + return EnroController().apply { + 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..a70c29dc6 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/PathRepository.kt @@ -0,0 +1,31 @@ +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<*>? { + val matching = bindings.filter { it.matches(path) } + if (matching.isEmpty()) return null + require(matching.size == 1) { + "Multiple path bindings found for path: $path" + } + return matching.single() + } +} \ 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..33d892edb --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/controller/repository/SerializerRepository.kt @@ -0,0 +1,64 @@ +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 + classDiscriminatorMode = kotlinx.serialization.json.ClassDiscriminatorMode.ALL_JSON_OBJECTS + 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..cfc832ba9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/NavigationInterceptor.kt @@ -0,0 +1,159 @@ +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.platform.EnroLog +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 } + while (toProcess.isNotEmpty()) { + 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) + } + intercepted is NavigationOperation.RootOperation -> { + // Different operation returned, add to processing queue + toProcess.add(0, intercepted) + } + intercepted is NavigationOperation.AggregateOperation -> { + // Different operation returned, add to processing queue + toProcess.addAll(0, intercepted.operations) + } + } + } + + val openedIds = mutableSetOf() + val closedIds = mutableSetOf() + val completedResultIds = mutableSetOf() + val filteredResult = result.mapNotNull { + when (it) { + is NavigationOperation.Close -> { + closedIds.add(it.instance.id) + if (!backstackById.containsKey(it.instance.id)) { + EnroLog.warn( + "Attempted to close a NavigationKey.Instance that was not on the backstack: ${it.instance}." + ) + return@mapNotNull null + } + } + is NavigationOperation.Complete -> { + closedIds.add(it.instance.id) + completedResultIds.add(it.instance.metadata.get(NavigationResultChannel.ResultIdKey)) + if (!backstackById.containsKey(it.instance.id)) { + EnroLog.warn( + "Attempted to complete a NavigationKey.Instance that was not on the backstack: ${it.instance}." + ) + return@mapNotNull null + } + } + 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..f8a9c3ed1 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/NavigationInterceptorBuilder.kt @@ -0,0 +1,168 @@ +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, + ).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, + ).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, + ).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..b85366af2 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyClosedScope.kt @@ -0,0 +1,36 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation + +/** + * 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 key: K get() = instance.key + + /** + * 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) +} \ No newline at end of file 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..eed214393 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyCompletedScope.kt @@ -0,0 +1,61 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +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 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) + +} \ No newline at end of file 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..33667e4a1 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/interceptor/builder/OnNavigationKeyOpenedScope.kt @@ -0,0 +1,38 @@ +package dev.enro.interceptor.builder + +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +import dev.enro.asInstance + +/** + * Scope for handling when a navigation key is opened. + */ +public class OnNavigationKeyOpenedScope( + public val instance: NavigationKey.Instance, +) { + public val key: T get() = instance.key + + /** + * 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)) +} \ No newline at end of file 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..f33a5c804 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/path/NavigationContext.getNavigationKeyFromPath.kt @@ -0,0 +1,15 @@ +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? { + val parsedPath = ParsedPath.fromString(path) + @Suppress("UNCHECKED_CAST") + val binding = controller.paths.getPathBinding(parsedPath) as? NavigationPathBinding + return binding?.fromPath(parsedPath) +} \ No newline at end of file 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..30da79742 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlow.kt @@ -0,0 +1,162 @@ +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 + println("\nSTEPS") + steps.forEach { + println(it) + } + println("-----") + 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..4059002fd --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationFlowReference.kt @@ -0,0 +1,45 @@ +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) +} + +@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..2a6c7bfe5 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/result/flow/NavigationHandle.navigationFlow.kt @@ -0,0 +1,10 @@ +package dev.enro.result.flow + +import dev.enro.NavigationHandle +import dev.enro.annotations.AdvancedEnroApi + +@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/LocalDestinationsToRenderInCurrentScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalDestinationsToRenderInCurrentScene.kt new file mode 100644 index 000000000..341359807 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalDestinationsToRenderInCurrentScene.kt @@ -0,0 +1,17 @@ +package dev.enro.ui + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * The destination IDs to render in the current NavigationScene, in the sense of the target of the animation for + * an [AnimatedContent] that is transitioning between different scenes. + */ +public val LocalDestinationsToRenderInCurrentScene: ProvidableCompositionLocal> = + compositionLocalOf { + throw IllegalStateException( + "Unexpected access to LocalDestinationsToRenderInCurrentScene. You should only " + + "access LocalDestinationsToRenderInCurrentScene inside a NavigationDestination passed " + + "to NavigationDisplay." + ) + } \ No newline at end of file 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..8b7077791 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationAnimatedVisibilityScope.kt @@ -0,0 +1,8 @@ +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") } 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..af7c66b3b --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContainer.kt @@ -0,0 +1,8 @@ +package dev.enro.ui + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf + +public val LocalNavigationContainer: ProvidableCompositionLocal = staticCompositionLocalOf { + error("No LocalNavigationContainer (you might be calling this from a RootContext)") +} \ 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..ffd7b9794 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationContext.kt @@ -0,0 +1,28 @@ +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 + +public object LocalNavigationContext { + private val LocalNavigationContext = compositionLocalOf { null } + + public val current: NavigationContext + @Composable get() { + val current = LocalNavigationContext.current ?: findRootNavigationContext() + return remember { 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..c0178aefa --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/LocalNavigationSharedTransitionScope.kt @@ -0,0 +1,10 @@ +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")} 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..00bbf1fcd --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationAnimations.kt @@ -0,0 +1,65 @@ +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: AnimatedContentTransitionScope.() -> ContentTransform = { + ContentTransform( + targetContentEnter = fadeIn(spring(stiffness = Spring.StiffnessMedium)) + slideInHorizontally { it / 3 }, + initialContentExit = slideOutHorizontally { -it / 4 }, + ) + }, + val popTransitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { + ContentTransform( + targetContentEnter = slideInHorizontally { -it / 4 }, + initialContentExit = fadeOut(spring(stiffness = Spring.StiffnessMedium)) + slideOutHorizontally { it / 3 }, + ) + }, + val predictivePopTransitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = popTransitionSpec, + val containerTransitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { + 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..335a3172e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationContainerState.kt @@ -0,0 +1,96 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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 + + /** Progress of the current predictive back gesture (0.0 to 1.0) */ + public var predictiveBackProgress: Float by mutableFloatStateOf(0f) + internal set + + /** Whether a predictive back gesture is currently in progress */ + public var inPredictiveBack: Boolean by mutableStateOf(false) + internal set + + /** 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("TODO BETTER DEPRECATION MESSAGE") + @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..960ccfca0 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDestination.kt @@ -0,0 +1,146 @@ +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(), + public val content: @Composable () -> Unit, +) { + public val id: String get() = instance.id + public val key: T get() = instance.key + + /** + * 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) + } + + internal fun build(): Map = builder.toMap() + } + + 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) +} 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..eb6ce42b4 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationDisplay.kt @@ -0,0 +1,693 @@ +package dev.enro.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastForEachReversed +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import dev.enro.NavigationContainer +import dev.enro.NavigationKey +import dev.enro.asBackstack +import dev.enro.platform.EnroLog +import dev.enro.requestClose +import dev.enro.ui.decorators.ProvideRemovalTrackingInfo +import dev.enro.ui.scenes.DialogSceneStrategy +import dev.enro.ui.scenes.DirectOverlaySceneStrategy +import dev.enro.ui.scenes.SinglePaneSceneStrategy +import dev.enro.ui.scenes.calculateSceneWithSinglePaneFallback +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 handles: + * - Scene management (organizing destinations into logical groups like dialogs, overlays, etc.) + * - Transition animations between destinations + * - Predictive back gesture support + * - Lifecycle and state management for destinations + * - Shared element transitions + * + * @param container The navigation container 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 transitionSpec Animation spec for forward navigation transitions + * @param popTransitionSpec Animation spec for back navigation transitions + * @param predictivePopTransitionSpec Animation spec for predictive back gesture transitions + * @param containerTransitionSpec Animation spec for transitions when the container [state] changes + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +public fun NavigationDisplay( + state: NavigationContainerState, + modifier: Modifier = Modifier, + sceneStrategy: NavigationSceneStrategy = remember { + NavigationSceneStrategy.from( + DialogSceneStrategy(), + DirectOverlaySceneStrategy(), + SinglePaneSceneStrategy(), + ) + }, + 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) + } + } + // Create and remember the state that tracks the display's internal state + val sceneState = remember { NavigationSceneState() } + + // Calculate the scene hierarchy - scenes organize destinations into logical groups + val (scene, overlayScenes) = calculateScenes( + state = state, + sceneStrategy = sceneStrategy, + ) + + // Create a unique key for the current scene and store it + val sceneKey = SceneKey( + sceneType = scene::class, + key = scene.key, + containerKey = state.container.key, + visible = scene.entries.map { it.instance }, + previouslyVisible = scene.previousEntries.map { it.instance }, + ) + sceneState.scenes[sceneKey] = scene + + // Set up predictive back gesture handling + HandlePredictiveBack( + scene = overlayScenes.lastOrNull() ?: scene, + state = state, + ) + + // Create the transition state that manages animations between scenes + val transitionState = remember { SeekableTransitionState(sceneKey) } + val transition = rememberTransition(transitionState, label = sceneKey.toString()) + + // Calculate which destinations should be rendered in each scene + val sceneToRenderableDestinationMap = calculateSceneToRenderableDestinationMap( + state = sceneState, + transition = transition, + ) + + // Determine if this is a pop (back) navigation + val lastBackstackIds = remember { mutableStateOf(listOf()) } + val currentBackstackIds = state.backstack.map { it.id } + val isPop = remember(currentBackstackIds) { + val isPop = isPop( + lastBackstackIds.value, + currentBackstackIds + ) + lastBackstackIds.value = currentBackstackIds + return@remember isPop + } + + // Calculate z-indices for proper layering during transitions + val zIndices = updateZIndices( + transition = transition, + isPop = isPop, + inPredictiveBack = state.inPredictiveBack + ) + + // Handle transition animations + TransitionAnimationEffect( + transitionState = transitionState, + transition = transition, + sceneKey = sceneKey, + state = state, + scene = overlayScenes.lastOrNull() ?: scene, + sceneStrategy = sceneStrategy, + scenes = sceneState.scenes + ) + + // Select the appropriate transition spec based on navigation type + 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) + state.inPredictiveBack -> animations.predictivePopTransitionSpec(this) + isPop -> animations.popTransitionSpec(this) + else -> animations.transitionSpec(this) + } + } + // Render the navigation content + CompositionLocalProvider( + LocalNavigationContainer provides state, + LocalNavigationContext provides state.context, + ) { + ProvideRemovalTrackingInfo { + RenderMainContent( + transition = transition, + scenes = sceneState.scenes, + sceneToRenderableDestinationMap = sceneToRenderableDestinationMap, + zIndices = zIndices, + contentTransform = contentTransform, + contentAlignment = contentAlignment, + modifier = modifier, + sizeTransform = sizeTransform + ) + CleanupSceneEffect(transition, sceneState) + UpdateSettledStateEffect(transition) { state.isSettled = it } + RenderOverlayScenes(overlayScenes) + } + } +} + +/** + * Internal state management for NavigationDisplay. + * Tracks the current state of animations, scenes, and navigation gestures. + */ +private class NavigationSceneState { + /** Map of scene keys to their corresponding NavigationScene instances */ + val scenes = mutableStateMapOf() + + /** Ordered list of scene keys, with most recently targeted scenes last */ + val mostRecentSceneKeys = mutableStateListOf() +} + +/** + * Calculates the scene hierarchy from the list of destinations. + * Scenes organize destinations into logical groups (e.g., main content, dialogs, overlays). + * + * The function recursively processes overlay scenes, building a hierarchy where: + * - The main scene contains the primary content + * - Overlay scenes (like dialogs) are rendered on top + * - Each overlay scene may itself have overlaid content + * + * @param destinations The list of all destinations to organize into scenes + * @param sceneStrategy The strategy for determining scene organization + * @param onBack Callback for handling back navigation + * @return Pair of the main scene and list of overlay scenes + */ +@Composable +private fun calculateScenes( + state: NavigationContainerState, + sceneStrategy: NavigationSceneStrategy, +): Pair> { + val destinations = state.destinations + // Start with calculating the first scene from all destinations + val allScenes = mutableListOf(sceneStrategy.calculateSceneWithSinglePaneFallback(destinations)) + var currentScene = allScenes.last() + + // Process overlay scenes recursively + // Each overlay scene may have destinations that should be overlaid on it + while (currentScene is NavigationScene.Overlay && currentScene.overlaidEntries.isNotEmpty()) { + allScenes += sceneStrategy.calculateSceneWithSinglePaneFallback(currentScene.overlaidEntries) + currentScene = allScenes.last() + } + + // The last scene is the main scene, all others are overlays + val overlayScenes = allScenes.dropLast(1).filterIsInstance() + val scene = allScenes.last() + + SceneRecompositionDebugger( + scene = scene, + overlayScenes = overlayScenes, + destinations = destinations, + ) + return scene to overlayScenes +} + +@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 + * @param destinations All destinations in the backstack + * @param visibility The navigation display state to update + * @param onBack Callback to execute when back gesture completes + */ +@Composable +private fun HandlePredictiveBack( + 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) + } + + val navState = rememberNavigationEventState(NavigationEventInfo.None) + NavigationBackHandler( + state = navState, + 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() + } + } + ) + + LaunchedEffect(navState.transitionState) { + when (val transitionState = navState.transitionState) { + is NavigationEventTransitionState.InProgress -> { + state.inPredictiveBack = true + val isProgressConsumed = state.emptyBehavior + .onPredictiveBackProgress( + backstack = scene.previousEntries.map { it.instance }.asBackstack(), + progress = transitionState.latestEvent.progress, + ) + + if (!isProgressConsumed) { + state.predictiveBackProgress = transitionState.latestEvent.progress + } + } + + is NavigationEventTransitionState.Idle -> { + state.inPredictiveBack = false + } + } + } +} + +public interface SceneTransitionData { + public val containerKey: NavigationContainer.Key + public val visible: List> + public val previouslyVisible: List> +} + +/** + * A key that uniquely identifies a scene instance. + * Combines the scene's type (KClass) with its instance key. + */ +private data class SceneKey( + val sceneType: KClass<*>, + val key: Any, + override val containerKey: NavigationContainer.Key, + override val visible: List>, + override val previouslyVisible: List>, +) : SceneTransitionData + +/** + * Calculates which destinations should be rendered in each scene. + * + * This function ensures that: + * - Each destination is only rendered in one scene + * - More recently targeted scenes get priority for rendering destinations + * - The transition target scene always renders its destinations + * + * @param state The navigation display state containing scene information + * @param transition The current transition state + * @return Map of scene keys to sets of destination IDs that should be rendered in each scene + */ +@Composable +private fun calculateSceneToRenderableDestinationMap( + state: NavigationSceneState, + transition: Transition, +): Map> { + // Update the most recent scene keys list when target changes + LaunchedEffect(transition.targetState) { + if (state.mostRecentSceneKeys.lastOrNull() != transition.targetState) { + state.mostRecentSceneKeys.remove(transition.targetState) + state.mostRecentSceneKeys.add(transition.targetState) + } + } + + return remember( + state.mostRecentSceneKeys, + state.scenes.values.map { scene -> scene.entries.map { it.instance.id } }, + transition.targetState, + ) { + buildMap { + val coveredDestinationIds = mutableSetOf() + // Process scenes from most recent to least recent + (state.mostRecentSceneKeys.filter { it != transition.targetState } + listOf(transition.targetState)) + .fastForEachReversed { sceneKey -> + val scene = state.scenes.getValue(sceneKey) + put( + sceneKey, + scene.entries + .map { it.instance.id } + .filterNot(coveredDestinationIds::contains) // Only include uncovered destinations + .toSet(), + ) + // Mark these destinations as covered + scene.entries.forEach { coveredDestinationIds.add(it.instance.id) } + } + } + } +} + +/** + * Manages z-indices for proper layering during transitions. + * + * Z-index logic: + * - Forward navigation: new content appears on top (higher z-index) + * - Back navigation: old content appears on top (lower z-index for new content) + * - No change when transitioning to the same scene + * + * @param transition The current transition state + * @param isPop Whether this is a back navigation + * @param inPredictiveBack Whether a predictive back gesture is active + * @return Map of scene keys to their z-indices + */ +@Composable +private fun updateZIndices( + transition: Transition, + isPop: Boolean, + inPredictiveBack: Boolean, +): MutableMap { + val zIndices = remember { mutableMapOf() } + val initialKey = transition.currentState + val targetKey = transition.targetState + val initialZIndex = zIndices.getOrPut(initialKey) { 0f } + + // Calculate target z-index based on navigation direction + val targetZIndex = when { + initialKey == targetKey -> initialZIndex // No change for same scene + isPop || inPredictiveBack -> initialZIndex - 1f // Lower for back navigation + else -> initialZIndex + 1f // Higher for forward navigation + } + zIndices[targetKey] = targetZIndex + return zIndices +} + +/** + * Manages transition animations between scenes. + * + * Handles two cases: + * 1. Predictive back: Creates a "peek" scene and animates based on gesture progress + * 2. Regular navigation: Animates to the target scene or handles settling animations + * + * @param transitionState The seekable transition state for controlling animations + * @param transition The current transition + * @param sceneKey The target scene key + * @param progress Current progress of predictive back gesture + * @param inPredictiveBack Whether predictive back is active + * @param scene The current scene + * @param sceneStrategy Strategy for calculating scenes + * @param scenes Map of all scenes + */ +@Composable +private fun TransitionAnimationEffect( + transitionState: SeekableTransitionState, + transition: Transition, + sceneKey: SceneKey, + state: NavigationContainerState, + scene: NavigationScene, + sceneStrategy: NavigationSceneStrategy, + scenes: MutableMap, +) { + if (state.inPredictiveBack) { + if (scene is NavigationScene.Overlay) return + // During predictive back, create a "peek" scene showing the previous destinations + var peekScene = sceneStrategy.calculateSceneWithSinglePaneFallback( + scene.previousEntries, + ) + while (peekScene is NavigationScene.Overlay) { + peekScene = sceneStrategy.calculateSceneWithSinglePaneFallback( + peekScene.previousEntries, + ) + } + val peekSceneKey = SceneKey( + sceneType = peekScene::class, + key = peekScene.key, + containerKey = state.key, + visible = peekScene.entries.map { it.instance }, + previouslyVisible = peekScene.previousEntries.map { it.instance }, + ) + scenes[peekSceneKey] = peekScene + + // Seek to the appropriate position based on gesture progress + if (transitionState.currentState != peekSceneKey) { + LaunchedEffect(state.predictiveBackProgress) { + transitionState.seekTo( + state.predictiveBackProgress, + peekSceneKey + ) + } + } + } else { + // Regular navigation - animate to target or handle settling + LaunchedEffect(sceneKey) { + if (transitionState.currentState != sceneKey) { + // Animate to the new scene + transitionState.animateTo(sceneKey) + } else { + // Already at target - animate any remaining settling + val totalDuration = transition.totalDurationNanos / 1000000 + animate( + transitionState.fraction, + 0f, + animationSpec = tween((transitionState.fraction * totalDuration).toInt()), + ) { value, _ -> + this@LaunchedEffect.launch { + if (value > 0) { + transitionState.seekTo(value) + } + if (value == 0f) { + transitionState.snapTo(sceneKey) + } + } + } + } + } + } +} + +/** + * Renders the main navigation content with animated transitions. + * + * Uses SharedTransitionLayout and AnimatedContent to: + * - Animate between different scenes + * - Support shared element transitions + * - Provide proper scoping for animations + * + * @param transition The current transition state + * @param scenes Map of all available scenes + * @param sceneToRenderableDestinationMap Map of which destinations to render in each scene + * @param zIndices Z-index values for each scene + * @param contentTransform Transform to apply during transitions + * @param contentAlignment Alignment of content + * @param modifier Modifier for the content + * @param sizeTransform Transform for size changes + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun RenderMainContent( + transition: Transition, + scenes: Map, + sceneToRenderableDestinationMap: Map>, + zIndices: Map, + contentTransform: AnimatedContentTransitionScope.() -> ContentTransform, + contentAlignment: Alignment, + modifier: Modifier, + sizeTransform: SizeTransform?, +) { + SharedTransitionLayout { + transition.AnimatedContent( + contentAlignment = contentAlignment, + modifier = modifier, + transitionSpec = { + ContentTransform( + targetContentEnter = contentTransform(this).targetContentEnter, + initialContentExit = contentTransform(this).initialContentExit, + targetContentZIndex = zIndices[transition.targetState] ?: 0f, + sizeTransform = sizeTransform, + ) + } + ) { targetSceneKey -> + val targetScene = scenes.getValue(targetSceneKey) + // Provide necessary composition locals for the scene content + CompositionLocalProvider( + LocalNavigationAnimatedVisibilityScope provides this@AnimatedContent, + LocalNavigationSharedTransitionScope provides this@SharedTransitionLayout, + LocalDestinationsToRenderInCurrentScene provides sceneToRenderableDestinationMap.getValue( + targetSceneKey + ) + ) { + targetScene.content() + } + } + } +} + +/** + * Cleans up scenes that are no longer needed after transitions complete. + * This prevents memory leaks and ensures only active scenes are retained. + * + * @param transition The current transition state + * @param state The navigation display state containing scenes + */ +@Composable +private fun CleanupSceneEffect( + transition: Transition, + state: NavigationSceneState, +) { + LaunchedEffect(transition) { + snapshotFlow { transition.isRunning } + .filter { !it } // Only proceed when transition is complete + .collect { + // Remove all scenes except the current target + state.scenes.keys.toList().forEach { key -> + if (key != transition.targetState) { + state.scenes.remove(key) + } + } + // Clean up the most recent keys list as well + state.mostRecentSceneKeys.toList().forEach { key -> + if (key != transition.targetState) { + state.mostRecentSceneKeys.remove(key) + } + } + } + } +} + +/** + * Updates the settled state based on transition progress. + * The state is settled when no transition is running (current == target). + * + * @param transition The current transition state + * @param onSettledChange Callback invoked when settled state changes + */ +@Composable +private fun UpdateSettledStateEffect( + transition: Transition, + onSettledChange: (Boolean) -> Unit, +) { + LaunchedEffect(transition.currentState, transition.targetState) { + val settled = transition.currentState == transition.targetState + onSettledChange(settled) + } +} + +/** + * 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) { + overlayScenes.fastForEachReversed { overlayScene -> + val allDestinations = overlayScene.entries.map { it.instance.id }.toSet() + SharedTransitionLayout { + AnimatedVisibility(true) { + CompositionLocalProvider( + LocalNavigationAnimatedVisibilityScope provides this@AnimatedVisibility, + LocalNavigationSharedTransitionScope provides this@SharedTransitionLayout, + LocalDestinationsToRenderInCurrentScene provides allDestinations + ) { + overlayScene.content() + } + } + } + } +} + +/** + * 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 + if (oldBackStack.firstOrNull() != newBackStack.firstOrNull()) return false // Different roots + if (newBackStack.size > oldBackStack.size) return false // Can't be pop if growing + + // Check if new backstack is a prefix of the old one + val divergingIndex = newBackStack.indices.firstOrNull { index -> + newBackStack[index] != oldBackStack[index] + } + 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..960c25dee --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationScene.kt @@ -0,0 +1,37 @@ +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 + + /** + * 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> + } +} + 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..154f15b43 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/NavigationSceneStrategy.kt @@ -0,0 +1,23 @@ +package dev.enro.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import dev.enro.NavigationKey + +@Stable +public fun interface NavigationSceneStrategy { + @Composable + public fun calculateScene( + entries: List>, + ): NavigationScene? + + public companion object { + public fun from( + vararg sceneStrategies: NavigationSceneStrategy, + ): NavigationSceneStrategy { + return NavigationSceneStrategy { entries -> + sceneStrategies.firstNotNullOfOrNull { it.calculateScene(entries) } + } + } + } +} \ No newline at end of file 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..83d736979 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationDestinationDecorator.kt @@ -0,0 +1,54 @@ +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. + * + * @param T The type of NavigationKey this decorator can handle + * @property onRemove Called when the destination is removed from the backstack + * @property decorator The composable function that wraps the destination content + */ +public class NavigationDestinationDecorator( + internal val onRemove: (key: NavigationKey.Instance) -> Unit, + internal val decorator: @Composable (destination: NavigationDestination) -> Unit, +) + +/** + * Creates a [NavigationDestinationDecorator] with the provided lifecycle callback and decorator function. + * + * @param onRemove Called when the destination is removed from the backstack + * @param decorator The composable function that wraps the destination content + */ +public fun navigationDestinationDecorator( + onRemove: (key: NavigationKey.Instance) -> Unit = {}, + decorator: @Composable (destination: NavigationDestination) -> Unit, +): NavigationDestinationDecorator = NavigationDestinationDecorator(onRemove, decorator) + +/** + * 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. + * + * @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.decorator(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..881bd817e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/NavigationSavedStateHolder.kt @@ -0,0 +1,173 @@ +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 { + savedStateRegistry?.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/movableContentDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/movableContentDecorator.kt new file mode 100644 index 000000000..d64eb3917 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/movableContentDecorator.kt @@ -0,0 +1,101 @@ +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.LocalDestinationsToRenderInCurrentScene + +/** + * 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 marked as visible in the current scene + * via [LocalDestinationsToRenderInCurrentScene]. + */ +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() + } + } + } + } + + // Only render if this destination should be visible in the current scene + if (LocalDestinationsToRenderInCurrentScene.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..aa06ac489 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationContextDecorator.kt @@ -0,0 +1,134 @@ +package dev.enro.ui.decorators + +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.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, + ) { + destination.content() + DisposableEffect(isActiveInRoot) { + if (isFirstOpen.value) { + context.controller.plugins.onOpened(navigationHandle) + } + isFirstOpen.value = false + 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..76947d9be --- /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..9eab71576 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationSavedStateDecorator.kt @@ -0,0 +1,112 @@ +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( + onRemove = { instance -> + val id = instance.id + navigationSavedStateHolder.removeState(id) + }, + decorator = { 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 + + 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..ddc532a5e --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/navigationViewModelStoreDecorator.kt @@ -0,0 +1,185 @@ +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( + onRemove = { instance -> + if (shouldRemoveStoreOwner()) { + storage.clearViewModelStoreForInstance(instance) + } + }, + decorator = { 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 { + 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() + } +} + +/** + * 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..132355634 --- /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( + * onRemove = { instance -> + * // Clean up when destination is removed + * }, + * decorator = { 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/removalTrackingDecorator.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/removalTrackingDecorator.kt new file mode 100644 index 000000000..ef80f5a9a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/decorators/removalTrackingDecorator.kt @@ -0,0 +1,127 @@ +package dev.enro.ui.decorators + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import dev.enro.NavigationKey +import dev.enro.ui.LocalNavigationContainer + +/** + * Returns a [NavigationDestinationDecorator] that tracks when destinations are removed + * from the backstack and invokes the onRemove callbacks of all decorators. + * + * This decorator should be applied last to ensure it can track all other decorators' + * onRemove callbacks and invoke them when destinations are removed. + * + * @param backstack The current navigation backstack + * @param decorators The list of decorators whose onRemove callbacks should be tracked + */ +@Composable +public fun rememberRemovalTrackingDecorator( + decorators: List>, +): NavigationDestinationDecorator = remember(decorators) { + removalTrackingDecorator(decorators) +} + +// TODO: I think it should be possible to use lifecycle state tracking to perform the same functionality that this +// decorator provides. Possibly worth looking into, as that would simplify things quite a bit +/** + * Creates a decorator that tracks destination removal and invokes onRemove callbacks. + * + * This implementation is inspired by navigation3's approach, using DisposableEffect + * to detect when destinations are removed from composition and the backstack. + */ +internal fun removalTrackingDecorator( + decorators: List>, +): NavigationDestinationDecorator { + return navigationDestinationDecorator { destination -> + val container = LocalNavigationContainer.current + val removalInfo = LocalRemovalTrackingInfo.current + val instance = destination.instance + val id = instance.id + + // Store onRemove callbacks for all decorators + val popCallbacks = remember(instance) { + mutableSetOf<(NavigationKey.Instance) -> Unit>() + } + + // Collect onRemove callbacks from all decorators + @Suppress("UNCHECKED_CAST") + decorators.distinct().forEach { decorator -> + popCallbacks.add(decorator.onRemove as (NavigationKey.Instance) -> Unit) + } + + // Track this destination's lifecycle + DisposableEffect(instance) { + // Mark as in composition + removalInfo.idsInComposition.add(id) + + // Update reference count + removalInfo.keyRefCounts[instance] = (removalInfo.keyRefCounts[instance] ?: 0) + 1 + + onDispose { + // Remove from composition tracking + val wasInComposition = removalInfo.idsInComposition.remove(id) + + // Check if this destination is still in the backstack + val stillInBackstack = container.backstack.any { it.id == id } + + // Update reference count + val currentCount = removalInfo.keyRefCounts[instance] ?: 1 + if (currentCount > 1) { + removalInfo.keyRefCounts[instance] = currentCount - 1 + } else { + removalInfo.keyRefCounts.remove(instance) + } + + // If removed from composition and not in backstack, call onRemove + if (wasInComposition && !stillInBackstack && removalInfo.keyRefCounts[instance] == null) { + // Call onRemove in reverse order (similar to navigation3) + popCallbacks.toList().reversed().forEach { callback -> + @Suppress("UNCHECKED_CAST") + callback(instance as NavigationKey.Instance) + } + } + } + } + + destination.content() + } +} + +/** + * Provides removal tracking information to the decorated destinations. + * This should be called at the NavigationDisplay level to wrap all destinations. + */ +@Composable +public fun ProvideRemovalTrackingInfo( + content: @Composable () -> Unit, +) { + val removalInfo = remember { RemovalTrackingInfo() } + CompositionLocalProvider(LocalRemovalTrackingInfo provides removalInfo) { + content() + } +} + +/** + * Internal class that tracks the state needed for removal detection. + */ +private class RemovalTrackingInfo { + /** Set of destination IDs currently in composition */ + val idsInComposition: MutableSet = mutableSetOf() + + /** Reference counts for each destination instance (handles duplicates) */ + val keyRefCounts: MutableMap, Int> = mutableMapOf() +} + +/** + * CompositionLocal that provides access to removal tracking information. + */ +private val LocalRemovalTrackingInfo = staticCompositionLocalOf { + error( + "LocalRemovalTrackingInfo not provided. Ensure ProvideRemovalTrackingInfo " + + "is called before using removalTrackingDecorator." + ) +} 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..c579cbf45 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestination.kt @@ -0,0 +1,78 @@ +package dev.enro.ui.destinations + +import dev.enro.EnroController +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.NavigationOperation +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 + +public class SyntheticDestination( + internal val block: SyntheticDestinationScope.() -> Unit, +) { + public 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 NavigationOperation.SideEffect{ + executeSynthetic( + fromContext = fromContext, + instance = operation.instance + ) + } + } + } + + public fun executeSynthetic( + fromContext: NavigationContext, + instance: NavigationKey.Instance, + ) { + 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 + synthetic.block( + SyntheticDestinationScope( + context = fromContext, + instance = instance, + ) + ) + } + + } +} + +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 +} + 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..1a8d771da --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/destinations/SyntheticDestinationScope.kt @@ -0,0 +1,40 @@ +package dev.enro.ui.destinations + +import dev.enro.NavigationContext +import dev.enro.NavigationKey +import dev.enro.context.AnyNavigationContext +import dev.enro.context.ContainerContext +import dev.enro.context.DestinationContext +import dev.enro.context.RootContext + +// TODO: Need to add functionality to do a replace/close/complete/completeFrom from the context of +// a synthetic destination scope (to allow for "forwardResult" and "setResult") type functionality +// that previously existed in Enro 2.x +public class SyntheticDestinationScope( + // context is the NavigationContext that is executing this SyntheticDestination, + // which could be a RootContext, ContainerContext or DestinationContext depending on how + // the synthetic destination was opened + public val context: NavigationContext, + public val instance: NavigationKey.Instance, +) { + public val key: K = instance.key + + // destinationContext will be the active destination closest to the context, + // meaning that if context is a DestinationContext, destinationContext will be that instance, + // if context is a ContainerContext, destinationContext will be that container's active context, + // and if the context is a RootContext, destinationContext will be the active child of the RootContext's + // active ContainerContext + 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 +} 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..c4f506ae9 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/rememberDecoratedDestinations.kt @@ -0,0 +1,70 @@ +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.NavigationKey +import dev.enro.ui.decorators.NavigationSavedStateHolder +import dev.enro.ui.decorators.decorateNavigationDestination +import dev.enro.ui.decorators.rememberLifecycleDecorator +import dev.enro.ui.decorators.rememberMovableContentDecorator +import dev.enro.ui.decorators.rememberNavigationContextDecorator +import dev.enro.ui.decorators.rememberRemovalTrackingDecorator +import dev.enro.ui.decorators.rememberSavedStateDecorator +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. + * + * @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( + 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) + + // Add removal tracking decorator last to ensure it tracks all other decorators + val decoratorsWithRemovalTracking = decorators + rememberRemovalTrackingDecorator(decorators) + val decoratedDestinations = remember { + mutableMapOf>() + } + + return 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) { + // Find the navigation binding for this instance and create the destination + val destination = controller.bindings.destinationFor(instance) + decorateNavigationDestination( + destination = destination, + decorators = decoratorsWithRemovalTracking, + ) + } + } + } +} + 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..a2e4e5e59 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DialogScene.kt @@ -0,0 +1,81 @@ +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 + +/** An [NavigationScene.Overlay] that renders an [entry] within a [Dialog]. */ +internal class DialogScene( + override val key: Any, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavigationDestination, + private 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 calculateScene( + entries: List>, + ): NavigationScene? { + val lastEntry = entries.lastOrNull() + val dialogProperties = lastEntry?.metadata?.get(DialogPropertiesKey) as? DialogProperties + + return if (dialogProperties != null) { + DialogScene( + key = lastEntry.instance.id, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + dialogProperties = dialogProperties, + ) + } else null + } + + public companion object Companion { + private const val DialogPropertiesKey = "dev.enro.ui.scenes.DialogProperties" + + /** + * Function to create a metadata map with dialog properties to mark this entry as something that + * should be displayed within a [Dialog]. + * + * @param dialogProperties properties that should be passed to the containing [Dialog]. + */ + public fun dialogMetadata( + dialogProperties: DialogProperties = DialogProperties(), + ): Pair = DialogPropertiesKey to dialogProperties + } +} + +public fun NavigationDestination.MetadataBuilder<*>.dialog( + dialogProperties: DialogProperties = DialogProperties(), +) { + add(DialogSceneStrategy.dialogMetadata(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..ae836eb39 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DirectOverlayScene.kt @@ -0,0 +1,80 @@ +package dev.enro.ui.scenes + +import androidx.compose.runtime.Composable +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 + +/** + * 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. + * + * Animations for enter/exit will need to be handled by the [NavigationDestination] itself, + * as the [DirectOverlayScene] will not apply any animations. + * + * 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 class DirectOverlayScene( + override val key: Any, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavigationDestination, +) : NavigationScene.Overlay { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + 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 DirectOverlaySceneStrategy : NavigationSceneStrategy { + @Composable + public override fun calculateScene( + entries: List>, + ): NavigationScene? { + val lastEntry = entries.lastOrNull() + + val directOverlayMetadata = lastEntry?.metadata?.get(DirectOverlayKey) as? Unit + val isDirectOverlay = directOverlayMetadata != null + + return if (isDirectOverlay) { + DirectOverlayScene( + key = lastEntry.instance.id, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + ) + } else null + } + + public companion object { + internal const val DirectOverlayKey = "dev.enro.ui.scenes.DirectOverlayKey" + + /** + * Function to create a metadata map with dialog properties to mark this entry as something that + * should be displayed within a [Dialog]. + * + * @param dialogProperties properties that should be passed to the containing [Dialog]. + */ + public fun overlay(): Pair = DirectOverlayKey to Unit + } +} + +public fun NavigationDestination.MetadataBuilder<*>.directOverlay() { + add(DirectOverlaySceneStrategy.overlay()) +} + +public fun NavigationDestination<*>.isDirectOverlay(): Boolean { + return metadata[DirectOverlaySceneStrategy.DirectOverlayKey] != null +} diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DoublePaneScene.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DoublePaneScene.kt new file mode 100644 index 000000000..567f3a49a --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/DoublePaneScene.kt @@ -0,0 +1,81 @@ +package dev.enro.ui.scenes + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import dev.enro.NavigationKey +import dev.enro.ui.LocalNavigationAnimatedVisibilityScope +import dev.enro.ui.LocalNavigationSharedTransitionScope +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy + +public class DoublePaneScene : NavigationSceneStrategy { + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + override fun calculateScene( + entries: List>, + ): NavigationScene { + return remember(entries) { + object : NavigationScene { + + override val entries: List> = + if (entries.size >= 2) entries.takeLast(2) else listOf(entries.last()) + + override val key: Any = DoublePaneScene::class to entries.map { it.instance.id } + + override val previousEntries: List> = + entries.dropLast(1) + + override val content: @Composable (() -> Unit) = { + val entries = this.entries.toList() + val width = with(LocalDensity.current) { + LocalWindowInfo.current.containerSize.toSize().width.toDp() + } + Row { + with(LocalNavigationSharedTransitionScope.current) { + if (width > 600.dp) { + // Render both destinations side by side or in some layout + if (entries.size > 1) { + Box( + modifier = Modifier.Companion + .sharedElement( + rememberSharedContentState(key = entries.first().instance.id), + animatedVisibilityScope = LocalNavigationAnimatedVisibilityScope.current, + ) + .weight(1f) + + ) { entries.first().content() } + } + Box( + modifier = Modifier.Companion + .sharedElement( + rememberSharedContentState(key = entries.last().instance.id), + animatedVisibilityScope = LocalNavigationAnimatedVisibilityScope.current, + ) + .weight(1f) + ) { entries.last().content() } + } else { + Box( + modifier = Modifier.Companion + .sharedElement( + rememberSharedContentState(key = entries.last().instance.id), + animatedVisibilityScope = LocalNavigationAnimatedVisibilityScope.current, + ) + .weight(1f) + ) { entries.last().content() } + } + } + } + } + } + } + } +} \ No newline at end of file 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..cc3e73588 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/NavigationSceneStrategy.calculateSceneWithSinglePaneFallback.kt @@ -0,0 +1,12 @@ +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 + +@Composable +internal fun NavigationSceneStrategy.calculateSceneWithSinglePaneFallback( + entries: List>, +): NavigationScene = calculateScene(entries) ?: SinglePaneSceneStrategy().calculateScene(entries) diff --git a/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SceneTransitionData.kt b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SceneTransitionData.kt new file mode 100644 index 000000000..6679acbeb --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SceneTransitionData.kt @@ -0,0 +1,50 @@ +package dev.enro.ui.scenes + +import dev.enro.NavigationContainer +import dev.enro.NavigationKey + +/** + * Provides transition data for navigation animations in a NavigationDisplay. + * + * This interface is used as the transition data type in AnimatedContentTransitionScope when defining + * navigation animations. It allows animation definitions to access information about the current + * navigation state and make decisions based on what content is visible. + * + * When defining animations in NavigationAnimations, you can access this data through the + * AnimatedContentTransitionScope's initialState and targetState properties. This enables creating + * context-aware animations based on the navigation state. + * + * Example usage: + * ``` + * transitionSpec = { + * // Different animation when content first becomes visible + * if (initialState.visible.isEmpty() && targetState.visible.isNotEmpty()) { + * // First appearance animation + * ContentTransform( + * targetContentEnter = fadeIn() + scaleIn(), + * initialContentExit = fadeOut() + * ) + * } else { + * // Regular navigation animation + * ContentTransform( + * targetContentEnter = slideInHorizontally { it }, + * initialContentExit = slideOutHorizontally { -it } + * ) + * } + * } + * ``` + */ +public interface SceneTransitionData { + /** + * The key of the navigation container that this scene belongs to. + * This can be used to determine if a transition is happening between different containers. + */ + public val containerKey: NavigationContainer.Key + + /** + * List of navigation key instances that are currently visible in this scene. + * This can be used to create different animations based on what content is visible, + * such as detecting when a scene is empty (first destination) or has multiple items. + */ + public val visible: List> +} \ No newline at end of file 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..48916f126 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/ui/scenes/SinglePaneScene.kt @@ -0,0 +1,30 @@ +package dev.enro.ui.scenes + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.enro.NavigationKey +import dev.enro.ui.NavigationDestination +import dev.enro.ui.NavigationScene +import dev.enro.ui.NavigationSceneStrategy + +public class SinglePaneSceneStrategy : NavigationSceneStrategy { + @Composable + override fun calculateScene( + entries: List>, + ): NavigationScene { + return remember(entries) { + object : NavigationScene { + override val entries: List> = entries.takeLast(1) + override val key: Any = SinglePaneSceneStrategy::class to entries.map { it.instance.id } + override val previousEntries: List> = entries.dropLast(1) + override val content: @Composable (() -> Unit) = { + val entries = this.entries + if (entries.isNotEmpty()) { + entries.single().content() + } + } + } + } + } +} + 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..24b2e6031 --- /dev/null +++ b/enro-runtime/src/commonMain/kotlin/dev/enro/viewmodel/ViewModelStoreOwner.getNavigationHandle.kt @@ -0,0 +1,22 @@ +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 + +public inline fun ViewModelStoreOwner.getNavigationHandle(): NavigationHandle { + return getNavigationHandle(K::class) +} + +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/NavigationContainerTests.kt b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationContainerTests.kt new file mode 100644 index 000000000..0b8a3d00c --- /dev/null +++ b/enro-runtime/src/commonTest/kotlin/dev/enro/NavigationContainerTests.kt @@ -0,0 +1,729 @@ +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.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.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 `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 `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/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/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/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/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/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..29e781000 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/handle/RootNavigationHandle.wasmJs.kt @@ -0,0 +1,12 @@ +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 { + TODO("Not yet implemented") +} \ No newline at end of file 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..d63196a78 --- /dev/null +++ b/enro-runtime/src/wasmJsMain/kotlin/dev/enro/ui/EnroBrowserContent.kt @@ -0,0 +1,123 @@ +package dev.enro.ui + +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.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() = CanvasBasedWindow { + * EnroBrowserContent { + * // Your app content here + * MyNavigationContainer() + * } + * } + * ``` + * + * @param content The composable content of your application + */ +@Composable +public fun EnroBrowserContent( + 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, + ) { + 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/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/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-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..128936aff --- /dev/null +++ b/enro-test/src/commonMain/kotlin/dev/enro/test/EnroTest.kt @@ -0,0 +1,64 @@ +@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 + + private val application: Any? + get() { + runCatching { + return TODO("Application install support android")//ApplicationProvider.getApplicationContext() + } + return null + } + + // TODO: Would be nice to add functionality to temporarily install a NavigationModule for a particular test + fun installNavigationController() { + if (navigationController != null) { + uninstallNavigationController() + } + + // Check if there's already an installed controller + navigationController = EnroController.instance + if (navigationController != null) { + wasInstalled = true + return + } + + // Create a new controller for testing + navigationController = EnroController().apply { + install(application) + } + 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/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/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/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/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..2a26535b2 --- /dev/null +++ b/enro/build.gradle.kts @@ -0,0 +1,86 @@ +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") +} + +android { + lint { + textReport = true + } + testOptions { + animationsDisabled = true + } + packaging { + resources.excludes.add("META-INF/*") + } +} + +tasks.withType() { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xfriend-paths=../enro-core/src/main") + } +} + +kotlin { + 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 { + + } + androidUnitTest.dependencies { + implementation(libs.testing.junit) + implementation(libs.testing.androidx.junit) + implementation(libs.testing.androidx.runner) + implementation(libs.testing.robolectric) + implementation("dev.enro:enro-test:${project.enroVersionName}") + } + androidInstrumentedTest.dependencies { + implementation("dev.enro:enro-test:${project.enroVersionName}") + + implementation(libs.testing.junit) + + implementation(libs.kotlin.reflect) + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.fragment) + implementation(libs.androidx.activity) + implementation(libs.androidx.recyclerview) + + implementation(libs.testing.androidx.fragment) + implementation(libs.testing.androidx.junit) + implementation(libs.testing.androidx.espresso) + implementation(libs.testing.androidx.espressoRecyclerView) + implementation(libs.testing.androidx.espressoIntents) + implementation(libs.testing.androidx.runner) + + implementation(libs.testing.androidx.compose) + implementation(libs.compose.materialIcons) + + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + + implementation(libs.leakcanary) + implementation(libs.testing.leakcanary.instrumentation) + } + } +} + +// 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 @@ - - - - - - - -