diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000..454b8427 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..a4ca5e02 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Bash(git show:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git status)", + "Bash(git blame:*)", + "Bash(sed -n:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(cat:*)", + "Bash(wc:*)", + "Bash(xxd:*)", + "Bash(file:*)", + "Bash(ls:*)", + "Bash(rg:*)", + "Bash(grep:*)", + "Bash(jq:*)", + "Bash(diff:*)", + "Bash(pwd)" + ], + "deny": [ + "Read(./local.properties)", + "Read(**/local.properties)" + ] + } +} \ No newline at end of file diff --git a/.claude/skills/task-workflow/SKILL.md b/.claude/skills/task-workflow/SKILL.md new file mode 100644 index 00000000..5b20520b --- /dev/null +++ b/.claude/skills/task-workflow/SKILL.md @@ -0,0 +1,210 @@ +--- +name: task-workflow +description: Spec-driven workflow for non-trivial work. Scaffolds a tasks// folder with overview/analysis/spec/plan/log docs; completion produces an ADR under adr/. Use when the user asks for architectural analysis, multi-step refactor planning, investigations that span sessions, or any work that would benefit from a durable design record. Skip for trivial fixes, typos, or obviously-specified single-file edits — just do those directly. +--- + +# Task workflow + +Spec-driven development for non-trivial work. Every task is a self-contained folder holding the context, analysis, design, plan, and rationale for one piece of work. + +## When to use this workflow + +**Use it when:** +- The work involves a design decision that will matter in 3 months (why-was-this-done). +- The work spans multiple sessions or agents. +- The user is exploring options, not requesting a pre-specified implementation. +- There's real risk of wasted effort without alignment on approach first. + +**Skip it when:** +- The request is a single-file edit with a clear desired outcome. +- The fix is obvious once the bug is located. +- The user explicitly asks to just do the work. + +When in doubt, propose the workflow to the user and let them decide — "this looks like a multi-step refactor, want me to scaffold a task folder for it?" + +## Folder layout + +``` +tasks// + 00-overview.md # TL;DR: problem, target, status, index + 01-analysis.md # current state — facts only, no decisions + 02-spec.md # target design with decisions baked in + 03-plan.md # ordered implementation steps, checklists, rollback + 04-log.md # decision rationale — options, why, consequence per decision +``` + +Numeric prefixes force readable sort order in directory listings. + +**Naming:** kebab-case topic, e.g. `navmodel-encapsulation`, `screenmodel-threading-fix`. Keep it short and descriptive — it's the primary identifier. + +## What goes in each file + +### 00-overview.md + +- **Status** line (see lifecycle below). +- Scope — which files/modules this touches. +- Problem in one sentence. +- Target in one sentence. +- Index of other files in the folder. +- **Follow-ups / next steps** — appended to as deferred items surface during the task. Anything postponed, split into a follow-up task, or noticed-but-out-of-scope goes here. Each entry: short description + pointer (e.g. `04-log.md#Qn`, `03-plan.md` strikeout, or "noticed during Phase 3"). This is the canonical list a fresh agent reads when picking the work back up, and the source the ADR's `Follow-ups` field is distilled from. +- Post-completion note — ADR filename and commit decision. + +Every task has this file, even if it's the only one. + +### 01-analysis.md + +Current state, facts only. No judgments about what *should* happen. No options. Things that belong here: + +- Types, interfaces, call graph relevant to the task. +- Current problems / smells — described as observations, not prescriptions. +- Why the current code is shaped the way it is (if discernible — helps avoid undoing intentional choices). +- Call-site map for anything being removed/changed. +- Public API surface impact preview. + +This doc should stand alone as a reference even if the task is abandoned. + +### 02-spec.md + +Target design, decisions baked in. Written as if the design is settled (because it is — this file is updated when decisions change, not as an open debate). Things that belong here: + +- Target types with code sketches. +- Key behaviors and invariants. +- Threading/concurrency notes. +- Public API impact. +- Explicit non-goals — what this task does *not* do. + +If there are open questions, they go in `04-log.md` as pending decisions, not here. + +### 03-plan.md + +Ordered steps. Phased so the tree compiles after each phase where possible. Things that belong here: + +- Phase-by-phase checklist. +- Known call sites that need updating (pinned with file:line). +- Rollback strategy if a phase can't land. +- Verification steps (tests, manual checks). +- Out-of-scope items explicitly called out. + +A fresh agent should be able to implement from this doc alone (with the spec as reference). + +**Checklist conventions** — keep the plan in sync with reality as work lands: + +- `- [ ]` — todo (default). +- `- [x]` — completed. Tick as soon as the step lands; don't batch at the end. +- `- [-]` — cancelled or superseded. Append an inline reason: `- [-] Step — superseded by phase X` / `- [-] Step — deferred to follow-up task Y`. Use this instead of deleting the line so the audit trail survives. If the cancellation reflects a design change (not just sequencing), also add a `04-log.md` entry capturing *why*. + +Note: `- [-]` is not standard CommonMark/GFM (it's an Obsidian/Logseq convention) — it renders as plain text on GitHub. Acceptable here because plan docs are read mostly by agents and locally in IDEs, not on GitHub-rendered pages. + +### 04-log.md + +Decision rationale. One entry per decision, structured: + +``` +## Q + +**Options:** A/B/C with short descriptions. +**Decision:** . +**Why:** . +**Consequence:** . +``` + +Both accepted and rejected decisions live here. Captures *why*, not *what* — the what is in the spec. + +## Status lifecycle + +`00-overview.md` carries a **Status** line: + +- `exploring` — problem scoped, no target yet. +- `design in progress` — analysis and spec being written; decisions open. +- `design accepted` — spec and plan locked; implementation hasn't started. +- `implementation in progress` — code changes underway. +- `complete` — code merged; ADR written or pending. +- `abandoned` — task dropped; reason captured in `04-log.md` or an ADR. + +Update the status line when it changes. Avoid leaving stale statuses — a fresh agent reads it first. + +## Commit policy + +`tasks/` is **gitignored by default** (see root `.gitignore`). Working docs are local scratch. To commit a specific task, whitelist its subfolder: + +``` +# in .gitignore +tasks/ +!tasks/.gitkeep +!tasks// +``` + +Default assumption: tasks are local. Agents working on a task need the user to point them to the folder (by path, opening in IDE, or referencing by name). + +## Completion → ADR + +### Pre-completion review + +Before flipping status to `complete` and writing the ADR, run this checklist: + +- Every `03-plan.md` item is `[x]` (done) or `[-]` (cancelled with reason). No stale `[ ]` items — if something is genuinely pending but not blocking, move it to `00-overview.md`'s Follow-ups section first. +- Every `[-]` line has an inline reason (`— superseded by X`, `— deferred to follow-up Y`, etc.). +- Every "deferred / postponed / out-of-scope-but-noticed" item that surfaced during implementation is captured in `00-overview.md`'s **Follow-ups / next steps** section, not just buried inline in the log or as a strikeout. +- Status line in `00-overview.md` accurately reflects current state. +- Verification steps (Phase 6 / equivalent) actually ran — not just listed. + +If the task was abandoned mid-flight, the same review applies: cancelled items marked `[-]`, follow-ups captured (so resuming is possible), status set to `abandoned`. + +### Writing the ADR + +When a task reaches `complete`, distill the outcome into an ADR under `adr/`: + +``` +adr/NNNN-.md +``` + +Numbering is zero-padded, monotonically increasing. Grep existing ADRs for the next number. + +**ADR format** (short — one page): + +```markdown +# . + +## Context +<what problem / why this was decided> + +## Decision +<what was decided, concisely> + +## Consequences +<implications — positive and negative — going forward> + +## Follow-ups +<deferred work, split-out tasks, known limitations to revisit. Distilled from +00-overview.md's Follow-ups section. Omit the section entirely if there are none.> +``` + +ADRs are **always committed**. They are the durable record of why the codebase looks the way it does. Task folders are scratch; ADRs are canon. + +### After the ADR is written + +Decide on the task folder: + +- **Delete** — ADR captures the outcome; working docs were scratch. Most common. +- **Keep and commit** — analysis or plan is valuable reference material beyond the ADR summary. Whitelist in `.gitignore` and commit. +- **Keep local, uncommitted** — rare; only if the task might resume. + +### Abandoned tasks + +- If no ADR is warranted (e.g. the direction was rejected without teaching anything new), just delete the folder. +- If the reasoning for abandoning is worth preserving, write a short ADR describing why the direction was rejected. + +## Agent access note + +`tasks/` being gitignored affects git only. Agents have full filesystem read/write access via their standard tools. Gitignore is about "don't push this"; it is not a permission boundary. + +## Typical flow + +1. User describes a non-trivial piece of work. +2. Agent proposes the task workflow ("this looks like X, want to scaffold a task?"). +3. User confirms → create `tasks/<name>/00-overview.md` with status `exploring` or `design in progress`. +4. Agent populates `01-analysis.md` from code reading. +5. Agent and user converge on design through discussion; `02-spec.md` and `04-log.md` get written together (one captures the settled design, the other captures the why). +6. `03-plan.md` lists phased steps. +7. User agrees → status → `implementation in progress` → code. Tick `03-plan.md` items as each lands (`[x]`); mark cancelled/superseded items `[-]` with a reason. Capture deferred / postponed / out-of-scope-but-noticed items in `00-overview.md`'s Follow-ups section. Update the status line in `00-overview.md` when phases shift. +8. Code merged → run pre-completion review → status → `complete` → write ADR (including `Follow-ups` if any) → delete/commit task folder. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..dd1d3a22 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +name: Publish +run-name: Publish ${{ inputs.ref }} to Sonatype staging + +# Manual-only release workflow. +# +# What it does: +# 1. Builds the release AAR + sources + javadoc. +# 2. Signs artifacts with GPG. +# 3. Uploads to a Sonatype staging repository and closes it for validation. +# +# What it does NOT do: +# - It does not auto-release to Maven Central. After the workflow succeeds, +# open https://central.sonatype.com/publishing, review the deployment, +# and click "Publish" (or "Drop" to discard). Once published, the version +# is immutable. +# +# Security: +# - Secrets are scoped to the `maven-central` environment, which must be +# configured in repo settings with required reviewers — the job will pause +# for human approval before any secret is exposed to the runner. +# - Action references use version tags for consistency with other workflows +# in this repo. For stronger supply-chain hardening, pin all `uses:` lines +# to a commit SHA and enable Dependabot for actions. +# +# See PUBLISHING.md for setup instructions (required secrets and how to +# generate them). + +on: + workflow_dispatch: + inputs: + ref: + description: "Git ref to publish (tag, branch, or commit SHA)" + required: true + default: "dev" + +permissions: + contents: read + +jobs: + publish: + name: Publish to Sonatype staging + runs-on: ubuntu-latest + environment: maven-central + timeout-minutes: 30 + + steps: + - name: Checkout ${{ inputs.ref }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Decode signing key + env: + SIGNING_KEY_BASE64: ${{ secrets.SIGNING_KEY_BASE64 }} + run: | + if [ -z "${SIGNING_KEY_BASE64:-}" ]; then + echo "::error::SIGNING_KEY_BASE64 secret is not set" + exit 1 + fi + mkdir -p "$RUNNER_TEMP/signing" + printf '%s' "$SIGNING_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/secring.gpg" + chmod 600 "$RUNNER_TEMP/signing/secring.gpg" + echo "SIGNING_SECRET_KEY_RING_FILE=$RUNNER_TEMP/signing/secring.gpg" >> "$GITHUB_ENV" + + - name: Publish to staging and close + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + run: | + ./gradlew --no-daemon clean \ + modo-compose:bundleReleaseAar \ + publishAllPublicationsToSonatypeRepository \ + closeSonatypeStagingRepository + + - name: Wipe signing key + if: always() + run: rm -f "$RUNNER_TEMP/signing/secring.gpg" + + - name: Summary + if: success() + run: | + { + echo "### Staged for Maven Central" + echo + echo "Artifacts have been uploaded to a Sonatype staging repository and validated." + echo + echo "**Next:** open https://central.sonatype.com/publishing, review the deployment," + echo "and click **Publish** to release — or **Drop** to discard." + echo + echo "Once published, the version is immutable." + } >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 5d238a15..bc3c083e 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -4,31 +4,6 @@ <option name="GENERATE_FINAL_PARAMETERS" value="true" /> <option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" /> <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" /> - <option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND"> - <value /> - </option> - <option name="IMPORT_LAYOUT_TABLE"> - <value> - <package name="android" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="com" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="junit" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="net" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="org" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="java" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="javax" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="" withSubpackages="true" static="false" /> - <emptyLine /> - <package name="" withSubpackages="true" static="true" /> - <emptyLine /> - </value> - </option> <option name="RIGHT_MARGIN" value="150" /> <AndroidXmlCodeStyleSettings> <option name="LAYOUT_SETTINGS"> diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..59afeec5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Modo — Agent Orientation + +State-based navigation library for Jetpack Compose. UDF architecture: navigation is a tree of `Screen`s and `ContainerScreen`s driven by +`NavigationState` and updated via `dispatch(Action)` on a `NavigationContainer`. + +IMPORTANT: When applicable, prefer using android-studio-index MCP tools for code navigation and refactoring. + +## Modules + +- `modo-compose/` — the library. Core abstractions (`Screen`, `ContainerScreen`, `NavModel`, `NavigationState`, `NavigationContainer`), Compose + integration (`ComposeRenderer`, `SaveableContent`), Android integration (`ModoScreenAndroidAdapter`, lifecycle, saved state), built-in container + types (`StackScreen`, `MultiScreen`, `DialogScreen`), and `ScreenModel` infrastructure. +- `sample/` — demo app exercising library features. +- `workshop-app/` — tutorial/workshop codebase used to teach the library. +- `build-logic/` — convention plugins for the Gradle build. +- `Writerside/` — user-facing documentation site (published to GitHub Pages). + +## Build & test + +``` +./gradlew build # full build, all modules +./gradlew :modo-compose:test # library unit tests +./gradlew :modo-compose:testDebugUnitTest +./gradlew :sample:installDebug # run sample app on a connected device +``` + +Check `config/` for shared gradle/lint config and `gradle.properties` for JVM/Compose settings. Kotlin code style is enforced; match existing +formatting in the file you're editing. + +## Code conventions + +- All navigation-facing types live under `com.github.terrakok.modo`. +- `NavigationState` implementations must be `Parcelable` and return every held `Screen` from `getChildScreens()` — this drives cleanup and lifecycle. +- Prefer editing existing files over creating new ones. Only add new files when an abstraction genuinely belongs in its own unit. +- Breaking API changes on public types (`NavigationContainer`, `Screen`, `NavModel`, etc.) require a deliberate decision — surface in a task doc (see + *Non-trivial work* below) before implementing. + +## Non-trivial work + +For multi-step tasks (refactors, architectural investigations, work likely to span sessions), we use a task-folder workflow — see the **task-workflow +** skill (`.agents/skills/task-workflow/`). Invoke it via `/task-workflow` (if supported by your agent), or follow the protocol in the skill doc. +Completed tasks produce an ADR under `adr/`. + +For trivial fixes, typos, and single-file edits, just do the work — no scaffolding needed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/PUBLISHING.md b/PUBLISHING.md index d9dadeea..f142336a 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -2,7 +2,48 @@ This guide is for library maintainers who need to publish new versions of Modo to Maven Central. -## Prerequisites +The recommended path is **CI-based publishing** via the [`Publish` GitHub Actions workflow](.github/workflows/release.yml) — secrets stay in GitHub, the run is gated behind a manually-approved environment, and the laptop is not involved. Local publishing remains supported as a fallback (see *Option 1 / Option 2* below). + +## CI-based publishing (recommended) + +### One-time repository setup + +1. **Create the `maven-central` environment** (Settings → Environments → New environment). + - Add yourself (and any co-maintainers) as **required reviewers**. The publish job will pause until a reviewer approves, so secrets are never exposed to the runner without a human in the loop. + - Optionally restrict the environment to specific branches/tags. + +2. **Add the following secrets to the `maven-central` environment** (Settings → Environments → maven-central → Add secret): + + | Secret | Where to get it | + |---|---| + | `SONATYPE_USERNAME` | Central Portal → Account → Generate User Token → name | + | `SONATYPE_PASSWORD` | Central Portal → Account → Generate User Token → token value | + | `SIGNING_KEY_ID` | Last 8 hex chars of your GPG key fingerprint (`gpg --list-keys --keyid-format=short`) | + | `SIGNING_PASSWORD` | Passphrase for the GPG key | + | `SIGNING_KEY_BASE64` | Base64 of your `secring.gpg` file: `gpg --export-secret-keys <key-id> \| base64` (single line; the workflow handles both wrapped and unwrapped input) | + + Treat all five as production credentials. Rotate the Sonatype token every 6-12 months by regenerating it in Central Portal and updating the secret. + +3. **Bump the version** in `gradle/libs.versions.toml` and merge to the target branch (usually `dev` or a release branch). + +### Running a release + +1. Go to **Actions → Publish → Run workflow**. +2. Pick the ref to publish (tag, branch, or commit SHA). +3. The job will pause for environment approval — approve it. +4. When the job finishes, open <https://central.sonatype.com/publishing>: + - Find the deployment in **VALIDATED** state. + - Review the artifacts (AAR, `-sources.jar`, `-javadoc.jar`, POM, and `.asc` signatures for each). + - Click **Publish** to release to Maven Central, or **Drop** to discard. +5. After publishing, tag the commit (`git tag v<x.y.z>` + `git push --tags`) and draft a GitHub Release using the matching `changelogs/<x.y.z>.md`. + +### Supply-chain hardening (optional but recommended) + +- Pin every `uses:` line in the workflow to a commit SHA (current style is version tags, matched to existing workflows in this repo). +- Enable Dependabot for GitHub Actions so SHA pins stay current. +- Keep 2FA on the GitHub account. + +## Local publishing (fallback) ### 1. Credentials Setup @@ -28,9 +69,9 @@ Update the version in `gradle/libs.versions.toml`: modo = "x.y.z" # Update this ``` -## Publishing Workflows +### Publishing commands -### Option 1: Manual Release (Recommended) +#### Option 1: Manual Release (Recommended for local) This workflow publishes to a staging repository and validates artifacts, but requires manual approval before releasing to Maven Central. @@ -58,7 +99,7 @@ This workflow publishes to a staging repository and validates artifacts, but req - ✅ Safer for production releases - ⚠️ Remember: Once published, versions are **immutable** -### Option 2: Automatic Release +#### Option 2: Automatic Release This workflow automatically publishes to Maven Central after validation, with no manual review step. @@ -137,5 +178,6 @@ This project uses: - **`maven-publish` plugin**: Creates and signs artifacts (configured in `PublishingPlugin.kt`) - **`gradle-nexus/publish-plugin`**: Manages Nexus staging workflow (configured in root `build.gradle.kts`) - **OSSRH Staging API compatibility endpoint**: Bridges old Gradle plugins with new Central Portal +- **`.github/workflows/release.yml`**: Manual `workflow_dispatch` job that runs the staging publish under a gated `maven-central` environment; downstream publication to Central is still a manual step in the Sonatype UI. The migration from OSSRH to Central Portal is complete, using the compatibility endpoint to maintain existing workflow. diff --git a/README.md b/README.md index a758569e..ee056ee1 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,20 @@ Each integration of Modo is a * Each node is a <code>Screen</code> or <code>ContainerScreen</code>. * Leaf nodes are <code>Screen</code>s. * Inner nodes are <code>ContainerScreen</code>s. They can contain other <code>Screen</code>s or <code>ContainerScreen</code>s in their <code> - navigationState</code>. + NavigationState</code>. * The root node is a <code>RootScreen</code>. You can have multiple roots in your app. See <a href="https://ikarenkov.github.io/Modo/how-to-integrate-modo-to-your-app.html">How to integrate Modo</a> for details. ## State Defines UI * `NavigationState` defines the UI: - * The initial state is defined in the constructor of `ContainerScreen` by `navModel: NavModel<State, Action>`. - * To update the state, use `dispatch(action: Action)` on `NavigationContainer`, or use the built-in extension functions + * The initial state is defined in the constructor of `ContainerScreen` by `navModel: NavModel<State>`. + * To update the state, dispatch a lambda that calculates the new state from the old one, or use the built-in extension functions for [StackScreen](modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt) and [MultiScreen](modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt). * There are `Screen` and `ContainerScreen`: * `ContainerScreen` can contain and render child screens. * There are some built-in implementations of `ContainerScreen` like `StackScreen` and `MultiScreen`. -* You can easily create custom `Action` by extending `Action` or `ReducerAction`. # For Maintainers diff --git a/Writerside/codeSnippets/SampleAction.kt b/Writerside/codeSnippets/SampleAction.kt index 9ba3d3d4..ba2ce534 100644 --- a/Writerside/codeSnippets/SampleAction.kt +++ b/Writerside/codeSnippets/SampleAction.kt @@ -1,42 +1,11 @@ -fun interface SampleAction : ReducerAction<SampleState> { - class Remove : SampleAction { - override fun reduce(oldState: SampleState): SampleState = - oldState.copy(screen3 = null) - } +fun interface SampleReducer : NavigationReducer<SampleState> - class CreateScreen : SampleAction { - override fun reduce(oldState: SampleState): SampleState = - oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) +object SampleReducers { + val Remove = SampleReducer { oldState -> + oldState.copy(screen3 = null) } -} - -sealed interface SampleAction : NavigationAction<SampleState> { - class Remove : SampleAction - class CreateScreen : SampleAction -} - -@Parcelize -internal class RemovableItemContainerScreen( - private val navModel: NavModel<RemovableItemContainerState, RemovableItemContainerAction> = NavModel( - RemovableItemContainerState( - NestedScreen(canBeRemoved = false), - NestedScreen(canBeRemoved = false), - NestedScreen(canBeRemoved = true), - NestedScreen(canBeRemoved = false), - ) - ) -) : ContainerScreen<RemovableItemContainerState, RemovableItemContainerAction>(navModel) { - - override val reducer: NavigationReducer<RemovableItemContainerState, RemovableItemContainerAction> = NavigationReducer<RemovableItemContainerState, RemovableItemContainerAction> { action, state -> - when (action) { - is RemovableItemContainerAction.Remove -> { - state.copy(screen3 = null) - } - is RemovableItemContainerAction.CreateScreen -> { - state.copy(screen3 = NestedScreen(canBeRemoved = true)) - } - } + val CreateScreen = SampleReducer { oldState -> + oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) } - } diff --git a/Writerside/topics/Core-concepts.md b/Writerside/topics/Core-concepts.md index 1bafa57d..a52b7b48 100644 --- a/Writerside/topics/Core-concepts.md +++ b/Writerside/topics/Core-concepts.md @@ -48,13 +48,13 @@ structures. [`StackScreen`](StackScreen.md) and `MultiScreen` are built-in imple ![diagram_2.png](diagram_2.png){ height = 300 } -Each ContainerScreen is defined by two typed parameters: State and Action. +Each ContainerScreen is parameterized by its `State` type. ```kotlin @Stable -abstract class ContainerScreen<State : NavigationState, Action : NavigationAction<State>>( - private val navModel: NavModel<State, Action> -) : Screen, NavigationContainer<State, Action> by navModel +abstract class ContainerScreen<State : NavigationState>( + private val navModel: NavModel<State> +) : Screen, NavigationContainer<State> by navModel ``` { collapsible="true" default-state="collapsed" collapsed-title="ContainerScreen"} @@ -62,7 +62,7 @@ abstract class ContainerScreen<State : NavigationState, Action : NavigationActio <procedure> <title>State

-NavigationState - a class that can contain nested screens and other additional information. The state can be updated by calling dispatch(action). +NavigationState - a class that can contain nested screens and other additional information. The state can be updated by calling dispatch(reducer).

@Parcelize @@ -81,10 +81,9 @@ Read the State Update section for more details. -Action +Reducer

-NavigationAction - a marker interface to distinguish actions for this container on a specific State. You can also use -ReducerAction to define actions with an in-place update function: +NavigationReducer - a pure state transformer that takes the old state and returns the new one. Dispatch it via dispatch(reducer) to update the container's state:

@@ -106,18 +105,27 @@ The built-in `StackScreen` and `MultiScreen` use `InternalContent` under the hoo ## State Update -To update the state of a `ContainerScreen`, use `dispatch(action: Action)`. -There are two ways to define your action: +The simplest way to update a `ContainerScreen`'s state is to dispatch a lambda that calculates the new state from the old one. For example, to push two new screens onto a `StackScreen`: -### ReducerAction (Recommended) +```kotlin +stackContainer.dispatch { oldState -> + StackState(oldState.stack + listOf(NextScreen(), AnotherScreen())) +} +``` -ReducerAction allows defining the update function in-place. - +The built-in containers already expose convenience extension functions for the most common operations, so the same change can be written as: + +```kotlin +stackContainer.forward(NextScreen(), AnotherScreen()) +``` + +Explore the available commands in [`StackActions.kt`](%github_code_url%modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt) (`forward`, `back`, `replace`, …) and [`MultiScreenActions.kt`](%github_code_url%modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt). -### Custom Reducer + Action +If you need reusable or parameterized state changes, define your own `NavigationReducer`. Pick whichever shape fits your code: -You can provide a reducer in your ContainerScreen implementation. - +- named instances on an object (the `SampleReducer` / `SampleReducers` example above) +- a class with constructor parameters — see [`RemoveTabReducer`](%github_code_url%sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt) in the sample app +- your own extension functions on a typed `NavigationContainer` for ergonomic call sites ## Root Screen diff --git a/Writerside/topics/ModoOverview.md b/Writerside/topics/ModoOverview.md index 53614537..7b944205 100644 --- a/Writerside/topics/ModoOverview.md +++ b/Writerside/topics/ModoOverview.md @@ -39,15 +39,11 @@ Modo is an easy-to-use library. Here are some of the most-used features of Modo val onForwardClick = { stackNavigation.forward(SampleScreen()) } ``` -* You can easily change `NavigationState` as needed by calling `dispatch(action: (StackState) -> StackState)` on `NavigationContainer`: +* For arbitrary state changes the built-in commands don't cover, pass a lambda that calculates the new state from the old one. For example, to remove every `LoginScreen` from the stack: ```kotlin navigation.dispatch { oldState -> - StackState( - oldState.stack.filterIndexed { index, screen -> - index % 2 == 0 && screen != oldState.stack.last() - } - ) + StackState(oldState.stack.filter { it !is LoginScreen }) } ``` diff --git a/Writerside/topics/StackScreen.md b/Writerside/topics/StackScreen.md index 867bba70..ff52809c 100644 --- a/Writerside/topics/StackScreen.md +++ b/Writerside/topics/StackScreen.md @@ -18,14 +18,14 @@ val stackScreen = DefaultStackScreen( ) ``` -You can change the stack by calling `dispatch(Action)` on `NavigationContainer`. +You can change the stack by dispatching a `StackReducer` on `StackNavContainer` (an alias for `NavigationContainer`). -For a convenient way to update the state, there is a function `dispatch(action: (StackState) -> StackState)` that allows you to change the state +For a convenient way to update the state, there is a function `dispatch(reducer: (StackState) -> StackState)` that allows you to change the state according to your needs. There is also a list of built-in commands. -## Built-in Navigation Actions +## Built-in Stack Commands -Modo provides a list of built-in actions for stack navigation. You can explore the available +Modo provides a list of built-in commands (extension functions on `StackNavContainer`) for stack navigation. You can explore the available commands [here](%github_code_url%modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt). Some of them include: * `forward(screen: Screen, vararg screens: Screen)` - Adds the given screens to the top of the stack. diff --git a/Writerside/topics/snippets.topic b/Writerside/topics/snippets.topic index d2c33244..993e5b94 100644 --- a/Writerside/topics/snippets.topic +++ b/Writerside/topics/snippets.topic @@ -20,7 +20,7 @@ Each node is a Screen or ContainerScreen. Leaf nodes are Screens. Inner nodes are ContainerScreens. They can contain other Screens or ContainerScreens in their - navigationState. + NavigationState. The root node is a RootScreen. You can have multiple roots in your app. See How to integrate Modo for details. diff --git a/changelogs/0.12.0.md b/changelogs/0.12.0.md new file mode 100644 index 00000000..c3b0be4d --- /dev/null +++ b/changelogs/0.12.0.md @@ -0,0 +1,102 @@ +## Architecture refactor + +Before this release the navigation layers were tightly coupled: `ContainerScreen` carried both a model and a `NavigationRenderer`, the renderer interface was public, and state updates flowed through an action-dispatch protocol where `NavigationAction` and `ReducerAction` were separate concepts (action = command; reducer = `(action, state) -> State?`). Together they forced model and renderer to share an `Action` vocabulary — hence the two-generic types. + +This refactor separates the concerns cleanly: + +- **`NavModel` is a pure-Kotlin model.** `MutableStateFlow` plus `dispatch(reducer)`. No `androidx.compose.runtime` references at the declaration level (stability is inherited from `NavigationContainer`). `Parcelable`, so it survives process death on its own. +- **`ComposeRenderer` is an internal Compose adapter.** It subscribes to a `StateFlow` via a managed `CoroutineScope` and mirrors the value into Compose state. It does not own state and does not know what produced it. The public `NavigationRenderer` interface is gone. +- **`ContainerScreen` is just `Screen, NavigationContainer by navModel`.** A screen with a model glued in via delegation, plus an internal renderer. No reducer plumbing or renderer indirection. +- **One reducer concept, not two.** `NavigationReducer` is now the single primitive: `fun interface NavigationReducer { fun reduce(oldState: State): State }`. It replaces `NavigationAction` (the command-style marker) entirely — the dispatched reducer *is* the command. The old `ReducerAction` survives only as a deprecated typealias to `NavigationReducer`. Collapsing the two concepts is what lets the `Action` generic disappear. + +Everything else in this release — single-generic types, `subtreeStateFlow`, the deprecations — falls out of those new boundaries. + +--- + +## API changes + +**Generics collapsed to a single type parameter.** `NavigationContainer`, `ContainerScreen`, and `NavModel` no longer have an `Action` generic. Call sites must drop the second type parameter: + +- `ContainerScreen` → `ContainerScreen` +- `NavigationContainer` → `NavigationContainer` +- `NavModel` → `NavModel` + +**Action-dispatch model replaced with pure reducers.** `NavigationReducer` is now a single-method `fun interface`: + +```kotlin +fun interface NavigationReducer { + fun reduce(oldState: State): State +} +``` + +`dispatch(...)` accepts a reducer directly; state updates are pure functions of the previous state with no action argument. + +**`navigationState` removed; state exposed as a `StateFlow`.** `NavModel` holds state in a `MutableStateFlow`, so observers and synchronous readers go through `stateFlow.value`. + +**Deep tree observation.** Two new APIs let callers observe changes anywhere in the navigation subtree under a container: + +- `subtreeFlow(): Flow` — cold flow that emits the root state on collection and re-emits whenever any descendant container dispatches. +- `subtreeStateFlow(scope, started = Eagerly): StateFlow` — hot variant with synchronously accessible `.value`. **Intentionally does not deduplicate same-value emissions**, so nested dispatches that produce an unchanged root reference still notify observers to re-walk the tree. + +**`NavigationRenderer` interface removed.** `ComposeRenderer` is internal and now owns a `CoroutineScope` (created in `init`, cancelled on `dispose`) that collects state from the underlying flow. + +**`NavModel` is `Parcelable`.** State now survives process death via the state-flow holder; no separate render layer is required. + +--- + +## Deprecations + +The previous action-based / hot-flow API is retained as deprecated bindings so existing code keeps compiling under transitional builds, but most are at **`ERROR`** level — they will not run. + +- `NavigationAction` and its subtypes (`StackAction`, `MultiScreenAction`, `ListNavigationAction`, etc.) — **deprecated**. Replace with reducer factories (`StackActions`, `MultiScreenActions`, `ListReducer`, the new `RemoveTabReducer`, …). +- `NavigationContainer.dispatch(action: Action)` — **deprecated**. Use `dispatch(reducer: NavigationReducer)`. +- `NavigationContainer.navigationState` property — **removed**. Read `stateFlow.value`. +- `NavigationContainer.navigationStateFlow()` extension — **deprecated at `ERROR` level (no runtime fallback)**. Migration path: + - per-container, use `stateFlow` + - subtree-wide, use `subtreeFlow()` or `subtreeStateFlow(scope)` +- `NavigationRenderer` interface — **removed**. The renderer is internal to `ComposeRenderer`; downstream code should not depend on it. +- `NavigationReducer` (two-generic form) — **removed**. Use the single-generic `fun interface NavigationReducer`. + +### Migration cheat sheet + +1. Drop the `Action` generic from `ContainerScreen` / `NavigationContainer` / `NavModel` declarations and references. +2. Convert custom actions to reducers: `NavigationReducer { oldState -> /* compute new state */ }`. +3. `container.navigationState` → `container.stateFlow.value`. +4. `container.navigationStateFlow()` → `container.stateFlow` (per-container) or `container.subtreeStateFlow(scope)` (deep observation). +5. Replace any references to the removed `NavigationRenderer` interface with the hot/cold `StateFlow` APIs above. + +--- + +## Sample app changes + +- **`SampleAppSettings`** — DataStore-backed settings holder. Persists `showNavigationTree` (Boolean) and `navTreeVisibleScreens` (Int, default `2`). +- **`SettingsDialog`** — new dialog to toggle tree visibility and adjust depth via sliders. +- **`NavigationTree`** — new composable that replaces the previous `NavigationTreeStrip`. Driven by `subtreeStateFlow()`, mounted at the root activity, wrapped in `AnimatedContent` with dynamic visibility tied to settings. +- **`LifecycleEventsHistory`** — added `maxLines` support to bound the displayed event log. +- **`RemoveTabReducer`** — extracted reducer that replaces the previous `RemoveTabAction` in the multi-screen sample. + +--- + +## Test changes + +- **New** `ComposeRendererDisposalTest` — verifies the renderer's `CoroutineScope` lifecycle (created on init, cancelled on dispose). +- **New** `DeepNavigationStateFlowTest` — covers `subtreeStateFlow` across nested containers, including the deliberate no-op re-emission when a descendant dispatches without changing the root reference. +- **Renamed** (content updated to reducer form): + - `ListNavigationActionAddScreensTest` → `ListReducerAddScreensTest` + - `ListNavigationActionRemoveScreensTest` → `ListReducerRemoveScreensTest` + - `ListNavigationActionSetTest` → `ListReducerSetTest` + +--- + +## Repo meta + +- Add `AGENTS.md` and `CLAUDE.md` for AI-agent orientation. +- Add `.claude/skills/task-workflow/SKILL.md` for the task-folder workflow. + +--- + +## What's Changed + +* Architecture refactor: decouple NavModel, renderer, and reducer pipeline by @ikarenkov in https://github.com/ikarenkov/Modo/pull/78 + +**Full Changelog**: https://github.com/ikarenkov/Modo/compare/v0.11.0...v0.12.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d821e63..32ed394b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] composeWheelPicker = "1.0.0-beta05" leakcanaryAndroid = "2.14" -modo = "0.11.0" +modo = "0.12.0-rc1" #noinspection AndroidGradlePluginVersion androidGradlePlugin = "8.13.2" nexusPublish = "2.0.0" @@ -23,6 +23,7 @@ kotlinCompilerExtension = "1.5.12" minSdk = "21" compileSdk = "36" koin = "4.0.0" +datastorePreferences = "1.1.1" [libraries] androidx-compose-bom-modo = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBomModo" } @@ -55,6 +56,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.8.1" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.8.1" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -66,6 +68,7 @@ compose-compile-gradlePlugin = { group = "org.jetbrains.kotlin.plugin.compose", koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } +datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } [plugins] modo-detekt = { id = "modo-detekt" } modo-android-library = { id = "modo-android-library" } diff --git a/modo-compose/build.gradle.kts b/modo-compose/build.gradle.kts index b496b63f..f0fcb459 100644 --- a/modo-compose/build.gradle.kts +++ b/modo-compose/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.test.androidx.arch.core) testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) } tasks.withType(Test::class) { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index 60089b68..6af0850b 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt @@ -24,6 +24,13 @@ import com.github.terrakok.modo.logs.devLogV import com.github.terrakok.modo.model.ScreenModelStore import com.github.terrakok.modo.model.dependenciesSortedByRemovePriority import com.github.terrakok.modo.util.currentOrThrow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch typealias RendererContent = @Composable ComposeRendererScope.(Modifier) -> Unit @@ -150,25 +157,34 @@ class ComposeRendererScope( * 2. Storing and clearing composable states inside [SaveableStateHolder] */ internal class ComposeRenderer( - private val containerScreen: ContainerScreen<*, *>, -) : NavigationRenderer { + private val containerScreen: ContainerScreen, + stateFlow: StateFlow, +) { + internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var lastState: State? = null - var state: State? by mutableStateOf(null, neverEqualPolicy()) + var state: State by mutableStateOf(stateFlow.value, neverEqualPolicy()) private set // TODO: share removed screen for whole structure? private val removedScreens = mutableSetOf() - override fun render(state: State) { - this.state?.let { currentState -> - removedScreens.addAll(calculateRemovedScreens(currentState, state)) + init { + scope.launch { + stateFlow.drop(1).collect { newState -> + removedScreens.addAll(calculateRemovedScreens(state, newState)) + lastState = state + state = newState + // Handling a case when updating state doesn't cause UI to update. But if some + // screens were removed, we need to move them to destroy state. + // F.e. removing previous screen causes this case. + onPreDispose() + } } - lastState = this.state - this.state = state - // Handling a case when updating state doesn't cause UI to update. But if some screens was removed, we need to move them to destroy state. - // F.e. removing previous screen causes this case. - onPreDispose() + } + + internal fun dispose() { + scope.cancel() } @Suppress("UnusedPrivateProperty", "SpreadOperator") @@ -210,13 +226,13 @@ internal class ComposeRenderer( * @param stateHolder - SaveableStateHolder that contains screen states * @param clearAll - forces to remove all screen states that renderer holds (removed and "displayed") */ - private fun clearScreens(stateHolder: SaveableStateHolder, clearAll: Boolean = false) { - fun Iterable.clearStates(stateHolder: SaveableStateHolder) = forEach { screen -> + internal fun clearScreens(stateHolder: SaveableStateHolder?, clearAll: Boolean = false) { + fun Iterable.clearStates(stateHolder: SaveableStateHolder?) = forEach { screen -> screen.clearState(stateHolder) } if (clearAll) { - state?.getChildScreens()?.clearStates(stateHolder) + state.getChildScreens().clearStates(stateHolder) } // There can be several transition of different screens on the screen, // so it is important properly clear screens that are not visible for user. @@ -233,13 +249,13 @@ internal class ComposeRenderer( * Called onPreDispose for removed screens, that are not presented in [preDisposeProtectedScreens] (not displayed on screen). * @param clearAll - forces to call onPreDispose on all children screen states that renderer holds (removed and "displayed") */ - private fun onPreDispose(clearAll: Boolean = false) { + internal fun onPreDispose(clearAll: Boolean = false) { fun Iterable.onPreDispose() = forEach { screen -> screen.onPreDispose() } if (clearAll) { - state?.getChildScreens()?.onPreDispose() + state.getChildScreens().onPreDispose() } // There can be several transition of different screens on the screen, // so it is important properly clear screens that are not visible for user. @@ -247,38 +263,50 @@ internal class ComposeRenderer( safeToRemove.onPreDispose() } - private fun Screen.clearState(stateHolder: SaveableStateHolder) { - // It's important to do this check for debug purpose, because we must guaranty that Screen is cleaned only if it is not displaying anymore. - // But it seems like it is not working with movable content, so this one is going to be triggered. - if (this in cleanupProtectedScreens) { - ModoDevOptions.onIllegalClearState.validationFailed( - IllegalStateException( - "Trying to remove clean state of the screen $this, why this screen still is visible for User." - ) - ) - } - ScreenModelStore.remove(this) - stateHolder.removeState(saveableStateKey) - stateHolder.removeState(overlaySaveableStateKey) - - ModoDevOptions.onScreenDisposeListener?.invoke(this) - // clear nested screens using recursion - ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.clearScreens(stateHolder, clearAll = true) - } - - // need for correct handling lifecycle - private fun Screen.onPreDispose() { - devLogI(TAG) { "onPreDispose $screenKey" } - dependenciesSortedByRemovePriority() - .filterIsInstance() - .forEach { it.onPreDispose() } - // send onPreDispose to nested screens - ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.onPreDispose(clearAll = true) - } - private fun calculateRemovedScreens(oldState: NavigationState, newState: NavigationState): List { val newChainSet = newState.getChildScreens() return oldState.getChildScreens().filter { it !in newChainSet } } +} + +/** + * Dispatches `onPreDispose` to [this] screen's [LifecycleDependency] (so user code observing + * `ON_DESTROY` runs) and then cascades into nested renderers' children. + */ +internal fun Screen.onPreDispose() { + devLogI(TAG) { "onPreDispose $screenKey" } + dependenciesSortedByRemovePriority() + .filterIsInstance() + .forEach { it.onPreDispose() } + (this as? ContainerScreen<*>)?.renderer?.onPreDispose(clearAll = true) +} + +/** + * Removes [this] screen's [ScreenModelStore] entries, evicts its slots from [stateHolder] (when + * provided), fires [ModoDevOptions.onScreenDisposeListener], and recurses into any nested + * renderer to clean its children + dispose its scope. + * + * @param stateHolder caller-owned [SaveableStateHolder] to evict slots from. Pass null when the + * holder is dying with its composition (root teardown). + */ +internal fun Screen.clearState(stateHolder: SaveableStateHolder?) { + // It's important to do this check for debug purpose, because we must guaranty that Screen is cleaned only if it is not displaying anymore. + // But it seems like it is not working with movable content, so this one is going to be triggered. + if (this in cleanupProtectedScreens) { + ModoDevOptions.onIllegalClearState.validationFailed( + IllegalStateException( + "Trying to remove clean state of the screen $this, why this screen still is visible for User." + ) + ) + } + ScreenModelStore.remove(this) + stateHolder?.removeState(saveableStateKey) + stateHolder?.removeState(overlaySaveableStateKey) + + ModoDevOptions.onScreenDisposeListener?.invoke(this) + (this as? ContainerScreen<*>)?.renderer?.let { nested -> + nested.clearScreens(stateHolder, clearAll = true) + nested.dispose() + } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt index c1c68454..834bc281 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt @@ -9,34 +9,40 @@ import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update -val LocalContainerScreen = staticCompositionLocalOf?> { null } +val LocalContainerScreen = staticCompositionLocalOf?> { null } -fun interface ReducerAction : NavigationAction { - fun reduce(oldState: State): State -} +@Deprecated( + message = "Use NavigationReducer instead", + replaceWith = ReplaceWith("NavigationReducer") +) +typealias ReducerAction = NavigationReducer @Stable -abstract class ContainerScreen>( - private val navModel: NavModel -) : Screen, NavigationContainer by navModel { +abstract class ContainerScreen( + private val navModel: NavModel +) : Screen, NavigationContainer by navModel { /** * The reducer that can be used to control state updates. */ - open val reducer: NavigationReducer? = null + @Deprecated( + message = "Custom navigation behavior should move from screen-level external reducers to dispatch-time reducers. " + + "This property is no longer used by the navigation system.", + level = DeprecationLevel.ERROR + ) + open val reducer: NavigationReducer? = null - internal val renderer: NavigationRenderer? - get() = navModel.renderer + internal val renderer: ComposeRenderer = ComposeRenderer(this, navModel.stateFlow) final override val screenKey: ScreenKey = navModel.screenKey - init { - navModel.init( - reducerProvider = { reducer }, - renderer = ComposeRenderer(this) - ) - } + /** Compose-observable view of the current navigation state. */ + val navigationState: State get() = renderer.state /** * This function can be used to provide composition locals for inner screens. @@ -52,7 +58,7 @@ abstract class ContainerScreen>? = null + open fun provideNavigationContainer(): ProvidedValue>? = null /** * Use this function to render the content of nested screens. It provides correct work of [rememberSaveable] by using [SaveableStateHolder]. @@ -65,81 +71,46 @@ abstract class ContainerScreen = defaultRendererContent ) { - val composeRenderer = renderer as ComposeRenderer - composeRenderer.Content(screen, modifier, provideCompositionLocals(), content) + renderer.Content(screen, modifier, provideCompositionLocals(), content) } override fun toString(): String = this::class.java.simpleName + "(navModel: $navModel)" } -typealias ReducerProvider = () -> NavigationReducer? - /** - * Container for simple using [ContainerScreen] with [Parcelize] + * Pure UDF implementation of [NavigationContainer]. Holds state in a [MutableStateFlow] and mutates it + * exclusively through [dispatch]. Parcelable so it survives process death. + * Intended to be owned by a [ContainerScreen], which delegates [NavigationContainer] to it. */ -@Stable -class NavModel>( +class NavModel( initialState: State, val screenKey: ScreenKey = generateScreenKey() -) : NavigationContainer, Parcelable { - - override var navigationState: State = initialState - get() = renderer?.state ?: field - set(value) { - field = value - renderer?.render(value) - } +) : NavigationContainer, Parcelable { - private var reducerProvider: ReducerProvider? = null - internal var renderer: ComposeRenderer? = null - private set + private val _navigationState = MutableStateFlow(initialState) + override val stateFlow: StateFlow = _navigationState.asStateFlow() - internal fun init( - reducerProvider: ReducerProvider, - renderer: ComposeRenderer - ) { - assert(this.reducerProvider == null && this.renderer == null) { - "Trying to initialize navigation model again" - } - this.reducerProvider = reducerProvider - this.renderer = renderer.also { it.render(navigationState) } - } - - override fun dispatch(action: Action, vararg actions: Action) { - val reducer = reducerProvider!!() - var state = reduce(reducer, navigationState, action) - for (varargAction in actions) { - state = reduce(reducer, state, varargAction) - } - navigationState = state + override fun dispatch(reducer: NavigationReducer) { + _navigationState.update { reducer.reduce(it) } } override fun describeContents(): Int = 0 override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeParcelable(navigationState, flags) + parcel.writeParcelable(_navigationState.value, flags) parcel.writeString(screenKey.value) } - private fun reduce(reducer: NavigationReducer?, state: State, action: Action): State = - reducer?.reduce(action, state) - ?: when (action) { - is ReducerAction<*> -> (action as? ReducerAction)?.reduce(state) - else -> null - } - // TODO: print logs when fallback to state - ?: state - - override fun toString(): String = "NavModel(navigationState=$navigationState, screenKey=$screenKey)" + override fun toString(): String = "NavModel(navigationState=${_navigationState.value}, screenKey=$screenKey)" - companion object CREATOR : Parcelable.Creator> { - override fun createFromParcel(parcel: Parcel): NavModel { + companion object CREATOR : Parcelable.Creator> { + override fun createFromParcel(parcel: Parcel): NavModel { val state = parcel.readParcelable(NavModel::class.java.classLoader)!! val screenKey = parcel.readString()!! return NavModel(state, ScreenKey(screenKey)) } - override fun newArray(size: Int): Array?> = arrayOfNulls(size) + override fun newArray(size: Int): Array?> = arrayOfNulls(size) } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt b/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt index 8adf87cc..17453aa6 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt @@ -1,6 +1,7 @@ package com.github.terrakok.modo import android.app.Activity +import android.os.Build import android.os.Bundle import android.util.Log import androidx.compose.runtime.Composable @@ -17,6 +18,7 @@ import androidx.lifecycle.LifecycleEventObserver import com.github.terrakok.modo.Modo.rememberRootScreen import com.github.terrakok.modo.Modo.rootScreens import com.github.terrakok.modo.Modo.save +import com.github.terrakok.modo.lifecycle.LifecycleDependency import com.github.terrakok.modo.model.ScreenModelStore import com.github.terrakok.modo.util.getActivity import java.util.concurrent.ConcurrentHashMap @@ -35,7 +37,10 @@ object Modo { /** * Saves provided screen with nested graph to bundle for further restoration. */ - @Deprecated("Use rememberRootScreen, which handles saving and restoring automatically. Will be removed in 1.0.") + @Deprecated( + "Use rememberRootScreen, which handles saving and restoring automatically. Will be removed in 1.0.", + ReplaceWith("this.rememberRootScreen { rootScreen }") + ) fun save(outState: Bundle, rootScreen: Screen?) { outState.putInt(MODO_SCREEN_COUNTER_KEY, screenCounterKey.get()) outState.putParcelable(MODO_GRAPH, rootScreen) @@ -68,9 +73,19 @@ object Modo { * Must be null for Activities and for the very first Fragment creation. * @param rootScreenProvider called only in scenario 3 to construct the initial root screen. */ - @Deprecated("Use rememberRootScreen, which handles all lifecycle concerns automatically. Will be removed in 1.0.") + @Deprecated( + "Use rememberRootScreen, which handles all lifecycle concerns automatically. Will be removed in 1.0.", + ReplaceWith("this.rememberRootScreen(rootScreenProvider)") + ) fun getOrCreateRootScreen(savedState: Bundle?, inMemoryScreen: RootScreen?, rootScreenProvider: () -> T): RootScreen { - val savedModoGraph = savedState?.getParcelable>(MODO_GRAPH) + val savedModoGraph = savedState?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + it.getParcelable(MODO_GRAPH, RootScreen::class.java) + } else { + @Suppress("DEPRECATION") + it.getParcelable(MODO_GRAPH) + } + } as? RootScreen return if (savedModoGraph != null) { // Scenario 1: bundle restore. // Config change → cache hit, process death → cache miss, savedModoGraph is stored. @@ -90,14 +105,17 @@ object Modo { /** * Must be called to clear all data from [ScreenModelStore], related with removed screens. */ - @Deprecated("Use rememberRootScreen, which handles cleanup automatically. Will be removed in 1.0.") + @Deprecated( + "Use rememberRootScreen, which handles cleanup automatically. Will be removed in 1.0.", + ReplaceWith("Modo.rememberRootScreen") + ) fun onRootScreenFinished(rootScreen: RootScreen?) = finishRootScreen(rootScreen) private fun finishRootScreen(rootScreen: RootScreen?) { if (rootScreen != null) { Log.d("Modo", "rootScreen removing $rootScreen") rootScreens.remove(rootScreen.screenKey) - clearScreenModel(rootScreen) + rootScreen.clearFullTree() } } @@ -199,11 +217,17 @@ object Modo { return rootScreen } - private fun clearScreenModel(screen: Screen) { - ScreenModelStore.remove(screen) - (screen as? ContainerScreen<*, *>)?.navigationState?.getChildScreens()?.forEach(::clearScreenModel) - } +} +/** + * Final-tier teardown for a root subtree, run when there is no parent renderer to drive the + * in-tree cleanup cascade. Cancels every nested [ComposeRenderer] coroutine scope, evicts + * [ScreenModelStore] entries, and dispatches `ON_DESTROY` through each screen's + * [LifecycleDependency] — the bits that outlive the composition's own teardown. + */ +internal fun ContainerScreen<*>.clearFullTree() { + onPreDispose() + clearState(stateHolder = null) } /** diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index b3e76af7..b779b977 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -2,12 +2,18 @@ package com.github.terrakok.modo import android.os.Parcelable import androidx.compose.runtime.Stable -import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.shareIn /** * State of navigation used in [NavigationContainer]. Can be any type. @@ -22,37 +28,155 @@ interface NavigationState : Parcelable { /** * Marker interface to be able specify type of action for [NavigationContainer]. */ -interface NavigationAction +@Deprecated("Use NavigationReducer directly.", ReplaceWith("NavigationReducer")) +interface NavigationAction -fun interface NavigationReducer> { +/** + * Pure state transformer: takes old state and returns new state. + */ +fun interface NavigationReducer { /** - * Return a new state based on old [state] and incoming action. If returns null, then the work will be addressed to parent reducer. + * Return a new state based on old [oldState]. */ - fun reduce(action: Action, state: State): State? + fun reduce(oldState: State): State } /** - * Abstraction that represents tha UDF navigation container. It has [navigationState] that can be changed through [dispatch] and [NavigationAction]. - * @param State - type of state that is managed by container. - * @param Action - type for actions that can be sent to [dispatch] to request state updates. + * UDF navigation contract. State is exposed as a [StateFlow] and mutated exclusively through [dispatch]. + * The pure-Kotlin UDF implementation of this interface is [NavModel]. + * @param State - type of state that container manages. */ @Stable -interface NavigationContainer> { - val navigationState: State - - fun dispatch(action: Action, vararg actions: Action) +interface NavigationContainer { + val stateFlow: StateFlow + /** + * Atomically applies [reducer] to the current state. + * + * Reducers MUST be pure functions of their input state: on contended dispatches the + * implementation may invoke the reducer multiple times (compare-and-set retry) before + * one application wins and is published. Any side effect performed inside the reducer + * will therefore execute an unspecified number of times — perform side effects outside + * the reducer (e.g. before/after calling [dispatch]). + */ + fun dispatch(reducer: NavigationReducer) } -fun > NavigationContainer.navigationStateFlow(): Flow = - snapshotFlow { navigationState } +/** + * Extension to allow passing several reducers as a single atomic operation. + * Reducers are applied in order, and the resulting state is dispatched once. + * + * This is particularly important for animations and other UI functionalities that depend on + * a single state transition to avoid intermediate inconsistent states or multiple UI updates. + */ +fun NavigationContainer.dispatch( + reducer: NavigationReducer, + vararg reducers: NavigationReducer +) { + dispatch { oldState -> + var state = reducer.reduce(oldState) + for (r in reducers) { + state = r.reduce(state) + } + state + } +} -fun > NavigationContainer.navigationStateStateFlow( - coroutineScope: CoroutineScope, -): StateFlow = - snapshotFlow { navigationState } - .stateIn(coroutineScope, started = SharingStarted.WhileSubscribed(), initialValue = navigationState) +/** + * Cold [Flow] that observes navigation state changes across the entire subtree rooted at this container. + * + * Emits the current state on collection, and re-emits whenever this container or *any* descendant + * [NavigationContainer] dispatches. Observers are expected to re-walk via [NavigationState.getChildScreens] + * to inspect the updated tree — emissions carry the root state, not nested states. + * + * Because this is a cold flow, no [CoroutineScope] is needed at the call site. Use [subtreeStateFlow] + * for a hot [StateFlow] with a synchronously accessible current value. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun NavigationContainer<*>.subtreeFlow(): Flow = + stateFlow.flatMapLatest { state -> + flow { + emit(state) + // Children are re-read on every parent emission: when the parent dispatches to add + // (or replace) children, flatMapLatest cancels this inner flow and a fresh one re-walks + // getChildScreens(). So an "empty now, populated later" transition is handled by the + // outer flatMapLatest, not by branching here. + state.getChildScreens() + .filterIsInstance>() + .map { it.subtreeFlow().drop(1) } + .merge() + .collect { emit(state) } + } + } -interface NavigationRenderer { - fun render(state: State) +/** + * Hot [StateFlow] that observes navigation state changes across the entire subtree rooted at this container. + * + * Emits the current root state on collection, and re-emits whenever this container or *any* descendant + * [NavigationContainer] dispatches. Observers are expected to re-walk via [NavigationState.getChildScreens] + * to inspect the updated tree — emissions carry the root state, not nested states. + * + * Note: unlike a typical [StateFlow], same-value re-emissions are NOT deduplicated — a nested dispatch + * does not change the root state object but must still trigger observers. + * + * @param scope the [CoroutineScope] that keeps the returned [StateFlow] active. + */ +fun NavigationContainer<*>.subtreeStateFlow(scope: CoroutineScope): StateFlow { + // SharedFlow(replay=1) preserves all emissions without equals-based deduplication, + // which is required because nested dispatches re-emit the unchanged root state as a signal. + // Started eagerly so `.value` / `.collect` always observe at least the initial root state without + // a first-subscriber timing window. + val shared: SharedFlow = + subtreeFlow().shareIn(scope, SharingStarted.Eagerly, replay = 1) + return object : StateFlow { + override val value: NavigationState + get() = shared.replayCache.firstOrNull() ?: stateFlow.value + override val replayCache: List + get() = shared.replayCache.ifEmpty { listOf(stateFlow.value) } + override suspend fun collect(collector: FlowCollector): Nothing = + shared.collect(collector) + } } + +/** + * Migration shim for the previous `navigationState` property on [NavigationContainer]. Pick the + * replacement that matches the consumer's actual intent: + * + * - [NavigationContainer.stateFlow].value — one-shot read of this container's state. + * - `stateFlow.collectAsState()` — Compose-reactive read of this container's state. + * - [subtreeStateFlow] — hot StateFlow observing this container AND every nested container. + * - [subtreeFlow] — cold Flow equivalent of [subtreeStateFlow], no scope required. + * - Or change the declared type to the concrete ContainerScreen subtype (StackScreen, + * MultiScreen, ...) which still exposes Compose-reactive `navigationState`. + */ +@Deprecated( + message = "navigationState was removed from NavigationContainer. Pick a migration:\n" + + " - stateFlow.value — one-shot read of this container's state\n" + + " - stateFlow.collectAsState() — Compose-reactive read of this container's state\n" + + " - subtreeStateFlow(scope) — hot StateFlow observing this container AND every nested container (whole subtree)\n" + + " - subtreeFlow() — cold Flow equivalent of subtreeStateFlow, no scope required\n" + + " - or change the declared type to the concrete ContainerScreen subtype (StackScreen, MultiScreen, ...) " + + "which still exposes Compose-reactive navigationState.", + replaceWith = ReplaceWith("stateFlow.value"), + level = DeprecationLevel.ERROR, +) +@Suppress("unused") +val NavigationContainer.navigationState: State + get() = stateFlow.value + +/** + * Migration shim for the dev-branch `navigationStateFlow()` extension that produced a + * `snapshotFlow { navigationState }` flow. The closest replacement is the per-container + * [NavigationContainer.stateFlow] property; for whole-subtree observation use [subtreeFlow] + * (cold) or [subtreeStateFlow] (hot). + */ +@Deprecated( + message = "Replaced by the `stateFlow` property (per-container) and " + + "`subtreeFlow()` / `subtreeStateFlow(scope)` (whole subtree). " + + "The previous snapshotFlow-based extension is gone.", + replaceWith = ReplaceWith("stateFlow"), + level = DeprecationLevel.ERROR, +) +@Suppress("unused") +fun NavigationContainer.navigationStateFlow(): Flow = + stateFlow diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt index a28d70a4..e06531e2 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt @@ -19,9 +19,10 @@ data class RootScreenState( * Screen for single source of providing [LocalSaveableStateHolder]. Should be used with [Modo.rememberRootScreen] or [Modo.getOrCreateRootScreen]. */ @Parcelize +@Suppress("MemberExtensionConflict") class RootScreen internal constructor( - private val navModel: NavModel, NavigationAction>> -) : ContainerScreen, NavigationAction>>( + private val navModel: NavModel> +) : ContainerScreen>( navModel ) { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt index c490b087..ea7c3bb9 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt @@ -7,15 +7,15 @@ import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import kotlinx.parcelize.Parcelize -typealias ListNavModel = NavModel +typealias ListNavModel = NavModel fun ListNavModel(screens: List): ListNavModel = NavModel(ListNavigationState(screens = screens)) -interface ListNavigationContainer : NavigationContainer +interface ListNavigationContainer : NavigationContainer abstract class ListNavigationContainerScreen( navModel: ListNavModel -) : ListNavigationContainer, ContainerScreen(navModel) +) : ListNavigationContainer, ContainerScreen(navModel) @Parcelize data class ListNavigationState( diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListReducer.kt similarity index 63% rename from modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt rename to modo-compose/src/main/java/com/github/terrakok/modo/list/ListReducer.kt index 1472a40a..a46180dc 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListReducer.kt @@ -1,18 +1,21 @@ package com.github.terrakok.modo.list import com.github.terrakok.modo.NavigationContainer -import com.github.terrakok.modo.ReducerAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey -fun interface ListNavigationAction : ReducerAction { +@Deprecated("Use ListReducer instead.", ReplaceWith("ListReducer")) +typealias ListNavigationAction = ListReducer + +fun interface ListReducer : NavigationReducer { class RemoveScreens private constructor( - private val reducer: ReducerAction - ) : ListNavigationAction { + private val reducer: NavigationReducer + ) : ListReducer { constructor(removeCondition: (pos: Int, screen: Screen) -> Boolean) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> ListNavigationState( oldState.screens.filterIndexed { index, screen -> !removeCondition(index, screen) } ) @@ -20,7 +23,7 @@ fun interface ListNavigationAction : ReducerAction { ) constructor(screenToRemove: Screen, vararg screensToRemove: Screen) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> val screensToRemoveSet = screensToRemove.toMutableSet().apply { add(screenToRemove) } ListNavigationState( oldState.screens.filter { screen -> screen !in screensToRemoveSet } @@ -36,7 +39,7 @@ fun interface ListNavigationAction : ReducerAction { // Unable to use vararg because of https://youtrack.jetbrains.com/issue/KT-33565/Allow-vararg-parameter-of-inline-class-type constructor(screenKeysToRemove: Set) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> ListNavigationState( oldState.screens.filter { screen -> screen.screenKey !in screenKeysToRemove } ) @@ -51,11 +54,11 @@ fun interface ListNavigationAction : ReducerAction { } class AddScreens private constructor( - private val reducer: ReducerAction - ) : ListNavigationAction { + private val reducer: NavigationReducer + ) : ListReducer { constructor(pos: Int, screen: Screen, vararg screens: Screen) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> val newScreensCount = screens.size + 1 ListNavigationState( List(oldState.screens.size + newScreensCount) { @@ -71,7 +74,7 @@ fun interface ListNavigationAction : ReducerAction { ) constructor(screen: Screen, vararg screens: Screen, addToEnd: Boolean = false) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> ListNavigationState( if (addToEnd) { List(oldState.screens.size + screens.size + 1) { @@ -98,17 +101,17 @@ fun interface ListNavigationAction : ReducerAction { } class SetScreens private constructor( - private val reducer: ReducerAction - ) : ListNavigationAction { + private val reducer: NavigationReducer + ) : ListReducer { constructor(vararg screens: Screen) : this( - ReducerAction { _ -> + NavigationReducer { _ -> ListNavigationState(screens.toList()) } ) constructor(screens: List) : this( - ReducerAction { _ -> ListNavigationState(screens) } + NavigationReducer { _ -> ListNavigationState(screens) } ) override fun reduce(oldState: ListNavigationState): ListNavigationState = reducer.reduce(oldState) @@ -116,29 +119,29 @@ fun interface ListNavigationAction : ReducerAction { } -fun NavigationContainer.dispatch(action: (ListNavigationState) -> ListNavigationState) = - dispatch(ListNavigationAction(action)) +fun NavigationContainer.dispatch(action: (ListNavigationState) -> ListNavigationState) = + dispatch(NavigationReducer(action)) -fun NavigationContainer.addScreens(pos: Int, screen: Screen, vararg screens: Screen) = - dispatch(ListNavigationAction.AddScreens(pos, screen, *screens)) +fun NavigationContainer.addScreens(pos: Int, screen: Screen, vararg screens: Screen) = + dispatch(ListReducer.AddScreens(pos, screen, *screens)) -fun NavigationContainer.addScreens(screen: Screen, vararg screens: Screen, addToEnd: Boolean = false) = - dispatch(ListNavigationAction.AddScreens(screen, *screens, addToEnd = addToEnd)) +fun NavigationContainer.addScreens(screen: Screen, vararg screens: Screen, addToEnd: Boolean = false) = + dispatch(ListReducer.AddScreens(screen, *screens, addToEnd = addToEnd)) -fun NavigationContainer.removeScreens(removeCondition: (pos: Int, screen: Screen) -> Boolean) = - dispatch(ListNavigationAction.RemoveScreens(removeCondition)) +fun NavigationContainer.removeScreens(removeCondition: (pos: Int, screen: Screen) -> Boolean) = + dispatch(ListReducer.RemoveScreens(removeCondition)) -fun NavigationContainer.removeScreen(screenKeyToRemove: ScreenKey) = - dispatch(ListNavigationAction.RemoveScreens(screenKeyToRemove)) +fun NavigationContainer.removeScreen(screenKeyToRemove: ScreenKey) = + dispatch(ListReducer.RemoveScreens(screenKeyToRemove)) -fun NavigationContainer.removeScreens(screenToRemove: Screen) = - dispatch(ListNavigationAction.RemoveScreens(screenToRemove)) +fun NavigationContainer.removeScreens(screenToRemove: Screen) = + dispatch(ListReducer.RemoveScreens(screenToRemove)) -inline fun NavigationContainer.removeScreens() = - dispatch(ListNavigationAction.RemoveScreens()) +inline fun NavigationContainer.removeScreens() = + dispatch(ListReducer.RemoveScreens()) -fun NavigationContainer.setScreens(vararg screens: Screen) = - dispatch(ListNavigationAction.SetScreens(*screens)) +fun NavigationContainer.setScreens(vararg screens: Screen) = + dispatch(ListReducer.SetScreens(*screens)) -fun NavigationContainer.removeAllScreens() = - dispatch(ListNavigationAction.SetScreens()) \ No newline at end of file +fun NavigationContainer.removeAllScreens() = + dispatch(ListReducer.SetScreens()) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt index 0b9db673..16acf685 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt @@ -2,6 +2,7 @@ package com.github.terrakok.modo.multiscreen import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import com.github.terrakok.modo.ContainerScreen @@ -10,19 +11,32 @@ import com.github.terrakok.modo.Screen import com.github.terrakok.modo.defaultRendererContent val LocalMultiScreenNavigation: ProvidableCompositionLocal = staticCompositionLocalOf { - error("There is no MultiScreenContainer in hierarchy, or maybe you override provideCompositionLocal and forgot to call supper.") + error("There is no LocalMultiScreenNavigation in hierarchy, or maybe you override provideCompositionLocal and forgot to call super.") +} + +/** + * Provides the nearest [MultiScreen] in the composition. + */ +val LocalMultiScreen: ProvidableCompositionLocal = staticCompositionLocalOf { + error("There is no LocalMultiScreen in hierarchy. Wrap in MultiScreen or provide it manually.") } abstract class MultiScreen( navigationModel: MultiScreenNavModel -) : ContainerScreen(navigationModel), MultiScreenNavContainer { +) : ContainerScreen(navigationModel), MultiScreenNavContainer { @Composable override fun Content(modifier: Modifier) { SelectedScreen() } - override fun provideNavigationContainer() = LocalMultiScreenNavigation provides this + override fun provideNavigationContainer(): ProvidedValue = + LocalMultiScreenNavigation provides this + + override fun provideCompositionLocals(): Array> = arrayOf( + LocalMultiScreenNavigation provides this, + LocalMultiScreen provides this, + ) @Composable fun SelectedScreen( diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt index 52f6bd21..fa2ec9ac 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt @@ -1,19 +1,14 @@ package com.github.terrakok.modo.multiscreen -import com.github.terrakok.modo.NavigationAction import com.github.terrakok.modo.NavigationContainer -import com.github.terrakok.modo.ReducerAction +import com.github.terrakok.modo.NavigationReducer -interface MultiScreenAction : NavigationAction -fun interface MultiScreenReducerAction : MultiScreenAction, ReducerAction +@Deprecated("Use MultiScreenReducer instead.", ReplaceWith("MultiScreenReducer")) +typealias MultiScreenReducerAction = MultiScreenReducer -@Deprecated( - message = "Class with this name was renamed to SelectScreen. This typealias will be removed in further releases.", - replaceWith = ReplaceWith("SetMultiScreenState") -) -typealias SetContainers = SetMultiScreenState +fun interface MultiScreenReducer : NavigationReducer -class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducerAction { +class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducer { override fun reduce(oldState: MultiScreenState): MultiScreenState = state } @@ -24,25 +19,25 @@ class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducerActio ) typealias SelectContainer = SelectScreen -class SelectScreen(private val pos: Int) : MultiScreenReducerAction { +class SelectScreen(private val pos: Int) : MultiScreenReducer { override fun reduce(oldState: MultiScreenState): MultiScreenState = oldState.copy(selected = pos) } -fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenState) = dispatch(MultiScreenReducerAction(action)) +fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenState) = dispatch(NavigationReducer(action)) @Deprecated( message = "This function was renamed to setState. This function will be removed in further releases.", replaceWith = ReplaceWith("setState(state)") ) -fun NavigationContainer.setContainers(state: MultiScreenState) = setState(state) +fun NavigationContainer.setContainers(state: MultiScreenState) = setState(state) @Deprecated( message = "This function was renamed to selectScreen. This function will be removed in further releases.", replaceWith = ReplaceWith("selectScreen(index)") ) -fun NavigationContainer.selectContainer(index: Int) = selectScreen(index) +fun NavigationContainer.selectContainer(index: Int) = selectScreen(index) -fun NavigationContainer.setState(state: MultiScreenState) = dispatch(SetMultiScreenState(state)) +fun NavigationContainer.setState(state: MultiScreenState) = dispatch(SetMultiScreenState(state)) -fun NavigationContainer.selectScreen(pos: Int) = dispatch(SelectScreen(pos)) \ No newline at end of file +fun NavigationContainer.selectScreen(pos: Int) = dispatch(SelectScreen(pos)) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt index 80c27d21..bcf80fa6 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt @@ -7,9 +7,9 @@ import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import kotlinx.parcelize.Parcelize -typealias MultiScreenNavModel = NavModel +typealias MultiScreenNavModel = NavModel -interface MultiScreenNavContainer : NavigationContainer +interface MultiScreenNavContainer : NavigationContainer fun MultiScreenNavModel( screens: List, diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt index d5be90db..99c5ca33 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt @@ -1,16 +1,17 @@ package com.github.terrakok.modo.stack -import com.github.terrakok.modo.NavigationAction import com.github.terrakok.modo.NavigationContainer +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey -interface StackAction : NavigationAction +@Deprecated("Use StackReducer instead.", ReplaceWith("StackReducer")) +fun interface StackReducerAction : ReducerAction -fun interface StackReducerAction : StackAction, ReducerAction +fun interface StackReducer : NavigationReducer -class SetStack(val state: StackState) : StackReducerAction { +class SetStack(val state: StackState) : StackReducer { @Suppress("SpreadOperator") constructor(screen: Screen, vararg screens: Screen) : this( StackState(listOf(screen, *screens)) @@ -20,14 +21,14 @@ class SetStack(val state: StackState) : StackReducerAction { state } -class Forward(val screen: Screen, vararg val screens: Screen) : StackReducerAction { +class Forward(val screen: Screen, vararg val screens: Screen) : StackReducer { @Suppress("SpreadOperator") override fun reduce(oldState: StackState): StackState = StackState( oldState.stack + listOf(screen, *screens) ) } -class Replace(val screen: Screen, vararg val screens: Screen) : StackReducerAction { +class Replace(val screen: Screen, vararg val screens: Screen) : StackReducer { @Suppress("SpreadOperator") override fun reduce(oldState: StackState): StackState = if (oldState.stack.isNotEmpty()) { StackState( @@ -45,7 +46,7 @@ class Replace(val screen: Screen, vararg val screens: Screen) : StackReducerActi class BackTo( val backToCondition: (pos: Int, screen: Screen) -> Boolean, val including: Boolean = false -) : StackReducerAction { +) : StackReducer { constructor(screenKey: ScreenKey, including: Boolean = false) : this( { _, screen -> @@ -78,17 +79,17 @@ class BackTo( } companion object { - inline operator fun invoke(including: Boolean = false): StackReducerAction = BackTo( + inline operator fun invoke(including: Boolean = false): StackReducer = BackTo( { _, screen -> screen is T }, including ) - operator fun invoke(including: Boolean = false, condition: (pos: Int, screen: Screen) -> Boolean): StackReducerAction = + operator fun invoke(including: Boolean = false, condition: (pos: Int, screen: Screen) -> Boolean): StackReducer = BackTo(condition, including) } } -class RemoveScreens(val condition: (pos: Int, screen: Screen) -> Boolean) : StackReducerAction { +class RemoveScreens(val condition: (pos: Int, screen: Screen) -> Boolean) : StackReducer { override fun reduce(oldState: StackState): StackState = StackState( oldState.stack.filterIndexed { i, screen -> !condition(i, screen) } ) @@ -101,7 +102,7 @@ class RemoveScreens(val condition: (pos: Int, screen: Screen) -> Boolean) : Stac class Back( private val screensToDrop: Int = 1, private val canEmptyStack: Boolean = false -) : StackReducerAction { +) : StackReducer { override fun reduce(oldState: StackState): StackState = if (canEmptyStack || oldState.stack.size > 1) { StackState(oldState.stack.dropLast(screensToDrop)) @@ -110,28 +111,28 @@ class Back( } } -fun StackNavContainer.dispatch(action: (StackState) -> StackState) = dispatch(StackReducerAction(action)) +fun StackNavContainer.dispatch(action: (StackState) -> StackState) = dispatch(NavigationReducer(action)) -fun NavigationContainer.forward(screen: Screen, vararg screens: Screen) = dispatch(Forward(screen, *screens)) -fun NavigationContainer.replace(screen: Screen, vararg screens: Screen) = dispatch(Replace(screen, *screens)) -fun NavigationContainer.setStack(screen: Screen, vararg screens: Screen) = dispatch(SetStack(screen, *screens)) -fun NavigationContainer.setState(state: StackState) = dispatch(SetStack(state)) -fun NavigationContainer.clearStack() = dispatch(SetStack(StackState())) +fun NavigationContainer.forward(screen: Screen, vararg screens: Screen) = dispatch(Forward(screen, *screens)) +fun NavigationContainer.replace(screen: Screen, vararg screens: Screen) = dispatch(Replace(screen, *screens)) +fun NavigationContainer.setStack(screen: Screen, vararg screens: Screen) = dispatch(SetStack(screen, *screens)) +fun NavigationContainer.setState(state: StackState) = dispatch(SetStack(state)) +fun NavigationContainer.clearStack() = dispatch(SetStack(StackState())) -inline fun NavigationContainer.backTo(including: Boolean = false) = dispatch(BackTo(including)) -fun NavigationContainer.backTo(screen: Screen, including: Boolean = false) = dispatch(BackTo(screen, including)) -fun NavigationContainer.backTo(screenKey: ScreenKey, including: Boolean = false) = dispatch(BackTo(screenKey, including)) -fun NavigationContainer.backTo(pos: Int, including: Boolean = false) = backTo(including) { backToPos, _ -> pos == backToPos } -fun NavigationContainer.backTo(including: Boolean = false, backToCondition: (pos: Int, screen: Screen) -> Boolean) = +inline fun NavigationContainer.backTo(including: Boolean = false) = dispatch(BackTo(including)) +fun NavigationContainer.backTo(screen: Screen, including: Boolean = false) = dispatch(BackTo(screen, including)) +fun NavigationContainer.backTo(screenKey: ScreenKey, including: Boolean = false) = dispatch(BackTo(screenKey, including)) +fun NavigationContainer.backTo(pos: Int, including: Boolean = false) = backTo(including) { backToPos, _ -> pos == backToPos } +fun NavigationContainer.backTo(including: Boolean = false, backToCondition: (pos: Int, screen: Screen) -> Boolean) = dispatch(BackTo(including, backToCondition)) -fun NavigationContainer.backToRoot() = backTo(0) +fun NavigationContainer.backToRoot() = backTo(0) -fun NavigationContainer.removeScreens(condition: (pos: Int, screen: Screen) -> Boolean) = dispatch(RemoveScreens(condition)) +fun NavigationContainer.removeScreens(condition: (pos: Int, screen: Screen) -> Boolean) = dispatch(RemoveScreens(condition)) /** * @param screensToDrop count of screens to drop from top of the stack * @param canEmptyStack if true, then stack can be empty after this action */ -fun NavigationContainer.back(screensToDrop: Int = 1, canEmptyStack: Boolean = false) = +fun NavigationContainer.back(screensToDrop: Int = 1, canEmptyStack: Boolean = false) = dispatch(Back(screensToDrop, canEmptyStack)) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt index a7d62092..700f1bb6 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt @@ -30,7 +30,14 @@ import com.github.terrakok.modo.generateScreenKey import kotlinx.parcelize.Parcelize val LocalStackNavigation: ProvidableCompositionLocal = staticCompositionLocalOf { - error("There is no LocalStackNavigation in hierarchy, or maybe you override provideCompositionLocal and forgot to call supper.") + error("There is no LocalStackNavigation in hierarchy, or maybe you override provideCompositionLocal and forgot to call super.") +} + +/** + * Provides the nearest [StackScreen] in the composition. + */ +val LocalStackScreen: ProvidableCompositionLocal = staticCompositionLocalOf { + error("There is no LocalStackScreen in hierarchy. Wrap in StackScreen or provide it manually.") } /** @@ -39,7 +46,7 @@ val LocalStackNavigation: ProvidableCompositionLocal = static @Stable abstract class StackScreen( navigationModel: StackNavModel -) : ContainerScreen(navigationModel), StackNavContainer { +) : ContainerScreen(navigationModel), StackNavContainer { open val defaultBackHandler: Boolean = true @@ -51,9 +58,14 @@ abstract class StackScreen( TopScreenContent(modifier) } - override fun provideNavigationContainer(): ProvidedValue = + override fun provideNavigationContainer(): ProvidedValue = LocalStackNavigation provides this + override fun provideCompositionLocals(): Array> = arrayOf( + LocalStackNavigation provides this, + LocalStackScreen provides this, + ) + /** * The palace holder screen that is used to support animation of showing first dialog appearance. * You can return null to handle appearance animation by yourself. diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt index 76fa8d79..42a9875f 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt @@ -8,14 +8,14 @@ import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import kotlinx.parcelize.Parcelize -typealias StackNavModel = NavModel +typealias StackNavModel = NavModel -fun StackNavModel(stack: List): StackNavModel = StackNavModel(StackState(stack)) -fun StackNavModel(screen: Screen): StackNavModel = StackNavModel(listOf(screen)) -fun StackNavModel(vararg screens: Screen): StackNavModel = StackNavModel(screens.toList()) +fun StackNavModel(stack: List): StackNavModel = NavModel(StackState(stack)) +fun StackNavModel(screen: Screen): StackNavModel = NavModel(StackState(listOf(screen))) +fun StackNavModel(vararg screens: Screen): StackNavModel = NavModel(StackState(screens.toList())) @Stable -interface StackNavContainer : NavigationContainer +interface StackNavContainer : NavigationContainer @Parcelize data class StackState( diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt b/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt index 0d315287..3991a5d2 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt @@ -18,7 +18,7 @@ private fun getNavigationStateString(prefix: String, navigationState: Navigation is StackState -> { navigationState.stack.map { screen -> when (screen) { - is ContainerScreen<*, *> -> buildString { + is ContainerScreen<*> -> buildString { append(prefix) append(screen.screenKey) appendLine() @@ -35,7 +35,7 @@ private fun getNavigationStateString(prefix: String, navigationState: Navigation append(prefix) append(screen.screenKey) appendLine() - if (screen is ContainerScreen<*, *>) { + if (screen is ContainerScreen<*>) { append(getNavigationStateString("$prefix| ", screen.navigationState)) } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt new file mode 100644 index 00000000..512d4904 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt @@ -0,0 +1,83 @@ +package com.github.terrakok.modo + +import android.os.Parcel +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("DEPRECATION") +class ComposeRendererDisposalTest { + + @Test + fun `When ComposeRenderer is created - Then scope is active`() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + val renderer = createTestRenderer() + assertTrue(renderer.scope.isActive) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun `When ComposeRenderer dispose is called - Then scope is cancelled`() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + val renderer = createTestRenderer() + assertTrue(renderer.scope.isActive, "Scope should be active after creation") + + renderer.dispose() + + assertFalse(renderer.scope.isActive, "Scope should be inactive after dispose") + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun `When container screen is removed from tree - Then renderer can be disposed`() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + val renderer = createTestRenderer() + + // Simulate removing container screen and calling dispose + assertTrue(renderer.scope.isActive) + renderer.dispose() + assertFalse(renderer.scope.isActive) + } finally { + Dispatchers.resetMain() + } + } + + private fun createTestRenderer(): ComposeRenderer { + val state = MockNavigationState() + val navModel: NavModel = NavModel(state) + + @Suppress("UNCHECKED_CAST") + val containerScreen = object : + ContainerScreen(navModel), + Parcelable { + @Composable + override fun Content(modifier: Modifier) = Unit + + override fun describeContents(): Int = 0 + + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit + } + return ComposeRenderer(containerScreen, navModel.stateFlow) + } + + @Parcelize + private class MockNavigationState : NavigationState { + override fun getChildScreens(): List = emptyList() + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt new file mode 100644 index 00000000..a739a447 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt @@ -0,0 +1,206 @@ +package com.github.terrakok.modo + +import android.os.Parcel +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class DeepNavigationStateFlowTest { + + @Test + fun `subtreeStateFlow emits initial state on subscription`() = runTest { + val container = FakeContainer(NestedState()) + + val first = container.subtreeStateFlow(backgroundScope).first() + + assertEquals(NestedState(), first) + } + + @Test + fun `direct dispatch on root - emits new state`() = runTest { + val container = FakeContainer(NestedState()) + val emissions = collectInBackground(container) + + val newScreen = MockScreen() + container.dispatch { it.copy(children = it.children + newScreen) } + runCurrent() + + val expected: List = listOf(NestedState(), NestedState(listOf(newScreen))) + assertEquals(expected, emissions) + } + + @Test + fun `nested container dispatch - root re-emits its state`() = runTest { + val nested = FakeContainer(NestedState()) + val rootInitial = NestedState(listOf(nested)) + val root = FakeContainer(rootInitial) + val emissions = collectInBackground(root) + + val deepScreen = MockScreen() + nested.dispatch { it.copy(children = it.children + deepScreen) } + runCurrent() + + // Root emits twice: initial + re-emit on nested change. Both equal rootInitial + // because the root state itself didn't change — observers re-walk getChildScreens(). + assertEquals(2, emissions.size) + assertEquals(rootInitial, emissions[0]) + assertEquals(rootInitial, emissions[1]) + // And the nested container itself reflects the change + assertEquals( + NestedState(listOf(deepScreen)), + nested.stateFlow.value + ) + } + + @Test + fun `two-level nested dispatch - root re-emits`() = runTest { + val grandchild = FakeContainer(NestedState()) + val child = FakeContainer(NestedState(listOf(grandchild))) + val root = FakeContainer(NestedState(listOf(child))) + val emissions = collectInBackground(root) + + val initialSize = emissions.size + + grandchild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(initialSize + 1, emissions.size) + } + + @Test + fun `removed child container - dispatching on orphan does not emit on root`() = runTest { + val nested = FakeContainer(NestedState()) + val root = FakeContainer(NestedState(listOf(nested))) + val emissions = collectInBackground(root) + + // Remove the nested container from the root. + root.dispatch { NestedState(emptyList()) } + runCurrent() + val sizeAfterRemoval = emissions.size + + // Now dispatch on the orphaned container. The root must NOT emit, because + // flatMapLatest dropped the inner subscription when root's state changed. + nested.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(sizeAfterRemoval, emissions.size) + } + + @Test + fun `newly added child container - dispatch on it emits on root`() = runTest { + val root = FakeContainer(NestedState()) + val emissions = collectInBackground(root) + + // Add a fresh nested container after subscription started. + val nested = FakeContainer(NestedState()) + root.dispatch { it.copy(children = listOf(nested)) } + runCurrent() + val sizeAfterAdd = emissions.size + + // Dispatch on the new child — must propagate. + nested.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(sizeAfterAdd + 1, emissions.size) + } + + @Test + fun `empty children then populated later - deep observation works through transition`() = runTest { + // Root starts with zero children. The inner flow inside flatMapLatest emits the initial + // state and then completes (empty merge). flatMapLatest is still subscribed to the + // outer StateFlow, so when children appear later, a fresh inner flow walks the tree. + val root = FakeContainer(NestedState()) + val emissions = collectInBackground(root) + assertEquals(1, emissions.size, "initial emission only") + + // Add a nested container. + val nested = FakeContainer(NestedState()) + root.dispatch { it.copy(children = listOf(nested)) } + runCurrent() + assertEquals(2, emissions.size, "root re-emits when children grow from empty to one") + + // Add a grandchild under the freshly-attached nested. Deep change must propagate + // because the outer flatMapLatest restart wired up `nested`'s flow. + val grandchild = FakeContainer(NestedState()) + nested.dispatch { it.copy(children = listOf(grandchild)) } + runCurrent() + assertEquals(3, emissions.size, "nested change propagates to root") + + // Dispatch at the deepest level — must propagate through both hops. + grandchild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + assertEquals(4, emissions.size, "grandchild change propagates two levels up") + } + + @Test + fun `swap one child for another - only the live child propagates`() = runTest { + val oldChild = FakeContainer(NestedState()) + val newChild = FakeContainer(NestedState()) + val root = FakeContainer(NestedState(listOf(oldChild))) + val emissions = collectInBackground(root) + + // Replace the child. + root.dispatch { it.copy(children = listOf(newChild)) } + runCurrent() + val sizeAfterSwap = emissions.size + + // Dispatch on the old (orphaned) child — no propagation. + oldChild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + assertEquals(sizeAfterSwap, emissions.size) + + // Dispatch on the new child — propagates. + newChild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + assertEquals(sizeAfterSwap + 1, emissions.size) + } + +} + +private fun TestScope.collectInBackground(container: FakeContainer): MutableList { + val emissions = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + container.subtreeStateFlow(backgroundScope).collect { emissions += it } + } + runCurrent() + return emissions +} + +@Parcelize +private data class NestedState( + val children: List = emptyList() +) : NavigationState { + override fun getChildScreens(): List = children +} + +/** + * Test double: both a [Screen] (so it can live inside another container's state) and a + * [NavigationContainer]. Bypasses ContainerScreen/ComposeRenderer so we don't need a Main dispatcher. + */ +private class FakeContainer( + initialState: NestedState, + override val screenKey: ScreenKey = generateScreenKey() +) : Screen, NavigationContainer { + + private val navModel = NavModel(initialState, screenKey) + + override val stateFlow: StateFlow = navModel.stateFlow + + override fun dispatch(reducer: NavigationReducer) = navModel.dispatch(reducer) + + @Composable + override fun Content(modifier: Modifier) = Unit + + override fun describeContents(): Int = 0 + + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ModoFinishRootScreenDisposalTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ModoFinishRootScreenDisposalTest.kt new file mode 100644 index 00000000..49d587a6 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ModoFinishRootScreenDisposalTest.kt @@ -0,0 +1,137 @@ +package com.github.terrakok.modo + +import android.os.Parcel +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.terrakok.modo.lifecycle.LifecycleDependency +import com.github.terrakok.modo.model.ScreenModelStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Drives [Modo.onRootScreenFinished] through a 3-level container tree and asserts the unified-cleanup + * contract: every renderer scope is cancelled, every screen's [LifecycleDependency.onPreDispose] is + * dispatched, and [ModoDevOptions.onScreenDisposeListener] fires for every screen in the subtree. + */ +@Suppress("DEPRECATION") +class ModoFinishRootScreenDisposalTest { + + private var originalDisposeListener: ((Screen) -> Unit)? = null + + @BeforeEach + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + Modo.rootScreens.clear() + screenCounterKey.set(-1) + ScreenModelStore.removedScreenKeys.clear() + ScreenModelStore.screenModels.clear() + ScreenModelStore.dependencies.clear() + ScreenModelStore.dependencyCounter.set(0L) + ScreenModelStore.lastScreenModelKey.value = null + ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { } + ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { } + originalDisposeListener = ModoDevOptions.onScreenDisposeListener + } + + @AfterEach + fun tearDown() { + ModoDevOptions.onScreenDisposeListener = originalDisposeListener + Dispatchers.resetMain() + } + + @Test + fun `When finishRootScreen called - Then every nested renderer scope is cancelled`() { + val tree = build3LevelTree() + assertTrue(tree.root.renderer.scope.isActive, "root scope should start active") + assertTrue(tree.level2.renderer.scope.isActive, "level2 scope should start active") + assertTrue(tree.level3.renderer.scope.isActive, "level3 scope should start active") + + Modo.onRootScreenFinished(tree.root) + + assertFalse(tree.root.renderer.scope.isActive, "root scope should be cancelled") + assertFalse(tree.level2.renderer.scope.isActive, "level2 scope should be cancelled") + assertFalse(tree.level3.renderer.scope.isActive, "level3 scope should be cancelled") + } + + @Test + fun `When finishRootScreen called - Then onScreenDisposeListener fires for every screen in the tree`() { + val tree = build3LevelTree() + val disposed = mutableListOf() + ModoDevOptions.onScreenDisposeListener = { disposed += it } + + Modo.onRootScreenFinished(tree.root) + + assertEquals(listOf(tree.root, tree.level2, tree.level3, tree.leaf), disposed) + } + + @Test + fun `When finishRootScreen called - Then LifecycleDependency onPreDispose fires for every screen in the tree`() { + val tree = build3LevelTree() + val preDisposed = mutableListOf() + listOf(tree.root, tree.level2, tree.level3, tree.leaf).forEach { screen -> + ScreenModelStore.getOrPutDependency( + screen = screen, + name = LifecycleDependency.KEY, + factory = { RecordingLifecycleDependency(screen, preDisposed) } + ) + } + + Modo.onRootScreenFinished(tree.root) + + assertEquals(listOf(tree.root, tree.level2, tree.level3, tree.leaf), preDisposed) + } + + private fun build3LevelTree(): Tree { + val leaf = MockScreen(ScreenKey("leaf")) + val level3 = TestContainerScreen(TestNavigationState(listOf(leaf)), ScreenKey("l3")) + val level2 = TestContainerScreen(TestNavigationState(listOf(level3)), ScreenKey("l2")) + val root = RootScreen(level2) + Modo.rootScreens[root.screenKey] = root + return Tree(root, level2, level3, leaf) + } + + private data class Tree( + val root: RootScreen, + val level2: TestContainerScreen, + val level3: TestContainerScreen, + val leaf: Screen, + ) + + @Parcelize + private class TestNavigationState(val children: List) : NavigationState { + override fun getChildScreens(): List = children + } + + private class TestContainerScreen( + state: TestNavigationState, + screenKey: ScreenKey, + ) : ContainerScreen(NavModel(state, screenKey)) { + + @Composable + override fun Content(modifier: Modifier) = Unit + + override fun describeContents(): Int = 0 + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit + } + + private class RecordingLifecycleDependency( + private val screen: Screen, + private val tracker: MutableList, + ) : LifecycleDependency { + override fun showTransitionFinished() = Unit + override fun hideTransitionStarted() = Unit + override fun onPreDispose() { + tracker += screen + } + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt index 30c7c725..e7e101df 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt @@ -4,6 +4,11 @@ import android.os.Bundle import com.github.terrakok.modo.model.ScreenModelStore import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.test.assertNotSame @@ -15,6 +20,7 @@ class ModoRootScreenCacheTest { @BeforeEach fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) Modo.rootScreens.clear() screenCounterKey.set(-1) ScreenModelStore.removedScreenKeys.clear() @@ -25,6 +31,11 @@ class ModoRootScreenCacheTest { ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { } } + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + // region Scenario 3: first initialization (savedState == null, inMemoryScreen == null) @Test @@ -168,7 +179,8 @@ class ModoRootScreenCacheTest { // endregion private fun mockBundle(rootScreen: RootScreen<*>, counter: Int): Bundle = mockk { - every { getParcelable>("MODO_GRAPH") } returns rootScreen + every { getParcelable>("MODO_GRAPH", RootScreen::class.java) } returns rootScreen + every { @Suppress("DEPRECATION") getParcelable>("MODO_GRAPH") } returns rootScreen every { getInt("MODO_SCREEN_COUNTER_KEY") } returns counter } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/NavModelConcurrencyTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/NavModelConcurrencyTest.kt new file mode 100644 index 00000000..e5c54d11 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/NavModelConcurrencyTest.kt @@ -0,0 +1,42 @@ +package com.github.terrakok.modo + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * Verifies that [NavModel.dispatch] is atomic under concurrent invocation. + * + * The fix replaces a non-atomic `_navigationState.value = reducer.reduce(_navigationState.value)` + * read-modify-write with `_navigationState.update { ... }`, which performs a CAS loop and is + * safe under contention. Without the fix, concurrent appenders lose updates and the final stack + * size is smaller than the number of dispatches. + */ +class NavModelConcurrencyTest { + + @Test + fun `concurrent dispatch on Dispatchers Default - no updates are lost`() = runBlocking { + val n = 5_000 + val navModel = NavModel(StackState()) + + val jobs = List(n) { + async(Dispatchers.Default) { + navModel.dispatch { state -> state.copy(screens = state.screens + MockScreen()) } + } + } + jobs.awaitAll() + + assertEquals(n, navModel.stateFlow.value.screens.size) + } +} + +@Parcelize +private data class StackState( + val screens: List = emptyList() +) : NavigationState { + override fun getChildScreens(): List = screens +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/NavigationStateFlowShimTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/NavigationStateFlowShimTest.kt new file mode 100644 index 00000000..4c4f0b43 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/NavigationStateFlowShimTest.kt @@ -0,0 +1,86 @@ +package com.github.terrakok.modo + +import android.os.Parcel +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * Regression for the deprecated `navigationStateFlow()` migration shim: it must emit only + * when THIS container's state changes (matching the pre-refactor `snapshotFlow { navigationState }` + * semantics), not on every descendant dispatch. The previous implementation delegated to + * [subtreeFlow] which re-emits on any nested change — a silent semantic regression for + * pre-compiled callers linked against the shim. + */ +@Suppress("DEPRECATION_ERROR") +class NavigationStateFlowShimTest { + + @Test + fun `dispatch on nested container - root shim does NOT emit`() = runTest { + val nested = ShimFakeContainer(ShimState()) + val root = ShimFakeContainer(ShimState(listOf(nested))) + val emissions = collectInBackground(root) + val initialSize = emissions.size + + nested.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(initialSize, emissions.size, "shim must not re-emit on nested dispatch") + } + + @Test + fun `dispatch on root - root shim emits exactly once`() = runTest { + val root = ShimFakeContainer(ShimState()) + val emissions = collectInBackground(root) + val initialSize = emissions.size + + root.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(initialSize + 1, emissions.size, "shim must emit on this container's own dispatch") + } +} + +@Suppress("DEPRECATION_ERROR") +private fun TestScope.collectInBackground(container: ShimFakeContainer): MutableList { + val emissions = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + (container as NavigationContainer).navigationStateFlow().collect { emissions += it } + } + runCurrent() + return emissions +} + +@Parcelize +private data class ShimState( + val children: List = emptyList() +) : NavigationState { + override fun getChildScreens(): List = children +} + +private class ShimFakeContainer( + initialState: ShimState, + override val screenKey: ScreenKey = generateScreenKey() +) : Screen, NavigationContainer { + + private val navModel = NavModel(initialState, screenKey) + + override val stateFlow: StateFlow = navModel.stateFlow + + override fun dispatch(reducer: NavigationReducer) = navModel.dispatch(reducer) + + @Composable + override fun Content(modifier: Modifier) = Unit + + override fun describeContents(): Int = 0 + + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionAddScreensTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerAddScreensTest.kt similarity index 85% rename from modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionAddScreensTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerAddScreensTest.kt index 24b9ed33..51262c31 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionAddScreensTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerAddScreensTest.kt @@ -5,13 +5,13 @@ import com.github.terrakok.modo.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals -class ListNavigationActionAddScreensTest { +class ListReducerAddScreensTest { @Test fun `When add screen to empty list - Then screen is added`() { val screen = MockScreen(ScreenKey("1")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(screen) + val action = ListReducer.AddScreens(screen) val newState = action.reduce(oldState) @@ -25,7 +25,7 @@ class ListNavigationActionAddScreensTest { fun `When add screen to empty list by pos - Then screen is added`() { val screen = MockScreen(ScreenKey("1")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(pos = 0, screen) + val action = ListReducer.AddScreens(pos = 0, screen) val newState = action.reduce(oldState) @@ -40,7 +40,7 @@ class ListNavigationActionAddScreensTest { val screen1 = MockScreen(ScreenKey("1")) val screen2 = MockScreen(ScreenKey("2")) val oldState = ListNavigationState(listOf(screen1)) - val action = ListNavigationAction.AddScreens(pos = 1, screen2) + val action = ListReducer.AddScreens(pos = 1, screen2) val newState = action.reduce(oldState) @@ -56,7 +56,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(screen1, screen2, screen3) + val action = ListReducer.AddScreens(screen1, screen2, screen3) val newState = action.reduce(oldState) @@ -74,7 +74,7 @@ class ListNavigationActionAddScreensTest { val screen4 = MockScreen(ScreenKey("4")) val screen5 = MockScreen(ScreenKey("5")) val oldState = ListNavigationState(listOf(screen1, screen5)) - val action = ListNavigationAction.AddScreens(pos = 1, screen2, screen3, screen4) + val action = ListReducer.AddScreens(pos = 1, screen2, screen3, screen4) val newState = action.reduce(oldState) @@ -90,7 +90,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(pos = 0, screen1, screen2, screen3) + val action = ListReducer.AddScreens(pos = 0, screen1, screen2, screen3) val newState = action.reduce(oldState) @@ -106,7 +106,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf()) - val action = ListNavigationAction.AddScreens(screen1, screen2, screen3, addToEnd = true) + val action = ListReducer.AddScreens(screen1, screen2, screen3, addToEnd = true) val newState = action.reduce(oldState) @@ -122,7 +122,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf()) - val action = ListNavigationAction.AddScreens(screen1, screen2, screen3) + val action = ListReducer.AddScreens(screen1, screen2, screen3) val newState = action.reduce(oldState) @@ -138,7 +138,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1)) - val action = ListNavigationAction.AddScreens(screen2, screen3, addToEnd = true) + val action = ListReducer.AddScreens(screen2, screen3, addToEnd = true) val newState = action.reduce(oldState) @@ -154,7 +154,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen3)) - val action = ListNavigationAction.AddScreens(screen1, screen2) + val action = ListReducer.AddScreens(screen1, screen2) val newState = action.reduce(oldState) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionRemoveScreensTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerRemoveScreensTest.kt similarity index 84% rename from modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionRemoveScreensTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerRemoveScreensTest.kt index c068c134..ead27ab3 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionRemoveScreensTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerRemoveScreensTest.kt @@ -5,13 +5,13 @@ import com.github.terrakok.modo.ScreenKey import kotlin.test.Test import kotlin.test.assertContentEquals -class ListNavigationActionRemoveScreensTest { +class ListReducerRemoveScreensTest { @Test fun `When remove screen by key - Then screen is removed`() { val screen = MockScreen(ScreenKey("2")) val oldState = ListNavigationState(listOf(MockScreen(ScreenKey("1")), screen)) - val action = ListNavigationAction.RemoveScreens(ScreenKey("1")) + val action = ListReducer.RemoveScreens(ScreenKey("1")) val newState = action.reduce(oldState) @@ -29,7 +29,7 @@ class ListNavigationActionRemoveScreensTest { val screen4 = MockScreen(ScreenKey("3")) val screen5 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3, screen4)) - val action = ListNavigationAction.RemoveScreens(screen1, screen3, screen5) + val action = ListReducer.RemoveScreens(screen1, screen3, screen5) val newState = action.reduce(oldState) @@ -45,7 +45,7 @@ class ListNavigationActionRemoveScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3)) - val action = ListNavigationAction.RemoveScreens { _, screen -> screen.screenKey.value == "2" } + val action = ListReducer.RemoveScreens { _, screen -> screen.screenKey.value == "2" } val newState = action.reduce(oldState) @@ -61,7 +61,7 @@ class ListNavigationActionRemoveScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3)) - val action = ListNavigationAction.RemoveScreens(setOf(ScreenKey("1"), ScreenKey("3"))) + val action = ListReducer.RemoveScreens(setOf(ScreenKey("1"), ScreenKey("3"))) val newState = action.reduce(oldState) @@ -77,7 +77,7 @@ class ListNavigationActionRemoveScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3)) - val action = ListNavigationAction.RemoveScreens() + val action = ListReducer.RemoveScreens() val newState = action.reduce(oldState) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionSetTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerSetTest.kt similarity index 86% rename from modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionSetTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerSetTest.kt index e6316dd8..82a8a8c1 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionSetTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerSetTest.kt @@ -5,7 +5,7 @@ import com.github.terrakok.modo.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals -class ListNavigationActionSetTest { +class ListReducerSetTest { @Test fun `When set screens - Then screens are set`() { @@ -13,7 +13,7 @@ class ListNavigationActionSetTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2)) - val action = ListNavigationAction.SetScreens(screen3) + val action = ListReducer.SetScreens(screen3) val newState = action.reduce(oldState) @@ -29,7 +29,7 @@ class ListNavigationActionSetTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2)) - val action = ListNavigationAction.SetScreens(listOf(screen3)) + val action = ListReducer.SetScreens(listOf(screen3)) val newState = action.reduce(oldState) diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index e84e0cfc..2fb0253d 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.debug.logcat) implementation(libs.kotlinx.coroutines.android) + implementation(libs.datastore.preferences) debugImplementation(libs.leakcanary.android) } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt index 22760099..4482a76d 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt @@ -3,25 +3,77 @@ package com.github.terrakok.modo.sample import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Modo.rememberRootScreen +import com.github.terrakok.modo.sample.components.NavigationTreeStrip import com.github.terrakok.modo.sample.screens.MainScreen import com.github.terrakok.modo.sample.screens.containers.SampleStack +import com.github.terrakok.modo.sample.screens.dialogs.SettingsDialog +import com.github.terrakok.modo.stack.forward class ModoSampleActivity : AppCompatActivity() { + @OptIn(ExperimentalModoApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { ActivityContent { - // Remember root screen using rememberSeaveable under the hood. val rootScreen = rememberRootScreen { SampleStack(MainScreen(1)) } - rootScreen.Content(modifier = Modifier.fillMaxSize()) + val stackScreen = rootScreen.screen + val showNavigationTree by SampleAppSettings.instance.showNavigationTree.stateFlow.collectAsState() + val navTreeVisibleScreens by SampleAppSettings.instance.navTreeVisibleScreens.stateFlow.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f)) { + rootScreen.Content(modifier = Modifier.fillMaxSize()) + IconButton( + onClick = { stackScreen.forward(SettingsDialog()) }, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(end = 4.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "App settings", + tint = Color.White + ) + } + } + if (showNavigationTree) { + NavigationTreeStrip( + container = stackScreen, + visibleScreens = navTreeVisibleScreens, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + ) + } + } } } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt index a79bce02..a99753c8 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt @@ -1,15 +1,22 @@ package com.github.terrakok.modo.sample import android.app.Application +import androidx.datastore.preferences.preferencesDataStore import com.github.terrakok.modo.ModoDevOptions import com.github.terrakok.modo.sample.logs.logcat +import kotlinx.coroutines.MainScope import logcat.AndroidLogcatLogger import logcat.LogPriority +internal val Application.dataStore by preferencesDataStore(name = "sample_settings") + class ModoSampleApplication : Application() { + private val applicationScope = MainScope() + override fun onCreate() { super.onCreate() + SampleAppSettings.init(dataStore, applicationScope) AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE) ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { throwable -> throw throwable @@ -27,4 +34,4 @@ class ModoSampleApplication : Application() { it.logcat { "Screen preDisposed" } } } -} \ No newline at end of file +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt b/sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt new file mode 100644 index 00000000..7abd88cc --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt @@ -0,0 +1,25 @@ +package com.github.terrakok.modo.sample + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.github.terrakok.modo.sample.settings.AppSetting +import kotlinx.coroutines.CoroutineScope + +class SampleAppSettings private constructor( + dataStore: DataStore, + scope: CoroutineScope +) { + private val factory = AppSetting.Factory(dataStore, scope) + + val showNavigationTree = factory.boolean("show_navigation_tree", default = true) + val navTreeVisibleScreens = factory.int("nav_tree_visible_screens", default = 2) + + companion object { + lateinit var instance: SampleAppSettings + private set + + internal fun init(dataStore: DataStore, scope: CoroutineScope) { + instance = SampleAppSettings(dataStore, scope) + } + } +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt new file mode 100644 index 00000000..a3431ce1 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt @@ -0,0 +1,95 @@ +package com.github.terrakok.modo.sample.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.github.terrakok.modo.ContainerScreen +import com.github.terrakok.modo.NavigationContainer +import com.github.terrakok.modo.NavigationState +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.multiscreen.MultiScreenState +import com.github.terrakok.modo.stack.StackState +import com.github.terrakok.modo.subtreeStateFlow + +/** + * Demo: observes the navigation tree via [NavigationContainer.subtreeStateFlow] and renders a + * compact textual snapshot. Re-walks the tree on every emission — emissions are notifications, + * not values, since the deep flow re-emits the root state on any descendant change. + */ +@Composable +fun NavigationTreeStrip( + container: NavigationContainer<*>, + modifier: Modifier = Modifier, + visibleScreens: Int = 2, +) { + val scope = rememberCoroutineScope() + val stateFlow = remember(container) { container.subtreeStateFlow(scope) } + val state by stateFlow.collectAsState() + val text = state.compactRender(visibleScreens) + AnimatedContent( + targetState = text, + transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = true) }, + modifier = modifier + .background(Color.Black.copy(alpha = 0.7f)) + .padding(horizontal = 12.dp, vertical = 6.dp), + label = "NavTreeStrip", + ) { currentText -> + Text( + text = currentText, + color = Color.White, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ) + } +} + +private fun NavigationState.compactRender(visibleScreens: Int): String = + buildString { appendNode(prefix = "", state = this@compactRender, visibleScreens = visibleScreens) }.trimEnd() + +private fun StringBuilder.appendNode(prefix: String, state: NavigationState, visibleScreens: Int) { + when (state) { + is StackState -> { + val stack = state.stack + val hidden = (stack.size - visibleScreens).coerceAtLeast(0) + if (hidden > 0) append(prefix).append("…").append(hidden).append(" more\n") + val visible = stack.takeLast(visibleScreens) + visible.forEachIndexed { idx, screen -> + val isTop = idx == visible.lastIndex + appendScreen(prefix, screen, isTop, visibleScreens) + } + } + is MultiScreenState -> { + val selected = state.screens.getOrNull(state.selected) ?: return + append(prefix).append("multi #").append(state.selected).append('\n') + appendScreen("$prefix ", selected, isTop = true, visibleScreens) + } + else -> state.getChildScreens().forEach { appendScreen(prefix, it, isTop = false, visibleScreens) } + } +} + +private fun StringBuilder.appendScreen(prefix: String, screen: Screen, isTop: Boolean, visibleScreens: Int) { + append(prefix) + append(screen.label()) + if (isTop && screen !is ContainerScreen<*>) append(" ◀") + append('\n') + if (screen is ContainerScreen<*>) { + appendNode("$prefix ", screen.navigationState, visibleScreens) + } +} + +private fun Screen.label(): String = this::class.simpleName ?: "Screen" diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index 65422fb2..6cde7cc3 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -32,9 +32,9 @@ import com.github.terrakok.modo.sample.screens.lifecycle.KeyboardWithLifecycleSc import com.github.terrakok.modo.sample.screens.lifecycle.LifecycleSampleScreen import com.github.terrakok.modo.sample.screens.stack.StackActionsScreen import com.github.terrakok.modo.sample.screens.viewmodel.AndroidViewModelSampleScreen -import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.LocalStackScreen import com.github.terrakok.modo.stack.StackNavModel +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.forward import com.github.terrakok.modo.util.getActivity @@ -60,7 +60,7 @@ class MainScreen( MainScreenContent( screenIndex = screenIndex, screenKey = screenKey, - navigation = LocalStackNavigation.current, + navigation = LocalStackScreen.current, modifier = modifier, canOpenFragment = canOpenFragment, ) @@ -71,7 +71,7 @@ class MainScreen( internal fun Screen.MainScreenContent( screenIndex: Int, screenKey: ScreenKey, - navigation: StackNavContainer?, + navigation: StackScreen?, modifier: Modifier = Modifier, canOpenFragment: Boolean = false, ) { @@ -92,7 +92,7 @@ internal fun Screen.MainScreenContent( internal fun Screen.MainScreenContent( screenIndex: Int, counter: Int, - navigation: StackNavContainer, + navigation: StackScreen, modifier: Modifier = Modifier, canOpenFragment: Boolean = false, ) { @@ -118,7 +118,7 @@ internal fun Screen.MainScreenContent( @Composable private fun rememberButtons( screenKey: ScreenKey, - navigation: StackNavContainer?, + navigation: StackScreen?, i: Int, canOpenFragment: Boolean ): GroupedButtonsState { @@ -151,10 +151,7 @@ private fun rememberButtons( ModoButtonSpec("Dialogs & BottomSheets") { navigation?.forward(DialogsPlayground(i + 1)) }, ModoButtonSpec("Multiscreen") { navigation?.forward(SampleMultiScreen()) }, ModoButtonSpec("Custom Container Actions") { navigation?.forward(SampleCustomContainerScreen()) }, - ModoButtonSpec("Removable screen") { navigation?.forward(RemovableItemContainerScreen(useCustomReducer = false)) }, - ModoButtonSpec("Removable screen with reducer") { - navigation?.forward(RemovableItemContainerScreen(useCustomReducer = true)) - }, + ModoButtonSpec("Removable screen") { navigation?.forward(RemovableItemContainerScreen()) }, ModoButtonSpec("List navigation") { navigation?.forward(SampleListNavigation()) }, diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SampleCustomBottomSheet.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SampleCustomBottomSheet.kt index c506618d..b004b129 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SampleCustomBottomSheet.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SampleCustomBottomSheet.kt @@ -11,7 +11,7 @@ import com.github.terrakok.modo.DialogScreen import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.LocalStackScreen import com.github.terrakok.modo.stack.back import kotlinx.parcelize.Parcelize @@ -27,7 +27,7 @@ class SampleCustomBottomSheet( @OptIn(ExperimentalMaterialApi::class) @Composable override fun Content(modifier: Modifier) { - val navigation = LocalStackNavigation.current + val navigation = LocalStackScreen.current val state = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.HalfExpanded) LaunchedEffect(key1 = state.currentValue) { if (state.currentValue == ModalBottomSheetValue.Hidden) { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SamplePermanentDialog.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SamplePermanentDialog.kt index 596dfae4..f2531335 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/SamplePermanentDialog.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/SamplePermanentDialog.kt @@ -13,7 +13,7 @@ import com.github.terrakok.modo.DialogScreen import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.LocalStackScreen import kotlinx.parcelize.Parcelize @OptIn(ExperimentalModoApi::class) @@ -41,7 +41,7 @@ class SamplePermanentDialog( .clip(RoundedCornerShape(16.dp)) .background(Color.White) ) { - MainScreenContent(i, screenKey, LocalStackNavigation.current, modifier) + MainScreenContent(i, screenKey, LocalStackScreen.current, modifier) } } } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt index 044bd97c..6b3411af 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt @@ -42,6 +42,7 @@ fun LifecycleEventsHistory( enabled: Boolean = SampleAppConfig.displayLifecycleEvents, lifecycleEventsHistory: SnapshotStateList? = null, fontSize: TextUnit = 16.sp, + maxLines: Int = Int.MAX_VALUE, ) { if (enabled && !LocalInspectionMode.current) { val lifecycleEventsHistory = lifecycleEventsHistory ?: viewModel(key = key).lifecycleEventsHistory @@ -72,7 +73,7 @@ fun LifecycleEventsHistory( ) } ) { - for (item in lifecycleEventsHistory) { + for (item in lifecycleEventsHistory.takeLast(maxLines)) { Text(text = item.name, fontSize = fontSize) if (item == Lifecycle.Event.ON_STOP) { Divider( @@ -89,8 +90,10 @@ fun LifecycleEventsHistory( fun BoxScope.LifecycleEventsHistory( modifier: Modifier = Modifier, alignment: Alignment = Alignment.TopEnd, + maxLines: Int = Int.MAX_VALUE, ) = LifecycleEventsHistory( fontSize = 8.sp, + maxLines = maxLines, modifier = modifier .background(Color.White.copy(alpha = 0.5f)) .align(alignment) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt index 1456e6eb..7306040d 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt @@ -1,17 +1,14 @@ package com.github.terrakok.modo.sample.screens.containers +import com.github.terrakok.modo.NavigationContainer import com.github.terrakok.modo.Screen -import com.github.terrakok.modo.multiscreen.MultiScreenReducerAction import com.github.terrakok.modo.multiscreen.MultiScreenState -class AddTab( - val id: String, - val rootScreen: Screen -) : MultiScreenReducerAction { - override fun reduce(oldState: MultiScreenState): MultiScreenState { - return MultiScreenState( - oldState.screens + SampleStack(rootScreen), - oldState.selected - ) - } +fun NavigationContainer.addTab( + rootScreen: Screen +) = dispatch { oldState -> + MultiScreenState( + oldState.screens + SampleStack(rootScreen), + oldState.selected + ) } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt index 5a40cd7b..87f6cfba 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt @@ -1,5 +1,6 @@ package com.github.terrakok.modo.sample.screens.containers +import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -26,7 +27,6 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.list.ListNavigationAction import com.github.terrakok.modo.list.ListNavigationState import com.github.terrakok.modo.list.removeScreens import com.github.terrakok.modo.sample.components.CancelButton @@ -36,7 +36,7 @@ import kotlinx.parcelize.Parcelize @Parcelize class HorizontalPagerScreen( - private val navModel: NavModel = NavModel( + private val navModel: NavModel = NavModel( ListNavigationState( listOf( SampleStack(MainScreen(0)), @@ -45,7 +45,7 @@ class HorizontalPagerScreen( ) ) ) -) : ContainerScreen(navModel) { +) : ContainerScreen(navModel), Parcelable { @Composable override fun Content(modifier: Modifier) { @@ -75,7 +75,13 @@ class HorizontalPagerScreen( ) } IconButton( - onClick = { dispatch(AddStack) }, + onClick = { + dispatch { oldState -> + ListNavigationState( + oldState.screens + SampleStack(MainScreen(0)) + ) + } + }, modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), ) { Icon(painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Add") @@ -100,9 +106,4 @@ class HorizontalPagerScreen( } } - object AddStack : ListNavigationAction { - override fun reduce(oldState: ListNavigationState): ListNavigationState = ListNavigationState( - oldState.screens + SampleStack(MainScreen(0)) - ) - } } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt deleted file mode 100644 index e0244fb0..00000000 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.terrakok.modo.sample.screens.containers - -import com.github.terrakok.modo.multiscreen.MultiScreenAction - -/** - * The sample of the action that is handled by reducer - */ -internal class RemoveTabAction(val pos: Int) : MultiScreenAction \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt new file mode 100644 index 00000000..1c6e5404 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt @@ -0,0 +1,16 @@ +package com.github.terrakok.modo.sample.screens.containers + +import com.github.terrakok.modo.NavigationReducer +import com.github.terrakok.modo.multiscreen.MultiScreenState + +/** + * The sample of the action that is handled by reducer + */ +class RemoveTabReducer(private val pos: Int) : NavigationReducer { + + override fun reduce(oldState: MultiScreenState): MultiScreenState = oldState.copy( + screens = oldState.screens.filterIndexed { index, _ -> index != pos }, + selected = if (oldState.selected == pos) 0 else oldState.selected + ) + +} \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt index 8ad42b0b..07fabe1a 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt @@ -24,15 +24,11 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.multiscreen.MultiScreen -import com.github.terrakok.modo.multiscreen.MultiScreenAction import com.github.terrakok.modo.multiscreen.MultiScreenNavModel -import com.github.terrakok.modo.multiscreen.MultiScreenState -import com.github.terrakok.modo.multiscreen.selectContainer +import com.github.terrakok.modo.multiscreen.selectScreen import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.screens.MainScreen -import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Suppress("MagicNumber") @@ -48,18 +44,6 @@ internal class SampleMultiScreen( ) ) : MultiScreen(navModel) { - @IgnoredOnParcel - override val reducer: NavigationReducer = NavigationReducer { action, state -> - if (action is RemoveTabAction && action.pos in state.screens.indices) { - state.copy( - screens = state.screens.filterIndexed { index, _ -> index != action.pos }, - selected = if (state.selected == action.pos) 0 else state.selected - ) - } else { - null - } - } - @Composable override fun Content(modifier: Modifier) { var showAllStacks by rememberSaveable { @@ -70,7 +54,7 @@ internal class SampleMultiScreen( TopContent(showAllStacks) if (navigationState.screens.size > 1) { CancelButton( - onClick = { dispatch(RemoveTabAction(navigationState.selected)) }, + onClick = { dispatch(RemoveTabReducer(navigationState.selected)) }, contentDescription = "Cansel screen", modifier = Modifier .align(Alignment.TopEnd) @@ -91,12 +75,12 @@ internal class SampleMultiScreen( modifier = Modifier.weight(1f), isSelected = navigationState.selected == tabPos, tabPos = tabPos, - onTabClick = { selectContainer(tabPos) } + onTabClick = { selectScreen(tabPos) } ) } Text( modifier = Modifier - .clickable { dispatch(AddTab(navigationState.screens.size.toString(), MainScreen(1))) } + .clickable { addTab(MainScreen(1)) } .padding(16.dp), textAlign = TextAlign.Center, text = "[+]" diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt index 2b48e428..3eb15c6d 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt @@ -8,7 +8,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -18,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import com.github.terrakok.modo.DialogScreen import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.Screen import com.github.terrakok.modo.sample.SlideTransition import com.github.terrakok.modo.sample.screens.base.LifecycleEventsHistory @@ -26,28 +33,23 @@ import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheet import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheetStack import com.github.terrakok.modo.stack.DialogPlaceHolder import com.github.terrakok.modo.stack.StackNavModel -import com.github.terrakok.modo.stack.StackReducerAction import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import kotlinx.parcelize.Parcelize -class OpenActivityAction( - private val context: Context, - private val clazz: Class<*> -) : StackReducerAction { - override fun reduce(oldState: StackState): StackState { - context.startActivity( - Intent(context, clazz) - ) - return oldState - } - - companion object { - inline operator fun invoke(context: Context) = OpenActivityAction(context, T::class.java) - } +fun OpenActivityAction( + context: Context, + clazz: Class<*> +) = NavigationReducer { oldState -> + context.startActivity( + Intent(context, clazz) + ) + oldState } +inline fun OpenActivityAction(context: Context) = OpenActivityAction(context, T::class.java) + @Parcelize open class SampleStack( private val stackNavModel: StackNavModel @@ -58,21 +60,31 @@ open class SampleStack( @Composable override fun Content(modifier: Modifier) { LogLifecycle() - Box(modifier.fillMaxSize()) { - TopScreenContent( - modifier = Modifier.fillMaxSize(), - dialogModifier = Modifier.fillMaxSize() - ) { contentModifier -> - SlideTransition(contentModifier) + Column(modifier) { + // The strip below physically sits at the bottom of the window and pads the bottom + // system bar. Tell descendants of this Box to treat that inset as already handled, + // otherwise ButtonsScreenContent.windowInsetsPadding(WindowInsets.systemBars) doubles + // the bottom padding. consumeWindowInsets affects descendants only; the strip is a + // sibling, so it still sees and pads the full bottom inset. + Box( + Modifier + .weight(1f) + .consumeWindowInsets(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + ) { + TopScreenContent( + modifier = Modifier.fillMaxSize(), + dialogModifier = Modifier.fillMaxSize() + ) { contentModifier -> + SlideTransition(contentModifier) + } + LifecycleEventsHistory( + fontSize = 8.sp, + modifier = Modifier + .background(Color.White.copy(alpha = 0.5f)) + .align(Alignment.TopEnd) + ) } - LifecycleEventsHistory( - fontSize = 8.sp, - modifier = Modifier - .background(Color.White.copy(alpha = 0.5f)) - .align(Alignment.TopEnd) - ) } - } @OptIn(ExperimentalModoApi::class) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt index 5135d294..9a68229e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.lazylist.screenItems import com.github.terrakok.modo.list.ListNavModel -import com.github.terrakok.modo.list.ListNavigationAction import com.github.terrakok.modo.list.ListNavigationState +import com.github.terrakok.modo.list.addScreens import com.github.terrakok.modo.list.removeScreens import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.screens.MainScreen @@ -53,9 +53,8 @@ class StackInLazyColumnScreen( } } ) -) : ContainerScreen( - navModel -) { +) : ContainerScreen(navModel) { + @OptIn(ExperimentalFoundationApi::class) @Suppress("LongMethod") @Composable @@ -63,7 +62,7 @@ class StackInLazyColumnScreen( val lazyColumnState = rememberLazyListState() Scaffold( floatingActionButton = { - FloatingActionButton(onClick = { dispatch(ListNavigationAction.AddScreens(SampleStack(MainScreen(0)))) }) { + FloatingActionButton(onClick = { addScreens(SampleStack(MainScreen(0)), addToEnd = true) }) { Icon(painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Add screen") } }, @@ -90,7 +89,7 @@ class StackInLazyColumnScreen( .padding(horizontal = 16.dp) .fillMaxWidth(), onClick = { - dispatch(ListNavigationAction.AddScreens(pos = 0, SampleStack(MainScreen(0)))) + addScreens(pos = 0, SampleStack(MainScreen(0))) } ) { Text(text = "Add item", modifier = Modifier.align(Alignment.CenterVertically)) @@ -124,7 +123,7 @@ class StackInLazyColumnScreen( .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars), onClick = { - dispatch(ListNavigationAction.AddScreens(SampleStack(MainScreen(0)))) + addScreens(SampleStack(MainScreen(0)), addToEnd = true) } ) { Text(text = "Add item", modifier = Modifier.align(Alignment.CenterVertically)) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt index 09ed0e60..acd5f4ef 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt @@ -15,7 +15,6 @@ import com.github.terrakok.modo.LocalContainerScreen import com.github.terrakok.modo.NavModel import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationState -import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey @@ -32,22 +31,21 @@ data class RemovableItemContainerState( override fun getChildScreens(): List = listOfNotNull(screen1, screen2, screen3, screen4) } -internal sealed interface RemovableItemContainerAction : ReducerAction { - data object Remove : RemovableItemContainerAction { - override fun reduce(oldState: RemovableItemContainerState): RemovableItemContainerState = - oldState.copy(screen3 = null) +internal fun interface RemovableItemContainerReducer : NavigationReducer + +internal object RemovableItemContainerReducers { + val Remove = RemovableItemContainerReducer { oldState -> + oldState.copy(screen3 = null) } - data object CreateScreen : RemovableItemContainerAction { - override fun reduce(oldState: RemovableItemContainerState): RemovableItemContainerState = - oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) + val CreateScreen = RemovableItemContainerReducer { oldState -> + oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) } } @Parcelize internal class RemovableItemContainerScreen( - private val useCustomReducer: Boolean = false, - private val navModel: NavModel = NavModel( + private val navModel: NavModel = NavModel( RemovableItemContainerState( NestedScreen(canBeRemoved = false), NestedScreen(canBeRemoved = false), @@ -55,23 +53,7 @@ internal class RemovableItemContainerScreen( NestedScreen(canBeRemoved = false), ) ) -) : ContainerScreen(navModel) { - - override val reducer: NavigationReducer? - get() = if (useCustomReducer) { - NavigationReducer { action, state -> - when (action) { - is RemovableItemContainerAction.Remove -> { - state.copy(screen3 = null) - } - is RemovableItemContainerAction.CreateScreen -> { - state.copy(screen3 = NestedScreen(canBeRemoved = true)) - } - } - } - } else { - null - } +) : ContainerScreen(navModel) { @Composable override fun Content(modifier: Modifier) { @@ -95,7 +77,7 @@ internal class RemovableItemContainerScreen( Column { Button( modifier = Modifier.fillMaxWidth(), - onClick = { dispatch(RemovableItemContainerAction.CreateScreen) } + onClick = { dispatch(RemovableItemContainerReducers.CreateScreen) } ) { Text(text = "Create screen") } @@ -115,7 +97,7 @@ internal class NestedScreen( val parent = LocalContainerScreen.current as RemovableItemContainerScreen InnerContent( title = screenKey.value, - onRemoveClick = takeIf { canBeRemoved }?.let { { parent.dispatch(RemovableItemContainerAction.Remove) } }, + onRemoveClick = takeIf { canBeRemoved }?.let { { parent.dispatch(RemovableItemContainerReducers.Remove) } }, modifier = Modifier .fillMaxWidth() .height(400.dp) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt index b7aff128..461e77ab 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt @@ -29,9 +29,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.NavigationAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationState -import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import kotlinx.parcelize.Parcelize @@ -45,10 +44,9 @@ internal data class CustomContainerState( override fun getChildScreens(): List = screens } -internal interface CustomContainerAction : NavigationAction -internal fun interface CustomContainerReducerAction : CustomContainerAction, ReducerAction +internal fun interface CustomContainerReducer : NavigationReducer -internal class RemoveScreen(val screenKey: ScreenKey) : CustomContainerReducerAction { +internal class RemoveScreen(val screenKey: ScreenKey) : CustomContainerReducer { override fun reduce(oldState: CustomContainerState): CustomContainerState = CustomContainerState( oldState.screens.filter { it.screenKey != screenKey } ) @@ -61,8 +59,8 @@ internal val LocalSampleCustomNavigation = compositionLocalOf = NavModel(CustomContainerState(listOf(InnerScreen()))) -) : ContainerScreen(navModel) { + private val navModel: NavModel = NavModel(CustomContainerState(listOf(InnerScreen()))) +) : ContainerScreen(navModel) { override fun provideCompositionLocals(): Array> = arrayOf(LocalSampleCustomNavigation provides this) @@ -103,11 +101,9 @@ internal class SampleCustomContainerScreen( Column { Button( onClick = { - navModel.dispatch( - CustomContainerReducerAction { state -> - CustomContainerState(listOf(InnerScreen()) + state.screens) - } - ) + navModel.dispatch { state -> + CustomContainerState(listOf(InnerScreen()) + state.screens) + } }, modifier = Modifier.fillMaxWidth() ) { @@ -115,11 +111,9 @@ internal class SampleCustomContainerScreen( } Button( onClick = { - navModel.dispatch( - CustomContainerReducerAction { state -> - CustomContainerState(state.screens.reversed()) - } - ) + navModel.dispatch { state -> + CustomContainerState(state.screens.reversed()) + } }, modifier = Modifier.fillMaxWidth() ) { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleBottomSheet.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleBottomSheet.kt index 4221812e..ab7e9673 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleBottomSheet.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleBottomSheet.kt @@ -14,7 +14,7 @@ import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.sample.screens.MainScreenContent -import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.LocalStackScreen import com.github.terrakok.modo.stack.back import kotlinx.parcelize.Parcelize @@ -46,7 +46,7 @@ class SampleBottomSheet( @Composable override fun Content(modifier: Modifier) { SetupSystemBar() - val navigation = LocalStackNavigation.current + val navigation = LocalStackScreen.current val state = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.HalfExpanded) LaunchedEffect(key1 = state.currentValue) { if (state.currentValue == ModalBottomSheetValue.Hidden) { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt index 79263f3f..d1d67569 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SampleDialog.kt @@ -26,7 +26,7 @@ import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.lifecycle.LifecycleScreenEffect import com.github.terrakok.modo.sample.screens.MainScreenContent import com.github.terrakok.modo.sample.screens.base.ButtonsScreenContent -import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.LocalStackScreen import com.github.terrakok.modo.stack.StackScreen import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -67,7 +67,7 @@ class SampleDialog( logcat(tag = "SampleDialog") { "$screenKey $event" } } } - val navigation = LocalStackNavigation.current + val navigation = LocalStackScreen.current if (systemDialog) { Box(modifier) { val contentModifier = Modifier diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt new file mode 100644 index 00000000..63878583 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt @@ -0,0 +1,134 @@ +package com.github.terrakok.modo.sample.screens.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.DialogScreen +import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.sample.SampleAppSettings +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.back +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import kotlin.math.roundToInt + +private const val DIALOG_WIDTH_FRACTION = 0.85f +private const val NAV_TREE_SLIDER_MAX = 10f +private const val NAV_TREE_SLIDER_STEPS = 8 + +@OptIn(ExperimentalModoApi::class) +@Parcelize +class SettingsDialog( + override val screenKey: ScreenKey = generateScreenKey() +) : DialogScreen { + + override fun provideDialogConfig(): DialogScreen.DialogConfig = DialogScreen.DialogConfig.Custom + + @Composable + override fun Content(modifier: Modifier) { + val navigation = LocalStackNavigation.current + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Card( + shape = RoundedCornerShape(16.dp), + elevation = 8.dp, + modifier = Modifier + .fillMaxWidth(DIALOG_WIDTH_FRACTION) + .clickable( + enabled = false, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) {} + ) { + SettingsBody(onCloseClick = { navigation.back() }) + } + } + } +} + +@Composable +internal fun SettingsBody(onCloseClick: () -> Unit) { + Column(modifier = Modifier.padding(24.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Settings", style = MaterialTheme.typography.h6) + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close settings" + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(16.dp)) + val scope = rememberCoroutineScope() + val showNavigationTree by SampleAppSettings.instance.showNavigationTree.stateFlow.collectAsState() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Show navigation tree", + style = MaterialTheme.typography.body1 + ) + Switch( + checked = showNavigationTree, + onCheckedChange = { scope.launch { SampleAppSettings.instance.showNavigationTree.update(it) } } + ) + } + Spacer(modifier = Modifier.height(8.dp)) + val navTreeVisibleScreens by SampleAppSettings.instance.navTreeVisibleScreens.stateFlow.collectAsState() + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "Nav tree visible screens", style = MaterialTheme.typography.body1) + Text(text = "$navTreeVisibleScreens", style = MaterialTheme.typography.body1) + } + Slider( + value = navTreeVisibleScreens.toFloat(), + onValueChange = { scope.launch { SampleAppSettings.instance.navTreeVisibleScreens.update(it.roundToInt()) } }, + valueRange = 1f..NAV_TREE_SLIDER_MAX, + steps = NAV_TREE_SLIDER_STEPS, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt index 567f06fd..723747aa 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.dispatch import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.sample.screens.ButtonsState import com.github.terrakok.modo.sample.screens.GroupedButtonsState @@ -21,12 +22,11 @@ import com.github.terrakok.modo.sample.screens.dialogs.SampleDialog import com.github.terrakok.modo.sample.screens.dialogs.SampleDialogWithStack import com.github.terrakok.modo.stack.Back import com.github.terrakok.modo.stack.Forward -import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.LocalStackScreen +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.backTo -import com.github.terrakok.modo.stack.dispatch import com.github.terrakok.modo.stack.forward import com.github.terrakok.modo.stack.removeScreens import com.github.terrakok.modo.stack.replace @@ -51,7 +51,7 @@ internal class StackActionsScreen( screenName = "StackActionsScreen", screenIndex = screenIndex, state = rememberButtons( - LocalStackNavigation.current, + LocalStackScreen.current, screenKey, screenIndex ) @@ -62,14 +62,15 @@ internal class StackActionsScreen( @Suppress("LongMethod", "MagicNumber") @Composable private fun rememberButtons( - navigation: StackNavContainer, + navigation: StackScreen, screenKey: ScreenKey, screenIndex: Int ): GroupedButtonsState { val coroutineScope = rememberCoroutineScope() + val navigationState = navigation.navigationState val isFirstScreen by remember { derivedStateOf { - navigation.navigationState.stack.first().screenKey == screenKey + navigationState.stack.first().screenKey == screenKey } } return remember(navigation, isFirstScreen) { @@ -99,7 +100,7 @@ private fun rememberButtons( } }, ModoButtonSpec("Remove previous") { - val prevScreenIndex = navigation.navigationState.stack.lastIndex - 1 + val prevScreenIndex = navigation.stateFlow.value.stack.lastIndex - 1 navigation.removeScreens { pos, screen -> pos == prevScreenIndex } }, ModoButtonSpec("Back to '3'") { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt index a447c225..3061efaa 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt @@ -13,7 +13,7 @@ import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.sample.screens.MainScreenContent import com.github.terrakok.modo.sample.screens.base.COUNTER_DELAY_MS -import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.LocalStackScreen import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive @@ -32,7 +32,7 @@ internal class AndroidViewModelSampleScreen( val viewModel: SampleViewModel = viewModel { SampleViewModel(screenPos, createSavedStateHandle()) } - MainScreenContent(screenPos, viewModel.stateFlow.collectAsState().value, LocalStackNavigation.current, modifier) + MainScreenContent(screenPos, viewModel.stateFlow.collectAsState().value, LocalStackScreen.current, modifier) } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt b/sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt new file mode 100644 index 00000000..e24342fa --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt @@ -0,0 +1,45 @@ +package com.github.terrakok.modo.sample.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class AppSetting internal constructor( + private val dataStore: DataStore, + private val key: Preferences.Key, + private val defaultValue: T, + scope: CoroutineScope +) { + val stateFlow: StateFlow = dataStore.data + .map { it[key] ?: defaultValue } + .stateIn(scope, SharingStarted.Eagerly, defaultValue) + + val value: T get() = stateFlow.value + + suspend fun update(value: T) { + dataStore.edit { it[key] = value } + } + + class Factory( + private val dataStore: DataStore, + private val scope: CoroutineScope + ) { + fun boolean(key: String, default: Boolean) = AppSetting(dataStore, booleanPreferencesKey(key), default, scope) + fun int(key: String, default: Int) = AppSetting(dataStore, intPreferencesKey(key), default, scope) + fun string(key: String, default: String) = AppSetting(dataStore, stringPreferencesKey(key), default, scope) + fun float(key: String, default: Float) = AppSetting(dataStore, floatPreferencesKey(key), default, scope) + fun long(key: String, default: Long) = AppSetting(dataStore, longPreferencesKey(key), default, scope) + fun stringSet(key: String, default: Set) = AppSetting(dataStore, stringSetPreferencesKey(key), default, scope) + } +} diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt index 9d5b4381..9a7b5e26 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.LocalStackScreen +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.forward import kotlinx.parcelize.Parcelize @@ -32,7 +32,7 @@ class EmailScreen( @Composable override fun Content(modifier: Modifier) { - val navigation: StackNavContainer = LocalStackNavigation.current + val navigation: StackScreen = LocalStackScreen.current EmailScreenContent( modifier = modifier, onContinueClick = { email -> diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt index 53726ddd..e8805fe2 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.Modifier import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey -import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.LocalStackScreen +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.forward import kotlinx.parcelize.Parcelize @@ -17,7 +17,7 @@ class EmailScreenFinal( @Composable override fun Content(modifier: Modifier) { - val navigation: StackNavContainer = LocalStackNavigation.current + val navigation: StackScreen = LocalStackScreen.current EmailScreenContent( modifier = modifier, onContinueClick = { email -> diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt index 03cb0c74..5ffa593a 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt @@ -1,5 +1,6 @@ package io.github.ikarenkov.workshop.screens.profile +import android.os.Parcelable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -20,7 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.NavigationAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import com.github.terrakok.modo.stack.LocalStackNavigation @@ -33,8 +34,9 @@ import kotlinx.parcelize.Parcelize import org.koin.androidx.compose.koinViewModel @Parcelize +@Suppress("Wrapping") class EnhancedProfileScreen( - private val navModel: NavModel = NavModel( + private val navModel: NavModel = NavModel( // TODO: Workshop 6.2.4 - set initial state EnhancedProfileNavigationState( ClimberPersonalInfoScreen(), @@ -43,9 +45,9 @@ class EnhancedProfileScreen( ) ) // TODO: Workshop 6.2.1 - inherit from ContainerScreen -) : ContainerScreen( +) : ContainerScreen( navModel -) { +), Parcelable { @Composable override fun Content(modifier: Modifier) { @@ -146,7 +148,8 @@ data class EnhancedProfileNavigationState( } // TODO: Workshop 6.2.3 - define navigation action -class EnhancedProfileNavigationActionNoOp : NavigationAction +@Deprecated("Use NavigationReducer directly.") +fun interface EnhancedProfileNavigationAction : NavigationReducer @Preview @Composable diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt index c678bfa0..23002a20 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt @@ -1,5 +1,6 @@ package io.github.ikarenkov.workshop.screens.profile +import android.os.Parcelable import androidx.compose.material3.Card import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -18,10 +19,11 @@ import org.koin.core.parameter.parametersOf @Parcelize class EnhancedProfileScreenFinal( - private val navModel: NavModel = NavModel(EnhancedProfileNavigationState()) -) : ContainerScreen( + private val navModel: NavModel = NavModel(EnhancedProfileNavigationState()) +) : ContainerScreen( navModel -) { +), + Parcelable { @Composable override fun Content(modifier: Modifier) { diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt index 2bac713f..ffc76b56 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt @@ -2,7 +2,7 @@ package io.github.ikarenkov.workshop.screens.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.terrakok.modo.ReducerAction +import com.github.terrakok.modo.NavigationReducer import io.github.ikarenkov.workshop.core.mapStateFlow import io.github.ikarenkov.workshop.data.ClimberProfileRepository import io.github.ikarenkov.workshop.domain.ClimberProfile @@ -26,7 +26,7 @@ class EnhancedProfileViewModelFinal( viewModelScope.launch { climberProfileRepository.climberProfile.collect { profile -> enhancedProfileScreenFinal.dispatch( - EnhancedProfileNavigationAction( + EnhancedProfileNavigationReducer( showClimberProfile = profile.dateOfBirth != null, showBoulderLever = profile.boulderLevel.hasAllGrades(), showLeadLevel = profile.sportLevel.hasAllGrades() @@ -49,11 +49,11 @@ class EnhancedProfileViewModelFinal( ) } -class EnhancedProfileNavigationAction( +class EnhancedProfileNavigationReducer( private val showClimberProfile: Boolean, private val showLeadLevel: Boolean, private val showBoulderLever: Boolean, -) : ReducerAction { +) : NavigationReducer { override fun reduce(oldState: EnhancedProfileNavigationState): EnhancedProfileNavigationState = oldState.copy( climbingProfileScreen = if (showClimberProfile) { oldState.climbingProfileScreen ?: ClimberPersonalInfoScreen() @@ -71,5 +71,4 @@ class EnhancedProfileNavigationAction( null } ) - } \ No newline at end of file diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt index 55627639..5a0bdc44 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt @@ -2,8 +2,7 @@ package io.github.ikarenkov.workshop.screens.profile_setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.terrakok.modo.navigationStateStateFlow -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.forward @@ -18,7 +17,7 @@ class ProfileSetupFlowViewModelFinal( private val restartFlow: Boolean, // Workshop 5.1.1 - take screens as parametrs private val profileSetupFlowScreen: ProfileSetupFlowScreenFinal, - private val parentNavigation: StackNavContainer, + private val parentNavigation: StackScreen, private val climberProfileRepository: ClimberProfileRepository, ) : ViewModel() { @@ -30,7 +29,7 @@ class ProfileSetupFlowViewModelFinal( // Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile val state: StateFlow = combineStateFlow( - profileSetupFlowScreen.navigationStateStateFlow(viewModelScope), + profileSetupFlowScreen.stateFlow, climberProfileRepository.climberProfile, viewModelScope, ) { navigationState, profile ->